package platform import ( "context" "fmt" "sync" "sync/atomic" "time" "github.com/jan/fm-rds-tx/internal/output" ) // ----------------------------------------------------------------------- // Device capabilities and runtime stats // ----------------------------------------------------------------------- // DeviceCaps describes what a hardware device supports. type DeviceCaps struct { MinSampleRate float64 MaxSampleRate float64 SupportedSampleRates []float64 HasGain bool GainMinDB float64 GainMaxDB float64 Channels []int } // RuntimeStats exposes live telemetry from the backend. type RuntimeStats struct { TXEnabled bool `json:"txEnabled"` StreamActive bool `json:"streamActive"` FramesWritten uint64 `json:"framesWritten"` SamplesWritten uint64 `json:"samplesWritten"` Underruns uint64 `json:"underruns"` Overruns uint64 `json:"overruns"` LastError string `json:"lastError,omitempty"` LastErrorAt string `json:"lastErrorAt,omitempty"` EffectiveRate float64 `json:"effectiveSampleRateHz"` } // ----------------------------------------------------------------------- // SoapyConfig // ----------------------------------------------------------------------- type SoapyConfig struct { output.BackendConfig Driver string Device string CenterFreqHz float64 GainDB float64 Channels []int DeviceArgs map[string]string Simulated bool SimulationPath string } // ----------------------------------------------------------------------- // SoapyDriver interface — extended for real HW // ----------------------------------------------------------------------- type SoapyDriver interface { Name() string Configure(ctx context.Context, cfg SoapyConfig) error Capabilities(ctx context.Context) (DeviceCaps, error) Start(ctx context.Context) error Write(ctx context.Context, frame *output.CompositeFrame) (int, error) Stop(ctx context.Context) error Flush(ctx context.Context) error Close(ctx context.Context) error Stats() RuntimeStats // Tune changes the TX center frequency while streaming. Thread-safe. Tune(ctx context.Context, freqHz float64) error } // ----------------------------------------------------------------------- // SoapyBackend wraps driver and exposes output.Backend // ----------------------------------------------------------------------- type SoapyBackend struct { mu sync.Mutex driver SoapyDriver cfg SoapyConfig info output.BackendInfo } func NewSoapyBackend(cfg SoapyConfig, driver SoapyDriver) *SoapyBackend { if driver == nil { driver = NewSimulatedDriver(nil) } info := output.BackendInfo{ Name: fmt.Sprintf("soapy/%s", cfg.Driver), Description: "SoapySDR-friendly backend", Capabilities: output.BackendCapabilities{ SupportsComposite: true, FixedRate: cfg.SampleRateHz > 0, MaxSamplesPerWrite: 8192, }, } return &SoapyBackend{driver: driver, cfg: cfg, info: info} } func (sb *SoapyBackend) Configure(ctx context.Context, cfg output.BackendConfig) error { sb.mu.Lock() sb.cfg.BackendConfig = cfg sb.mu.Unlock() return sb.driver.Configure(ctx, sb.cfg) } func (sb *SoapyBackend) Write(ctx context.Context, frame *output.CompositeFrame) (int, error) { return sb.driver.Write(ctx, frame) } func (sb *SoapyBackend) Flush(ctx context.Context) error { return sb.driver.Flush(ctx) } func (sb *SoapyBackend) Close(ctx context.Context) error { return sb.driver.Close(ctx) } func (sb *SoapyBackend) Info() output.BackendInfo { sb.mu.Lock() defer sb.mu.Unlock() return sb.info } func (sb *SoapyBackend) Driver() SoapyDriver { return sb.driver } // ----------------------------------------------------------------------- // SimulatedDriver — implements full SoapyDriver interface // ----------------------------------------------------------------------- type SimulatedDriver struct { mu sync.Mutex fallback output.Backend cfg SoapyConfig started bool framesWritten atomic.Uint64 samplesWritten atomic.Uint64 lastError string lastErrorAt string } func NewSimulatedDriver(writer output.Backend) *SimulatedDriver { if writer == nil { writer = output.NewDummyBackend("simulated-soapy") } return &SimulatedDriver{fallback: writer} } func (sd *SimulatedDriver) Name() string { return sd.fallback.Info().Name } func (sd *SimulatedDriver) Configure(ctx context.Context, cfg SoapyConfig) error { sd.mu.Lock() sd.cfg = cfg sd.mu.Unlock() return sd.fallback.Configure(ctx, cfg.BackendConfig) } func (sd *SimulatedDriver) Capabilities(_ context.Context) (DeviceCaps, error) { return DeviceCaps{ MinSampleRate: 48000, MaxSampleRate: 2400000, HasGain: true, GainMinDB: 0, GainMaxDB: 47, Channels: []int{0}, }, nil } func (sd *SimulatedDriver) Start(_ context.Context) error { sd.mu.Lock() defer sd.mu.Unlock() sd.started = true return nil } func (sd *SimulatedDriver) Write(ctx context.Context, frame *output.CompositeFrame) (int, error) { n, err := sd.fallback.Write(ctx, frame) if err != nil { sd.mu.Lock() sd.lastError = err.Error() sd.lastErrorAt = time.Now().UTC().Format(time.RFC3339) sd.mu.Unlock() } if n > 0 { sd.framesWritten.Add(1) sd.samplesWritten.Add(uint64(n)) } return n, err } func (sd *SimulatedDriver) Stop(_ context.Context) error { sd.mu.Lock() defer sd.mu.Unlock() sd.started = false return nil } func (sd *SimulatedDriver) Flush(ctx context.Context) error { return sd.fallback.Flush(ctx) } func (sd *SimulatedDriver) Close(ctx context.Context) error { return sd.fallback.Close(ctx) } func (sd *SimulatedDriver) Stats() RuntimeStats { sd.mu.Lock() defer sd.mu.Unlock() return RuntimeStats{ TXEnabled: sd.started, StreamActive: sd.started, FramesWritten: sd.framesWritten.Load(), SamplesWritten: sd.samplesWritten.Load(), LastError: sd.lastError, LastErrorAt: sd.lastErrorAt, EffectiveRate: sd.cfg.SampleRateHz, } } func (sd *SimulatedDriver) Tune(_ context.Context, freqHz float64) error { sd.mu.Lock() sd.cfg.CenterFreqHz = freqHz sd.mu.Unlock() return nil }