瀏覽代碼

Add output backend abstractions

tags/v0.3.0-pre
Jan Svabenik 1 月之前
父節點
當前提交
eab1e4d39c
共有 8 個文件被更改,包括 463 次插入0 次删除
  1. +5
    -0
      examples/README.md
  2. +7
    -0
      examples/go.mod
  3. +70
    -0
      examples/soapy_simulated/main.go
  4. +3
    -0
      internal/go.mod
  5. +54
    -0
      internal/output/backend.go
  6. +85
    -0
      internal/output/dummy.go
  7. +110
    -0
      internal/output/file.go
  8. +129
    -0
      internal/platform/soapy.go

+ 5
- 0
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.

+ 7
- 0
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

+ 70
- 0
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)
}
}

+ 3
- 0
internal/go.mod 查看文件

@@ -0,0 +1,3 @@
module github.com/jan/fm-rds-tx/internal

go 1.21

+ 54
- 0
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
}

+ 85
- 0
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
}

+ 110
- 0
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
}

+ 129
- 0
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)
}

Loading…
取消
儲存