| @@ -0,0 +1,5 @@ | |||
| # Examples for fm-rds-tx | |||
| This folder collects runnable snippets that demonstrate how the backend abstractions can be wired together. | |||
| - `soapy_simulated/main.go` shows how to create a simulated SoapySDR backend that shunts composite samples into a file writer for offline inspection. | |||
| @@ -0,0 +1,7 @@ | |||
| module github.com/jan/fm-rds-tx/examples | |||
| go 1.21 | |||
| require github.com/jan/fm-rds-tx/internal v0.0.0 | |||
| replace github.com/jan/fm-rds-tx/internal => ../internal | |||
| @@ -0,0 +1,70 @@ | |||
| package main | |||
| import ( | |||
| "context" | |||
| "encoding/binary" | |||
| "fmt" | |||
| "os" | |||
| "time" | |||
| "github.com/jan/fm-rds-tx/internal/output" | |||
| "github.com/jan/fm-rds-tx/internal/platform" | |||
| ) | |||
| func main() { | |||
| ctx := context.Background() | |||
| fb, err := output.NewFileBackend("examples/simulated_mpx.iq", binary.LittleEndian, output.BackendInfo{ | |||
| Name: "example-file", | |||
| Description: "Captures simulated Soapy output for later playback.", | |||
| }) | |||
| if err != nil { | |||
| fmt.Fprintf(os.Stderr, "failed to open file backend: %v\n", err) | |||
| os.Exit(1) | |||
| } | |||
| defer fb.Close(ctx) | |||
| cfg := platform.SoapyConfig{ | |||
| Driver: "simulated", | |||
| Device: "dummy", | |||
| CenterFreqHz: 100e6, | |||
| Simulated: true, | |||
| BackendConfig: output.BackendConfig{ | |||
| SampleRateHz: 238_000, | |||
| Channels: 2, | |||
| IQLevel: 1.0, | |||
| Metadata: map[string]string{"example": "soapy-sim"}, | |||
| }, | |||
| } | |||
| driver := platform.NewSimulatedDriver(fb) | |||
| backend := platform.NewSoapyBackend(cfg, driver) | |||
| if err := backend.Configure(ctx, cfg.BackendConfig); err != nil { | |||
| fmt.Fprintf(os.Stderr, "backend configure: %v\n", err) | |||
| os.Exit(1) | |||
| } | |||
| frame := &output.CompositeFrame{ | |||
| SampleRateHz: cfg.BackendConfig.SampleRateHz, | |||
| Timestamp: time.Now(), | |||
| Samples: make([]output.IQSample, 512), | |||
| } | |||
| for idx := range frame.Samples { | |||
| val := float32((idx%64))/32 - 1 | |||
| frame.Samples[idx].I = val | |||
| frame.Samples[idx].Q = -val | |||
| } | |||
| written, err := backend.Write(ctx, frame) | |||
| if err != nil { | |||
| fmt.Fprintf(os.Stderr, "write frame: %v\n", err) | |||
| os.Exit(1) | |||
| } | |||
| fmt.Printf("wrote %d samples to simulated Soapy backend\n", written) | |||
| if err := backend.Flush(ctx); err != nil { | |||
| fmt.Fprintf(os.Stderr, "flush: %v\n", err) | |||
| } | |||
| } | |||
| @@ -0,0 +1,3 @@ | |||
| module github.com/jan/fm-rds-tx/internal | |||
| go 1.21 | |||
| @@ -0,0 +1,54 @@ | |||
| package output | |||
| import ( | |||
| "context" | |||
| "errors" | |||
| "time" | |||
| ) | |||
| var ErrBackendClosed = errors.New("backend already closed") | |||
| // IQSample is a normalized interleaved I/Q sample pair. | |||
| type IQSample struct { | |||
| I float32 | |||
| Q float32 | |||
| } | |||
| // CompositeFrame carries a block of MPX/IQ samples along with timing metadata. | |||
| type CompositeFrame struct { | |||
| Samples []IQSample | |||
| SampleRateHz float64 | |||
| Timestamp time.Time | |||
| Sequence uint64 | |||
| } | |||
| // BackendConfig describes the properties for a backend instance. | |||
| type BackendConfig struct { | |||
| SampleRateHz float64 | |||
| Channels int | |||
| IQLevel float32 | |||
| Metadata map[string]string | |||
| } | |||
| // BackendCapabilities advertise what a backend can do. | |||
| type BackendCapabilities struct { | |||
| SupportsComposite bool | |||
| FixedRate bool | |||
| MaxSamplesPerWrite int | |||
| } | |||
| // BackendInfo exposes runtime metadata about a backend. | |||
| type BackendInfo struct { | |||
| Name string | |||
| Description string | |||
| Capabilities BackendCapabilities | |||
| } | |||
| // Backend defines the contract that all output backends must satisfy. | |||
| type Backend interface { | |||
| Configure(ctx context.Context, cfg BackendConfig) error | |||
| Write(ctx context.Context, frame *CompositeFrame) (int, error) | |||
| Flush(ctx context.Context) error | |||
| Close(ctx context.Context) error | |||
| Info() BackendInfo | |||
| } | |||
| @@ -0,0 +1,85 @@ | |||
| package output | |||
| import ( | |||
| "context" | |||
| "sync" | |||
| ) | |||
| // DummyBackend keeps track of the latest frame without writing anywhere. Useful for unit testing. | |||
| type DummyBackend struct { | |||
| mu sync.Mutex | |||
| info BackendInfo | |||
| cfg BackendConfig | |||
| closed bool | |||
| total uint64 | |||
| lastFrame CompositeFrame | |||
| } | |||
| // NewDummyBackend constructs a lean backend that records the last frame seen. | |||
| func NewDummyBackend(name string) *DummyBackend { | |||
| return &DummyBackend{ | |||
| info: BackendInfo{ | |||
| Name: name, | |||
| Description: "in-memory dummy backend", | |||
| Capabilities: BackendCapabilities{ | |||
| SupportsComposite: true, | |||
| FixedRate: false, | |||
| MaxSamplesPerWrite: 0, | |||
| }, | |||
| }, | |||
| } | |||
| } | |||
| // Configure stores the config values. | |||
| func (db *DummyBackend) Configure(_ context.Context, cfg BackendConfig) error { | |||
| db.mu.Lock() | |||
| defer db.mu.Unlock() | |||
| db.cfg = cfg | |||
| return nil | |||
| } | |||
| // Write captures the most recent frame and updates the sample count. | |||
| func (db *DummyBackend) Write(_ context.Context, frame *CompositeFrame) (int, error) { | |||
| db.mu.Lock() | |||
| defer db.mu.Unlock() | |||
| if frame == nil { | |||
| return 0, nil | |||
| } | |||
| db.lastFrame = *frame | |||
| db.total += uint64(len(frame.Samples)) | |||
| return len(frame.Samples), nil | |||
| } | |||
| // Flush is a no-op for the dummy backend. | |||
| func (db *DummyBackend) Flush(_ context.Context) error { | |||
| return nil | |||
| } | |||
| // Close marks the backend unusable. | |||
| func (db *DummyBackend) Close(_ context.Context) error { | |||
| db.mu.Lock() | |||
| defer db.mu.Unlock() | |||
| db.closed = true | |||
| return nil | |||
| } | |||
| // Info returns the backend descriptors. | |||
| func (db *DummyBackend) Info() BackendInfo { | |||
| db.mu.Lock() | |||
| defer db.mu.Unlock() | |||
| return db.info | |||
| } | |||
| // TotalSamples reports how many samples have been written. | |||
| func (db *DummyBackend) TotalSamples() uint64 { | |||
| db.mu.Lock() | |||
| defer db.mu.Unlock() | |||
| return db.total | |||
| } | |||
| // LastFrame exposes a snapshot of the last frame written. | |||
| func (db *DummyBackend) LastFrame() CompositeFrame { | |||
| db.mu.Lock() | |||
| defer db.mu.Unlock() | |||
| return db.lastFrame | |||
| } | |||
| @@ -0,0 +1,110 @@ | |||
| package output | |||
| import ( | |||
| "context" | |||
| "encoding/binary" | |||
| "fmt" | |||
| "math" | |||
| "os" | |||
| "sync" | |||
| ) | |||
| // FileBackend streams composite samples to disk so that playback or offline tooling can consume them. | |||
| type FileBackend struct { | |||
| mu sync.Mutex | |||
| file *os.File | |||
| order binary.ByteOrder | |||
| info BackendInfo | |||
| cfg BackendConfig | |||
| closed bool | |||
| } | |||
| // NewFileBackend creates a writer that appends float32 interleaved I/Q pairs to the named file. | |||
| func NewFileBackend(path string, order binary.ByteOrder, info BackendInfo) (*FileBackend, error) { | |||
| f, err := os.Create(path) | |||
| if err != nil { | |||
| return nil, fmt.Errorf("open output file: %w", err) | |||
| } | |||
| if info.Name == "" { | |||
| info.Name = path | |||
| } | |||
| if info.Capabilities.MaxSamplesPerWrite == 0 { | |||
| info.Capabilities.MaxSamplesPerWrite = 4096 | |||
| } | |||
| info.Capabilities.SupportsComposite = true | |||
| info.Capabilities.FixedRate = true | |||
| return &FileBackend{ | |||
| file: f, | |||
| order: order, | |||
| info: info, | |||
| }, nil | |||
| } | |||
| // Configure stores the requested configuration, but the file backend simply preserves the values. | |||
| func (fb *FileBackend) Configure(_ context.Context, cfg BackendConfig) error { | |||
| fb.mu.Lock() | |||
| defer fb.mu.Unlock() | |||
| if fb.closed { | |||
| return ErrBackendClosed | |||
| } | |||
| fb.cfg = cfg | |||
| return nil | |||
| } | |||
| // Write emits the provided frame as binary interleaved float32 I/Q samples. | |||
| func (fb *FileBackend) Write(ctx context.Context, frame *CompositeFrame) (int, error) { | |||
| if err := ctx.Err(); err != nil { | |||
| return 0, err | |||
| } | |||
| fb.mu.Lock() | |||
| defer fb.mu.Unlock() | |||
| if fb.closed { | |||
| return 0, ErrBackendClosed | |||
| } | |||
| if frame == nil || len(frame.Samples) == 0 { | |||
| return 0, nil | |||
| } | |||
| buf := make([]byte, 8) | |||
| written := 0 | |||
| for _, sample := range frame.Samples { | |||
| if err := ctx.Err(); err != nil { | |||
| return written, err | |||
| } | |||
| fb.order.PutUint32(buf[0:], math.Float32bits(sample.I)) | |||
| fb.order.PutUint32(buf[4:], math.Float32bits(sample.Q)) | |||
| if _, err := fb.file.Write(buf); err != nil { | |||
| return written, fmt.Errorf("write sample data: %w", err) | |||
| } | |||
| written++ | |||
| } | |||
| return written, nil | |||
| } | |||
| // Flush commits the current file buffer to disk. | |||
| func (fb *FileBackend) Flush(_ context.Context) error { | |||
| fb.mu.Lock() | |||
| defer fb.mu.Unlock() | |||
| if fb.closed { | |||
| return ErrBackendClosed | |||
| } | |||
| return fb.file.Sync() | |||
| } | |||
| // Close finalizes the file handle. | |||
| func (fb *FileBackend) Close(_ context.Context) error { | |||
| fb.mu.Lock() | |||
| defer fb.mu.Unlock() | |||
| if fb.closed { | |||
| return ErrBackendClosed | |||
| } | |||
| fb.closed = true | |||
| return fb.file.Close() | |||
| } | |||
| // Info returns the backend metadata. | |||
| func (fb *FileBackend) Info() BackendInfo { | |||
| fb.mu.Lock() | |||
| defer fb.mu.Unlock() | |||
| return fb.info | |||
| } | |||
| @@ -0,0 +1,129 @@ | |||
| package platform | |||
| import ( | |||
| "context" | |||
| "fmt" | |||
| "sync" | |||
| "github.com/jan/fm-rds-tx/internal/output" | |||
| ) | |||
| // SoapyConfig exposes SoapySDR-specific knobs that drive hardware or simulated drivers. | |||
| type SoapyConfig struct { | |||
| output.BackendConfig | |||
| Driver string | |||
| Device string | |||
| CenterFreqHz float64 | |||
| GainDB float64 | |||
| Channels []int | |||
| DeviceArgs map[string]string | |||
| Simulated bool | |||
| SimulationPath string | |||
| } | |||
| // SoapyDriver is the low-level contract for talking to Soapy-style devices. | |||
| type SoapyDriver interface { | |||
| Name() string | |||
| Configure(ctx context.Context, cfg SoapyConfig) error | |||
| Write(ctx context.Context, frame *output.CompositeFrame) (int, error) | |||
| Flush(ctx context.Context) error | |||
| Close(ctx context.Context) error | |||
| } | |||
| // SoapyBackend wraps a driver and exposes the output.Backend interface. | |||
| type SoapyBackend struct { | |||
| mu sync.Mutex | |||
| driver SoapyDriver | |||
| cfg SoapyConfig | |||
| info output.BackendInfo | |||
| } | |||
| // NewSoapyBackend returns an output-aware backend that drives the provided driver. | |||
| 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} | |||
| } | |||
| // Configure propagates the latest backend config to the driver. | |||
| 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) | |||
| } | |||
| // Write delegates to the driver. | |||
| func (sb *SoapyBackend) Write(ctx context.Context, frame *output.CompositeFrame) (int, error) { | |||
| return sb.driver.Write(ctx, frame) | |||
| } | |||
| // Flush asks the driver to drain any buffers. | |||
| func (sb *SoapyBackend) Flush(ctx context.Context) error { | |||
| return sb.driver.Flush(ctx) | |||
| } | |||
| // Close shuts down the driver cleanly. | |||
| func (sb *SoapyBackend) Close(ctx context.Context) error { | |||
| return sb.driver.Close(ctx) | |||
| } | |||
| // Info reports the configured backend metadata. | |||
| func (sb *SoapyBackend) Info() output.BackendInfo { | |||
| sb.mu.Lock() | |||
| defer sb.mu.Unlock() | |||
| return sb.info | |||
| } | |||
| // SimulatedDriver keeps samples in a downstream backend for testing without hardware. | |||
| type SimulatedDriver struct { | |||
| mu sync.Mutex | |||
| fallback output.Backend | |||
| cfg SoapyConfig | |||
| } | |||
| // NewSimulatedDriver uses the provided backend or falls back to an in-memory dummy. | |||
| func NewSimulatedDriver(writer output.Backend) *SimulatedDriver { | |||
| if writer == nil { | |||
| writer = output.NewDummyBackend("simulated-soapy") | |||
| } | |||
| return &SimulatedDriver{fallback: writer} | |||
| } | |||
| // Name returns the runtime label of the simulated driver. | |||
| func (sd *SimulatedDriver) Name() string { | |||
| return sd.fallback.Info().Name | |||
| } | |||
| // Configure pushes the SoapyConfig into the fallback backend. | |||
| 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) | |||
| } | |||
| // Write simply plants the frame into the fallback pipeline. | |||
| func (sd *SimulatedDriver) Write(ctx context.Context, frame *output.CompositeFrame) (int, error) { | |||
| return sd.fallback.Write(ctx, frame) | |||
| } | |||
| // Flush is delegated. | |||
| func (sd *SimulatedDriver) Flush(ctx context.Context) error { | |||
| return sd.fallback.Flush(ctx) | |||
| } | |||
| // Close finalizes the fallback backend. | |||
| func (sd *SimulatedDriver) Close(ctx context.Context) error { | |||
| return sd.fallback.Close(ctx) | |||
| } | |||