| @@ -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) | |||||
| } | |||||