diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..2b4ad8b --- /dev/null +++ b/examples/README.md @@ -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. diff --git a/examples/go.mod b/examples/go.mod new file mode 100644 index 0000000..92685ed --- /dev/null +++ b/examples/go.mod @@ -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 diff --git a/examples/soapy_simulated/main.go b/examples/soapy_simulated/main.go new file mode 100644 index 0000000..7bf18a0 --- /dev/null +++ b/examples/soapy_simulated/main.go @@ -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) + } +} diff --git a/internal/go.mod b/internal/go.mod new file mode 100644 index 0000000..003cad7 --- /dev/null +++ b/internal/go.mod @@ -0,0 +1,3 @@ +module github.com/jan/fm-rds-tx/internal + +go 1.21 diff --git a/internal/output/backend.go b/internal/output/backend.go new file mode 100644 index 0000000..38e5b82 --- /dev/null +++ b/internal/output/backend.go @@ -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 +} diff --git a/internal/output/dummy.go b/internal/output/dummy.go new file mode 100644 index 0000000..89a2bb1 --- /dev/null +++ b/internal/output/dummy.go @@ -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 +} diff --git a/internal/output/file.go b/internal/output/file.go new file mode 100644 index 0000000..9d19f40 --- /dev/null +++ b/internal/output/file.go @@ -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 +} diff --git a/internal/platform/soapy.go b/internal/platform/soapy.go new file mode 100644 index 0000000..5255c6e --- /dev/null +++ b/internal/platform/soapy.go @@ -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) +}