//go:build soapy // Package soapysdr provides a pure-Go SoapySDR driver that loads the // SoapySDR shared library at runtime via dlopen/LoadLibrary. // No CGO required. No C compiler required. // // Build with: go build -tags soapy // Requires: SoapySDR shared library installed on the system. // Windows: SoapySDR.dll (via PothosSDR) // Linux: libSoapySDR.so (via package manager) // macOS: libSoapySDR.dylib (via brew) package soapysdr import ( "context" "fmt" "math" "sync" "sync/atomic" "time" "unsafe" "github.com/jan/fm-rds-tx/internal/output" "github.com/jan/fm-rds-tx/internal/platform" ) // nativeDriver implements platform.SoapyDriver using runtime-loaded SoapySDR. type nativeDriver struct { mu sync.Mutex lib *soapyLib cfg platform.SoapyConfig dev uintptr // SoapySDRDevice* stream uintptr // SoapySDRStream* mtu int started bool configured bool framesWritten atomic.Uint64 samplesWritten atomic.Uint64 underruns atomic.Uint64 lastError string lastErrorAt string } // NewNativeDriver creates an uninitialized SoapySDR native driver. func NewNativeDriver() platform.SoapyDriver { lib, err := loadSoapyLib() if err != nil { // Return a driver that will fail on Configure with a clear message return &nativeDriver{lastError: fmt.Sprintf("load SoapySDR library: %v", err)} } return &nativeDriver{lib: lib} } // Enumerate lists available SoapySDR devices. func Enumerate() ([]map[string]string, error) { lib, err := loadSoapyLib() if err != nil { return nil, fmt.Errorf("load SoapySDR: %w", err) } return lib.enumerate() } func (d *nativeDriver) Name() string { return "soapy-native" } func (d *nativeDriver) Configure(_ context.Context, cfg platform.SoapyConfig) error { d.mu.Lock() defer d.mu.Unlock() if d.lib == nil { return fmt.Errorf("soapy: library not loaded: %s", d.lastError) } // Close existing if d.dev != 0 { if d.stream != 0 { d.lib.deactivateStream(d.dev, d.stream) d.lib.closeStream(d.dev, d.stream) d.stream = 0 } d.lib.unmakeDevice(d.dev) d.dev = 0 } d.cfg = cfg // Open device dev, err := d.lib.makeDevice(cfg.Driver, cfg.Device, cfg.DeviceArgs) if err != nil { return err } d.dev = dev // Sample rate rate := cfg.SampleRateHz if rate <= 0 { rate = 528000 } if err := d.lib.setSampleRate(d.dev, dirTX, 0, rate); err != nil { return err } // Frequency if cfg.CenterFreqHz > 0 { if err := d.lib.setFrequency(d.dev, dirTX, 0, cfg.CenterFreqHz); err != nil { return err } } // Gain if cfg.GainDB != 0 { _ = d.lib.setGain(d.dev, dirTX, 0, cfg.GainDB) } // Setup TX stream (CF32) stream, err := d.lib.setupStream(d.dev, dirTX, "CF32", []uint{0}) if err != nil { return err } d.stream = stream d.mtu = d.lib.getStreamMTU(d.dev, d.stream) if d.mtu <= 0 { d.mtu = 4096 } d.configured = true return nil } func (d *nativeDriver) Capabilities(_ context.Context) (platform.DeviceCaps, error) { d.mu.Lock() defer d.mu.Unlock() if d.dev == 0 || d.lib == nil { return platform.DeviceCaps{}, fmt.Errorf("device not opened") } gMin, gMax := d.lib.getGainRange(d.dev, dirTX, 0) return platform.DeviceCaps{ MinSampleRate: 521e3, MaxSampleRate: 61.44e6, HasGain: true, GainMinDB: gMin, GainMaxDB: gMax, Channels: []int{0}, }, nil } func (d *nativeDriver) Start(_ context.Context) error { d.mu.Lock() defer d.mu.Unlock() if !d.configured || d.dev == 0 || d.stream == 0 { return fmt.Errorf("soapy: not configured") } if d.started { return fmt.Errorf("soapy: already started") } if err := d.lib.activateStream(d.dev, d.stream); err != nil { return err } d.started = true return nil } func (d *nativeDriver) Write(_ context.Context, frame *output.CompositeFrame) (int, error) { d.mu.Lock() lib, dev, stream, started, mtu := d.lib, d.dev, d.stream, d.started, d.mtu d.mu.Unlock() if !started || dev == 0 || stream == 0 { return 0, fmt.Errorf("soapy: stream not active") } if frame == nil || len(frame.Samples) == 0 { return 0, nil } total := len(frame.Samples) written := 0 for written < total { chunk := total - written if chunk > mtu { chunk = mtu } // IQSample is {I float32, Q float32} — contiguous CF32 in memory ptr := unsafe.Pointer(&frame.Samples[written]) n, err := lib.writeStream(dev, stream, ptr, chunk) if err != nil { d.mu.Lock() d.lastError = err.Error() d.lastErrorAt = time.Now().UTC().Format(time.RFC3339) d.underruns.Add(1) d.mu.Unlock() return written, err } written += n } d.framesWritten.Add(1) d.samplesWritten.Add(uint64(written)) return written, nil } func (d *nativeDriver) Stop(_ context.Context) error { d.mu.Lock() defer d.mu.Unlock() if !d.started { return nil } if d.dev != 0 && d.stream != 0 { d.lib.deactivateStream(d.dev, d.stream) } d.started = false return nil } func (d *nativeDriver) Flush(_ context.Context) error { return nil } func (d *nativeDriver) Tune(_ context.Context, freqHz float64) error { d.mu.Lock() defer d.mu.Unlock() if d.dev == 0 || d.lib == nil { return fmt.Errorf("soapy: not configured") } return d.lib.setFrequency(d.dev, dirTX, 0, freqHz) } func (d *nativeDriver) Close(_ context.Context) error { d.mu.Lock() defer d.mu.Unlock() if d.stream != 0 && d.dev != 0 { if d.started { d.lib.deactivateStream(d.dev, d.stream) d.started = false } d.lib.closeStream(d.dev, d.stream) d.stream = 0 } if d.dev != 0 { d.lib.unmakeDevice(d.dev) d.dev = 0 } d.configured = false return nil } func (d *nativeDriver) Stats() platform.RuntimeStats { d.mu.Lock() defer d.mu.Unlock() return platform.RuntimeStats{ TXEnabled: d.started, StreamActive: d.started, FramesWritten: d.framesWritten.Load(), SamplesWritten: d.samplesWritten.Load(), Underruns: d.underruns.Load(), LastError: d.lastError, LastErrorAt: d.lastErrorAt, EffectiveRate: d.cfg.SampleRateHz, } } // --- helper constants --- const dirTX = 1 // SOAPY_SDR_TX // float64 from raw bits func f64FromPtr(p uintptr) float64 { return math.Float64frombits(uint64(p)) }