From 59c338ebdafea985d9879f94ed635e954fd24806 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 21:58:30 +0200 Subject: [PATCH 1/7] feat: add live audio ingest pipeline for on-air streaming Add a lock-free stdin PCM ingest path, streaming resampler, stereo-linked limiting and pre-MPX audio filtering, plus the engine/control wiring needed to drive live audio into TX mode. Also document the ingest API and include a helper batch script for piping ffmpeg audio into fmrtx. --- cmd/fmrtx/main.go | 28 ++- docs/API.md | 100 ++++++++ internal/app/engine.go | 25 ++ internal/audio/stream.go | 196 +++++++++++++++ internal/audio/stream_test.go | 376 +++++++++++++++++++++++++++++ internal/control/control.go | 65 ++++- internal/dsp/biquad.go | 54 +++++ internal/dsp/stereolimiter.go | 68 ++++++ internal/offline/generator.go | 88 +++++-- internal/offline/generator_test.go | 5 +- internal/rds/encoder.go | 5 +- stream_tx.bat | 2 + 12 files changed, 988 insertions(+), 24 deletions(-) create mode 100644 internal/audio/stream.go create mode 100644 internal/audio/stream_test.go create mode 100644 internal/dsp/biquad.go create mode 100644 internal/dsp/stereolimiter.go create mode 100644 stream_tx.bat diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index cfa71f8..069f91e 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -12,6 +12,7 @@ import ( "time" apppkg "github.com/jan/fm-rds-tx/internal/app" + "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" ctrlpkg "github.com/jan/fm-rds-tx/internal/control" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" @@ -31,6 +32,8 @@ func main() { txMode := flag.Bool("tx", false, "start real TX mode (requires hardware + build tags)") txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch") listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") + audioStdin := flag.Bool("audio-stdin", false, "read S16LE stereo PCM audio from stdin") + audioRate := flag.Int("audio-rate", 44100, "sample rate of stdin audio input (Hz)") flag.Parse() // --- list-devices (SoapySDR) --- @@ -99,7 +102,7 @@ func main() { if driver == nil { log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") } - runTXMode(cfg, driver, *txAutoStart) + runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate) return } @@ -142,7 +145,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { return nil } -func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { +func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, audioStdin bool, audioRate int) { ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -172,10 +175,31 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { // Engine engine := apppkg.NewEngine(cfg, driver) + // Live audio stream source (optional) + var streamSrc *audio.StreamSource + if audioStdin { + // Buffer: 2 seconds at input rate — enough to absorb jitter + streamSrc = audio.NewStreamSource(audioRate*2, audioRate) + engine.SetStreamSource(streamSrc) + + // Stdin ingest goroutine + go func() { + log.Printf("audio: reading S16LE stereo PCM from stdin at %d Hz", audioRate) + if err := audio.IngestReader(os.Stdin, streamSrc); err != nil { + log.Printf("audio: stdin ingest ended: %v", err) + } else { + log.Println("audio: stdin EOF") + } + }() + } + // Control plane srv := ctrlpkg.NewServer(cfg) srv.SetDriver(driver) srv.SetTXController(&txBridge{engine: engine}) + if streamSrc != nil { + srv.SetStreamSource(streamSrc) + } if autoStart { log.Println("TX: auto-start enabled") diff --git a/docs/API.md b/docs/API.md index 8e3b096..b29d676 100644 --- a/docs/API.md +++ b/docs/API.md @@ -218,3 +218,103 @@ These cannot be hot-reloaded (they affect DSP pipeline structure): - `rds.pi` / `rds.pty` — rarely change, baked into encoder init - `audio.inputPath` — audio source selection - `backend.kind` / `backend.device` — hardware selection + +--- + +### `POST /audio/stream` + +Push raw audio data into the live stream buffer. Format: **S16LE stereo PCM** at the configured `--audio-rate` (default 44100 Hz). + +Requires `--audio-stdin` or a configured stream source. + +**Request:** Binary body, `application/octet-stream`, raw S16LE stereo PCM bytes. + +**Response:** +```json +{ + "ok": true, + "frames": 4096, + "stats": { + "available": 12000, + "capacity": 131072, + "buffered": 0.09, + "written": 890000, + "underruns": 0, + "overflows": 0 + } +} +``` + +**Example:** +```bash +# Push a file +ffmpeg -i song.mp3 -f s16le -ar 44100 -ac 2 - | \ + curl -X POST --data-binary @- http://pluto:8088/audio/stream +``` + +**Errors:** +- `405` if not POST +- `503` if no audio stream configured + +--- + +## Audio Streaming + +### Stdin pipe (primary method) + +Pipe any audio source through ffmpeg into the transmitter: + +```bash +# Internet radio stream +ffmpeg -i "http://stream.example.com/radio.mp3" -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin --config config.json + +# Local music file +ffmpeg -i music.flac -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# Playlist (ffmpeg concat) +ffmpeg -f concat -i playlist.txt -f s16le -ar 44100 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# PulseAudio / ALSA capture (Linux) +parecord --format=s16le --rate=44100 --channels=2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin + +# Custom sample rate (e.g. 48kHz source) +ffmpeg -i source.wav -f s16le -ar 48000 -ac 2 - | \ + fmrtx --tx --tx-auto-start --audio-stdin --audio-rate 48000 +``` + +### HTTP audio push + +Push audio from a remote machine via the HTTP API: + +```bash +# From another machine on the network +ffmpeg -i music.mp3 -f s16le -ar 44100 -ac 2 - | \ + curl -X POST --data-binary @- http://pluto-host:8088/audio/stream +``` + +### Audio buffer + +The stream uses a lock-free ring buffer (default: 2 seconds at input rate). Buffer stats are available in `GET /runtime` under `audioStream`: + +```json +{ + "audioStream": { + "available": 12000, + "capacity": 131072, + "buffered": 0.09, + "written": 890000, + "underruns": 0, + "overflows": 0 + } +} +``` + +- **underruns**: DSP consumed faster than audio arrived (silence inserted) +- **overflows**: Audio arrived faster than DSP consumed (data dropped) +- **buffered**: Fill ratio (0.0 = empty, 1.0 = full) + +When no audio is streaming, the transmitter falls back to the configured tone generator or silence. diff --git a/internal/app/engine.go b/internal/app/engine.go index 7384d9a..0139a9b 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -8,6 +8,7 @@ import ( "sync/atomic" "time" + "github.com/jan/fm-rds-tx/internal/audio" cfgpkg "github.com/jan/fm-rds-tx/internal/config" "github.com/jan/fm-rds-tx/internal/dsp" offpkg "github.com/jan/fm-rds-tx/internal/offline" @@ -70,6 +71,30 @@ type Engine struct { // Live config: pending frequency change, applied between chunks pendingFreq atomic.Pointer[float64] + + // Live audio stream (optional) + streamSrc *audio.StreamSource +} + +// SetStreamSource configures a live audio stream as the audio source. +// Must be called before Start(). The StreamResampler is created internally +// to convert from the stream's sample rate to the DSP composite rate. +func (e *Engine) SetStreamSource(src *audio.StreamSource) { + e.streamSrc = src + compositeRate := float64(e.cfg.FM.CompositeRateHz) + if compositeRate <= 0 { + compositeRate = 228000 + } + resampler := audio.NewStreamResampler(src, compositeRate) + e.generator.SetExternalSource(resampler) + log.Printf("engine: live audio stream — %d Hz → %.0f Hz (buffer %d frames)", + src.SampleRate, compositeRate, src.Stats().Capacity) +} + +// StreamSource returns the live audio stream source, or nil. +// Used by the control server for stats and HTTP audio ingest. +func (e *Engine) StreamSource() *audio.StreamSource { + return e.streamSrc } func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { diff --git a/internal/audio/stream.go b/internal/audio/stream.go new file mode 100644 index 0000000..bf951a8 --- /dev/null +++ b/internal/audio/stream.go @@ -0,0 +1,196 @@ +package audio + +import ( + "encoding/binary" + "fmt" + "io" + "sync/atomic" +) + +// StreamSource is a lock-free SPSC (single-producer, single-consumer) ring buffer +// for real-time audio streaming. One goroutine writes PCM frames, the DSP +// goroutine reads them via NextFrame(). Returns silence on underrun. +// +// Zero allocations in steady state. No mutex in the read or write path. +type StreamSource struct { + ring []Frame + size int + mask int // size-1, for fast modulo (size must be power of 2) + SampleRate int + + writePos atomic.Int64 + readPos atomic.Int64 + + Underruns atomic.Uint64 + Overflows atomic.Uint64 + Written atomic.Uint64 +} + +// NewStreamSource creates a ring buffer with the given capacity (rounded up +// to next power of 2) and input sample rate. +func NewStreamSource(capacity, sampleRate int) *StreamSource { + // Round up to power of 2 + size := 1 + for size < capacity { + size <<= 1 + } + return &StreamSource{ + ring: make([]Frame, size), + size: size, + mask: size - 1, + SampleRate: sampleRate, + } +} + +// WriteFrame pushes a single frame into the ring buffer. +// Returns false if the buffer is full (overflow). +func (s *StreamSource) WriteFrame(f Frame) bool { + wp := s.writePos.Load() + rp := s.readPos.Load() + if wp-rp >= int64(s.size) { + s.Overflows.Add(1) + return false + } + s.ring[int(wp)&s.mask] = f + s.writePos.Add(1) + s.Written.Add(1) + return true +} + +// WritePCM decodes interleaved S16LE stereo PCM bytes and writes frames +// to the ring buffer. Returns the number of frames written. +func (s *StreamSource) WritePCM(data []byte) int { + frames := len(data) / 4 // 2 channels × 2 bytes per sample + written := 0 + for i := 0; i < frames; i++ { + off := i * 4 + l := int16(binary.LittleEndian.Uint16(data[off:])) + r := int16(binary.LittleEndian.Uint16(data[off+2:])) + f := NewFrame( + Sample(float64(l)/32768.0), + Sample(float64(r)/32768.0), + ) + if !s.WriteFrame(f) { + break + } + written++ + } + return written +} + +// ReadFrame consumes one frame from the ring buffer. +// Returns silence (0,0) on underrun. +func (s *StreamSource) ReadFrame() Frame { + rp := s.readPos.Load() + wp := s.writePos.Load() + if rp >= wp { + s.Underruns.Add(1) + return NewFrame(0, 0) + } + f := s.ring[int(rp)&s.mask] + s.readPos.Add(1) + return f +} + +// NextFrame implements the frameSource interface. +func (s *StreamSource) NextFrame() Frame { + return s.ReadFrame() +} + +// Available returns the number of frames currently buffered. +func (s *StreamSource) Available() int { + return int(s.writePos.Load() - s.readPos.Load()) +} + +// Buffered returns the fill ratio (0.0 = empty, 1.0 = full). +func (s *StreamSource) Buffered() float64 { + return float64(s.Available()) / float64(s.size) +} + +// Stats returns diagnostic counters. +func (s *StreamSource) Stats() StreamStats { + return StreamStats{ + Available: s.Available(), + Capacity: s.size, + Buffered: s.Buffered(), + Written: s.Written.Load(), + Underruns: s.Underruns.Load(), + Overflows: s.Overflows.Load(), + } +} + +// StreamStats exposes runtime telemetry for the stream buffer. +type StreamStats struct { + Available int `json:"available"` + Capacity int `json:"capacity"` + Buffered float64 `json:"buffered"` + Written uint64 `json:"written"` + Underruns uint64 `json:"underruns"` + Overflows uint64 `json:"overflows"` +} + +// --- StreamResampler --- + +// StreamResampler wraps a StreamSource and rate-converts from the stream's +// native sample rate to the target output rate using linear interpolation. +// Consumes input frames on demand — no buffering beyond the ring buffer. +type StreamResampler struct { + src *StreamSource + ratio float64 // inputRate / outputRate (< 1 when upsampling) + pos float64 + prev Frame + curr Frame +} + +// NewStreamResampler creates a streaming resampler. +func NewStreamResampler(src *StreamSource, outputRate float64) *StreamResampler { + if src == nil || outputRate <= 0 || src.SampleRate <= 0 { + return &StreamResampler{src: src, ratio: 1.0} + } + return &StreamResampler{ + src: src, + ratio: float64(src.SampleRate) / outputRate, + } +} + +// NextFrame returns the next interpolated frame at the output rate. +// Implements the frameSource interface. +func (r *StreamResampler) NextFrame() Frame { + if r.src == nil { + return NewFrame(0, 0) + } + + // Consume input samples as the fractional position advances + for r.pos >= 1.0 { + r.prev = r.curr + r.curr = r.src.ReadFrame() + r.pos -= 1.0 + } + + frac := r.pos + l := float64(r.prev.L)*(1-frac) + float64(r.curr.L)*frac + ri := float64(r.prev.R)*(1-frac) + float64(r.curr.R)*frac + r.pos += r.ratio + return NewFrame(Sample(l), Sample(ri)) +} + +// --- Ingest helpers --- + +// IngestReader continuously reads S16LE stereo PCM from an io.Reader into +// a StreamSource. Blocks until the reader returns an error or io.EOF. +// Designed to run as a goroutine. +func IngestReader(r io.Reader, dst *StreamSource) error { + buf := make([]byte, 16384) // 4096 frames per read (16KB) + for { + n, err := r.Read(buf) + if n > 0 { + dst.WritePCM(buf[:n]) + } + if err != nil { + if err == io.EOF { + return nil + } + return fmt.Errorf("audio ingest: %w", err) + } + } +} diff --git a/internal/audio/stream_test.go b/internal/audio/stream_test.go new file mode 100644 index 0000000..cc2820a --- /dev/null +++ b/internal/audio/stream_test.go @@ -0,0 +1,376 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "io" + "math" + "sync" + "sync/atomic" + "testing" +) + +func TestStreamSource_WriteRead(t *testing.T) { + s := NewStreamSource(1024, 44100) + if s.size != 1024 { + t.Fatalf("expected size 1024, got %d", s.size) + } + + // Write and read a frame + f := NewFrame(0.5, -0.3) + if !s.WriteFrame(f) { + t.Fatal("write failed") + } + if s.Available() != 1 { + t.Fatalf("expected 1 available, got %d", s.Available()) + } + + out := s.ReadFrame() + if out.L != 0.5 || out.R != -0.3 { + t.Fatalf("read mismatch: got L=%.2f R=%.2f", out.L, out.R) + } + if s.Available() != 0 { + t.Fatalf("expected 0 available, got %d", s.Available()) + } +} + +func TestStreamSource_Underrun(t *testing.T) { + s := NewStreamSource(16, 44100) + + // Read from empty buffer — should return silence + f := s.ReadFrame() + if f.L != 0 || f.R != 0 { + t.Fatal("expected silence on underrun") + } + if s.Underruns.Load() != 1 { + t.Fatalf("expected 1 underrun, got %d", s.Underruns.Load()) + } +} + +func TestStreamSource_Overflow(t *testing.T) { + s := NewStreamSource(4, 44100) // size rounds up to 4 + + // Fill completely + for i := 0; i < 4; i++ { + if !s.WriteFrame(NewFrame(Sample(float64(i)/10), 0)) { + t.Fatalf("write %d failed", i) + } + } + + // Next write should overflow + if s.WriteFrame(NewFrame(1, 1)) { + t.Fatal("expected overflow") + } + if s.Overflows.Load() != 1 { + t.Fatalf("expected 1 overflow, got %d", s.Overflows.Load()) + } +} + +func TestStreamSource_PowerOf2Rounding(t *testing.T) { + tests := []struct{ in, expect int }{ + {1, 1}, {2, 2}, {3, 4}, {5, 8}, {100, 128}, {1024, 1024}, {1025, 2048}, + } + for _, tt := range tests { + s := NewStreamSource(tt.in, 44100) + if s.size != tt.expect { + t.Fatalf("NewStreamSource(%d): size=%d, expected %d", tt.in, s.size, tt.expect) + } + } +} + +func TestStreamSource_FIFO(t *testing.T) { + s := NewStreamSource(64, 44100) + n := 50 + for i := 0; i < n; i++ { + s.WriteFrame(NewFrame(Sample(float64(i)), 0)) + } + for i := 0; i < n; i++ { + f := s.ReadFrame() + if int(f.L) != i { + t.Fatalf("FIFO order broken at %d: got %d", i, int(f.L)) + } + } +} + +func TestStreamSource_Wraparound(t *testing.T) { + s := NewStreamSource(8, 44100) // size = 8 + + // Write and read more than buffer size to test wraparound + for round := 0; round < 10; round++ { + for i := 0; i < 8; i++ { + val := float64(round*8 + i) + if !s.WriteFrame(NewFrame(Sample(val), 0)) { + t.Fatalf("write failed round=%d i=%d", round, i) + } + } + for i := 0; i < 8; i++ { + expected := float64(round*8 + i) + f := s.ReadFrame() + if float64(f.L) != expected { + t.Fatalf("round=%d i=%d: got %f expected %f", round, i, float64(f.L), expected) + } + } + } + + stats := s.Stats() + if stats.Underruns != 0 || stats.Overflows != 0 { + t.Fatalf("unexpected errors: underruns=%d overflows=%d", stats.Underruns, stats.Overflows) + } +} + +func TestStreamSource_WritePCM(t *testing.T) { + s := NewStreamSource(256, 44100) + + // Create 10 stereo frames of S16LE PCM + var buf bytes.Buffer + for i := 0; i < 10; i++ { + l := int16(i * 1000) + r := int16(-i * 1000) + binary.Write(&buf, binary.LittleEndian, l) + binary.Write(&buf, binary.LittleEndian, r) + } + + written := s.WritePCM(buf.Bytes()) + if written != 10 { + t.Fatalf("expected 10 frames, wrote %d", written) + } + + // Verify first frame + f := s.ReadFrame() + if f.L != 0 || f.R != 0 { + t.Fatalf("frame 0: L=%.4f R=%.4f, expected 0", f.L, f.R) + } + // Verify frame 5 + for i := 1; i < 5; i++ { + s.ReadFrame() + } + f = s.ReadFrame() + expectedL := 5000.0 / 32768.0 + if math.Abs(float64(f.L)-expectedL) > 0.001 { + t.Fatalf("frame 5 L=%.4f, expected %.4f", f.L, expectedL) + } +} + +func TestStreamSource_ConcurrentSPSC(t *testing.T) { + s := NewStreamSource(4096, 44100) + frames := 50000 + var producerDone atomic.Bool + + var wg sync.WaitGroup + wg.Add(2) + + // Producer + go func() { + defer wg.Done() + for i := 0; i < frames; i++ { + for !s.WriteFrame(NewFrame(Sample(float64(i+1)), 0)) { + // Buffer full — yield + } + } + producerDone.Store(true) + }() + + // Consumer + var lastVal float64 + var orderOK = true + var readCount int + go func() { + defer wg.Done() + for { + if s.Available() == 0 { + if producerDone.Load() { + break + } + continue + } + f := s.ReadFrame() + readCount++ + v := float64(f.L) + if v > 0 && v < lastVal { + orderOK = false + } + if v > 0 { + lastVal = v + } + } + }() + + wg.Wait() + + if !orderOK { + t.Fatal("FIFO order broken in concurrent SPSC") + } + if readCount < frames/2 { + t.Fatalf("read too few frames: %d (expected ~%d)", readCount, frames) + } +} + +// --- StreamResampler tests --- + +func TestStreamResampler_1to1(t *testing.T) { + s := NewStreamSource(256, 44100) + r := NewStreamResampler(s, 44100) // 1:1 + + for i := 0; i < 100; i++ { + s.WriteFrame(NewFrame(Sample(float64(i)/100), 0)) + } + + // At 1:1 ratio, output should track input with a small startup delay. + // Skip first few samples (resampler priming), then verify monotonic increase. + prev := -1.0 + for i := 0; i < 90; i++ { + f := r.NextFrame() + v := float64(f.L) + if i > 5 && v < prev-0.001 { + t.Fatalf("sample %d: non-monotonic %.4f < %.4f", i, v, prev) + } + if v > 0 { + prev = v + } + } + // Final value should be close to 0.9 (we wrote 0..0.99) + if prev < 0.5 { + t.Fatalf("final value %.4f too low (expected > 0.5)", prev) + } +} + +func TestStreamResampler_Upsample(t *testing.T) { + // 44100 → 228000 (ratio ≈ 0.1934, ~5.17× upsampling) + s := NewStreamSource(4096, 44100) + r := NewStreamResampler(s, 228000) + + // Write 1000 frames of a 1kHz sine at 44100 Hz + for i := 0; i < 1000; i++ { + v := math.Sin(2 * math.Pi * 1000 * float64(i) / 44100) + s.WriteFrame(NewFrame(Sample(v), Sample(v))) + } + + // Read upsampled output — should be ~5170 samples for 1000 input + // (minus a few for resampler priming) + out := make([]float64, 0, 5200) + for i := 0; i < 5000; i++ { + f := r.NextFrame() + out = append(out, float64(f.L)) + } + + // Verify the output is a smooth sine, not clicks or zeros + // Check that max amplitude is close to 1.0 + maxAmp := 0.0 + for _, v := range out[100:] { // skip initial ramp + if math.Abs(v) > maxAmp { + maxAmp = math.Abs(v) + } + } + if maxAmp < 0.8 { + t.Fatalf("max amplitude %.4f too low (expected ~1.0)", maxAmp) + } + + // Check smoothness: no sudden jumps > 0.1 between adjacent samples + maxJump := 0.0 + for i := 101; i < len(out); i++ { + d := math.Abs(out[i] - out[i-1]) + if d > maxJump { + maxJump = d + } + } + // At 228kHz with 1kHz tone: max step ≈ sin(2π*1000/228000) ≈ 0.0276 + if maxJump > 0.05 { + t.Fatalf("max inter-sample jump %.4f (expected < 0.05 for smooth sine)", maxJump) + } +} + +func TestStreamResampler_Downsample(t *testing.T) { + // 96000 → 44100 (ratio ≈ 2.177, downsampling) + s := NewStreamSource(8192, 96000) + r := NewStreamResampler(s, 44100) + + // Write 4000 frames at 96kHz + for i := 0; i < 4000; i++ { + v := math.Sin(2 * math.Pi * 440 * float64(i) / 96000) + s.WriteFrame(NewFrame(Sample(v), 0)) + } + + // Should get ~1837 output frames (4000 × 44100/96000) + count := 0 + for i := 0; i < 1800; i++ { + f := r.NextFrame() + _ = f + count++ + } + if count != 1800 { + t.Fatalf("expected 1800 reads, got %d", count) + } +} + +func TestStreamResampler_NilSource(t *testing.T) { + r := NewStreamResampler(nil, 228000) + f := r.NextFrame() + if f.L != 0 || f.R != 0 { + t.Fatal("expected silence from nil source") + } +} + +// --- IngestReader test --- + +func TestIngestReader(t *testing.T) { + s := NewStreamSource(4096, 44100) + + // Create PCM data: 100 stereo frames + var buf bytes.Buffer + for i := 0; i < 100; i++ { + l := int16(i * 100) + r := int16(-i * 100) + binary.Write(&buf, binary.LittleEndian, l) + binary.Write(&buf, binary.LittleEndian, r) + } + + // IngestReader should read all data and return nil (EOF) + err := IngestReader(bytes.NewReader(buf.Bytes()), s) + if err != nil { + t.Fatalf("IngestReader: %v", err) + } + + if s.Available() != 100 { + t.Fatalf("expected 100 frames, got %d", s.Available()) + } + + // Verify first and last + f := s.ReadFrame() + if f.L != 0 { + t.Fatalf("frame 0 L=%.4f, expected 0", f.L) + } + for i := 1; i < 99; i++ { + s.ReadFrame() + } + f = s.ReadFrame() + expectedL := 9900.0 / 32768.0 + if math.Abs(float64(f.L)-expectedL) > 0.01 { + t.Fatalf("frame 99 L=%.4f, expected ~%.4f", f.L, expectedL) + } +} + +func TestIngestReader_Error(t *testing.T) { + s := NewStreamSource(256, 44100) + errReader := &errAfterN{n: 10} + err := IngestReader(errReader, s) + if err == nil { + t.Fatal("expected error") + } +} + +type errAfterN struct { + n, count int +} + +func (r *errAfterN) Read(p []byte) (int, error) { + if r.count >= r.n { + return 0, io.ErrUnexpectedEOF + } + r.count++ + // Return 4 bytes (one stereo frame) + if len(p) >= 4 { + p[0], p[1], p[2], p[3] = 0, 0, 0, 0 + return 4, nil + } + return 0, nil +} diff --git a/internal/control/control.go b/internal/control/control.go index e0cf0e8..7c74e7f 100644 --- a/internal/control/control.go +++ b/internal/control/control.go @@ -3,9 +3,11 @@ package control import ( _ "embed" "encoding/json" + "io" "net/http" "sync" + "github.com/jan/fm-rds-tx/internal/audio" "github.com/jan/fm-rds-tx/internal/config" drypkg "github.com/jan/fm-rds-tx/internal/dryrun" "github.com/jan/fm-rds-tx/internal/platform" @@ -39,10 +41,11 @@ type LivePatch struct { } type Server struct { - mu sync.RWMutex - cfg config.Config - tx TXController - drv platform.SoapyDriver // optional, for runtime stats + mu sync.RWMutex + cfg config.Config + tx TXController + drv platform.SoapyDriver // optional, for runtime stats + streamSrc *audio.StreamSource // optional, for live audio ingest } type ConfigPatch struct { @@ -78,6 +81,12 @@ func (s *Server) SetDriver(drv platform.SoapyDriver) { s.mu.Unlock() } +func (s *Server) SetStreamSource(src *audio.StreamSource) { + s.mu.Lock() + s.streamSrc = src + s.mu.Unlock() +} + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/", s.handleUI) @@ -88,6 +97,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/runtime", s.handleRuntime) mux.HandleFunc("/tx/start", s.handleTXStart) mux.HandleFunc("/tx/stop", s.handleTXStop) + mux.HandleFunc("/audio/stream", s.handleAudioStream) return mux } @@ -128,6 +138,7 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { s.mu.RLock() drv := s.drv tx := s.tx + stream := s.streamSrc s.mu.RUnlock() result := map[string]any{} @@ -137,10 +148,56 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { if tx != nil { result["engine"] = tx.TXStats() } + if stream != nil { + result["audioStream"] = stream.Stats() + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(result) } +// handleAudioStream accepts raw S16LE stereo PCM via HTTP POST and pushes +// it into the live audio ring buffer. Use with: +// curl -X POST --data-binary @- http://host:8088/audio/stream < audio.raw +// ffmpeg ... -f s16le -ar 44100 -ac 2 - | curl -X POST --data-binary @- http://host:8088/audio/stream +func (s *Server) handleAudioStream(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + s.mu.RLock() + stream := s.streamSrc + s.mu.RUnlock() + + if stream == nil { + http.Error(w, "audio stream not configured (use --audio-stdin or --audio-http)", http.StatusServiceUnavailable) + return + } + + // Read body in chunks and push to ring buffer + buf := make([]byte, 32768) + totalFrames := 0 + for { + n, err := r.Body.Read(buf) + if n > 0 { + totalFrames += stream.WritePCM(buf[:n]) + } + if err != nil { + if err == io.EOF { + break + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "frames": totalFrames, + "stats": stream.Stats(), + }) +} + func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/dsp/biquad.go b/internal/dsp/biquad.go new file mode 100644 index 0000000..90270aa --- /dev/null +++ b/internal/dsp/biquad.go @@ -0,0 +1,54 @@ +package dsp + +import "math" + +// BiquadLPF is a second-order Butterworth lowpass filter (biquad, direct form II transposed). +// Used after the audio limiter to remove intermodulation products and harmonics +// that could fall into the 19kHz pilot, 38kHz stereo sub, or 57kHz RDS bands. +// +// At 228kHz with fc=15kHz: +// 15kHz: -3 dB (corner) +// 19kHz: -5 dB +// 38kHz: -18 dB +// 57kHz: -27 dB ← protects RDS band +type BiquadLPF struct { + b0, b1, b2 float64 + a1, a2 float64 + z1, z2 float64 // state (direct form II transposed) +} + +// NewBiquadLPF creates a 2nd-order Butterworth lowpass at the given cutoff. +func NewBiquadLPF(cutoffHz, sampleRate float64) *BiquadLPF { + if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { + // Passthrough: return unity filter + return &BiquadLPF{b0: 1} + } + + omega := 2 * math.Pi * cutoffHz / sampleRate + cosW := math.Cos(omega) + sinW := math.Sin(omega) + alpha := sinW / (2 * math.Sqrt2) // Q = 1/√2 for Butterworth + + a0 := 1 + alpha + return &BiquadLPF{ + b0: (1 - cosW) / 2 / a0, + b1: (1 - cosW) / a0, + b2: (1 - cosW) / 2 / a0, + a1: (-2 * cosW) / a0, + a2: (1 - alpha) / a0, + } +} + +// Process filters a single sample. +func (f *BiquadLPF) Process(in float64) float64 { + out := f.b0*in + f.z1 + f.z1 = f.b1*in - f.a1*out + f.z2 + f.z2 = f.b2*in - f.a2*out + return out +} + +// Reset clears the filter state. +func (f *BiquadLPF) Reset() { + f.z1 = 0 + f.z2 = 0 +} diff --git a/internal/dsp/stereolimiter.go b/internal/dsp/stereolimiter.go new file mode 100644 index 0000000..08029ec --- /dev/null +++ b/internal/dsp/stereolimiter.go @@ -0,0 +1,68 @@ +package dsp + +import "math" + +// StereoLimiter applies identical gain reduction to L and R channels, +// driven by the peak of max(|L|, |R|). This preserves the stereo image +// while preventing either channel from exceeding the ceiling. +// +// Attack is INSTANTANEOUS — gain is reduced in the same sample that +// exceeds the ceiling. This avoids overshoot entirely, which is critical +// because overshoot causes composite clipping that destroys pilot/RDS. +// Unlike hard clipping, gain scaling preserves the waveform shape and +// does not create harmonics. +// +// Release is smooth (exponential decay) to avoid audible pumping. +type StereoLimiter struct { + ceiling float64 + releaseCoeff float64 + gainReduction float64 +} + +// NewStereoLimiter creates a stereo-linked limiter with instant attack. +// releaseMs controls how quickly gain recovers after a peak (typ. 50-200ms). +func NewStereoLimiter(ceiling, attackMs, releaseMs, sampleRate float64) *StereoLimiter { + if ceiling <= 0 { + ceiling = 1.0 + } + if releaseMs <= 0 { + releaseMs = 100 + } + releaseSamples := releaseMs * sampleRate / 1000 + + return &StereoLimiter{ + ceiling: ceiling, + releaseCoeff: 1.0 - math.Exp(-1.0/releaseSamples), + } +} + +// Process applies stereo-linked limiting. Both channels receive the +// same gain factor, determined by the louder of the two. +// +// If the peak exceeds ceiling, gain is INSTANTLY reduced (zero overshoot). +// When the signal drops below ceiling, gain recovers smoothly via release. +func (l *StereoLimiter) Process(left, right float64) (float64, float64) { + peak := math.Max(math.Abs(left), math.Abs(right)) + + // Target: how much gain reduction do we need right now? + targetReduction := 0.0 + if peak > l.ceiling { + targetReduction = 1.0 - l.ceiling/peak + } + + // Instant attack: if we need MORE reduction, apply it NOW. + // Smooth release: if we need LESS reduction, decay slowly. + if targetReduction > l.gainReduction { + l.gainReduction = targetReduction // instant + } else { + l.gainReduction += l.releaseCoeff * (targetReduction - l.gainReduction) // smooth + } + + gain := 1.0 - l.gainReduction + return left * gain, right * gain +} + +// Reset clears the limiter state. +func (l *StereoLimiter) Reset() { + l.gainReduction = 0 +} diff --git a/internal/offline/generator.go b/internal/offline/generator.go index e0513e4..51ec8b1 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -77,7 +77,8 @@ type Generator struct { stereoEncoder stereo.StereoEncoder rdsEnc *rds.Encoder combiner mpx.DefaultCombiner - limiter *dsp.MPXLimiter + limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding + lpfL, lpfR *dsp.BiquadLPF // 15kHz lowpass after limiter, protects RDS band fmMod *dsp.FMModulator sampleRate float64 initialized bool @@ -89,12 +90,23 @@ type Generator struct { // Live-updatable DSP parameters — written by control API, read per chunk. liveParams atomic.Pointer[LiveParams] + + // Optional external audio source (e.g. StreamResampler for live audio). + // When set, takes priority over WAV/tones in sourceFor(). + externalSource frameSource } func NewGenerator(cfg cfgpkg.Config) *Generator { return &Generator{cfg: cfg} } +// SetExternalSource sets a live audio source (e.g. StreamResampler) that +// takes priority over WAV/tone sources. Must be called before the first +// GenerateFrame() call (i.e. before init). +func (g *Generator) SetExternalSource(src frameSource) { + g.externalSource = src +} + // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API, // applied at the next chunk boundary by the DSP goroutine. func (g *Generator) UpdateLive(p LiveParams) { @@ -140,9 +152,18 @@ func (g *Generator) init() { } ceiling := g.cfg.FM.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } + // Audio ceiling leaves headroom for pilot + RDS so total ≤ ceiling + pilotAmp := g.cfg.FM.PilotLevel * g.cfg.FM.OutputDrive + rdsAmp := g.cfg.FM.RDSInjection * g.cfg.FM.OutputDrive + audioCeiling := ceiling - pilotAmp - rdsAmp + if audioCeiling < 0.3 { audioCeiling = 0.3 } if g.cfg.FM.LimiterEnabled { - g.limiter = dsp.NewMPXLimiter(ceiling, 0.1, 50, g.sampleRate) + g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate) } + // 15kHz lowpass after limiter — removes limiter gain-step intermodulation + // products that would otherwise fall into pilot/stereo/RDS bands. + g.lpfL = dsp.NewBiquadLPF(15000, g.sampleRate) + g.lpfR = dsp.NewBiquadLPF(15000, g.sampleRate) if g.cfg.FM.FMModulationEnabled { g.fmMod = dsp.NewFMModulator(g.sampleRate) if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } @@ -163,6 +184,9 @@ func (g *Generator) init() { } func (g *Generator) sourceFor(sampleRate float64) (frameSource, SourceInfo) { + if g.externalSource != nil { + return g.externalSource, SourceInfo{Kind: "stream", SampleRate: sampleRate, Detail: "live audio"} + } if g.cfg.Audio.InputPath != "" { if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { return audio.NewResampledSource(src, sampleRate), SourceInfo{Kind: "wav", SampleRate: float64(src.SampleRate), Detail: g.cfg.Audio.InputPath} @@ -199,32 +223,64 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} } - // Apply live combiner gains - g.combiner.PilotGain = lp.PilotLevel - g.combiner.RDSGain = lp.RDSInjection - + // Signal path (matches professional broadcast processors): + // Audio L/R → × Drive → Stereo-linked limiter → Stereo encoder + // → Mono + Stereo sub (from limited audio, natural levels) + // → + Pilot (fixed) → + RDS (fixed) → FM modulator + // + // The limiter never sees the 38kHz subcarrier, so it can't pump + // the stereo difference signal. Pilot and RDS are post-encoder + // at fixed amplitudes, unaffected by audio dynamics. + // + // Audio ceiling is auto-reduced to leave headroom for pilot + RDS, + // so total composite stays within ±ceiling (= ±75kHz deviation). ceiling := lp.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } + pilotAmp := lp.PilotLevel * lp.OutputDrive + rdsAmp := lp.RDSInjection * lp.OutputDrive + audioCeiling := ceiling - pilotAmp - rdsAmp + if audioCeiling < 0.3 { audioCeiling = 0.3 } // safety floor for i := 0; i < samples; i++ { in := g.source.NextFrame() - comps := g.stereoEncoder.Encode(in) - if !lp.StereoEnabled { - comps.Stereo = 0; comps.Pilot = 0 + // --- Stage 1: Band-limit pre-emphasized audio --- + // The 15kHz LPF goes BEFORE drive+limiter. Pre-emphasis boosts + // HF by up to +13.5dB. Without the LPF, the limiter would waste + // gain reduction on HF peaks that get filtered later, causing + // wild modulation swings (30-163%). With LPF first, the limiter + // sees the final audio bandwidth and sets gain correctly. + l := g.lpfL.Process(float64(in.L)) + r := g.lpfR.Process(float64(in.R)) + + // --- Stage 2: Scale and limit --- + l *= lp.OutputDrive + r *= lp.OutputDrive + + if lp.LimiterEnabled && g.limiter != nil { + l, r = g.limiter.Process(l, r) } - rdsValue := 0.0 + // --- Stage 3: Stereo encode the limited, filtered audio --- + limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) + comps := g.stereoEncoder.Encode(limited) + + // --- Stage 3: Combine at fixed levels --- + composite := float64(comps.Mono) + if lp.StereoEnabled { + composite += float64(comps.Stereo) + composite += pilotAmp * comps.Pilot + } if g.rdsEnc != nil && lp.RDSEnabled { rdsCarrier := g.stereoEncoder.RDSCarrier() - rdsValue = g.rdsEnc.NextSampleWithCarrier(rdsCarrier) + rdsValue := g.rdsEnc.NextSampleWithCarrier(rdsCarrier) + composite += rdsAmp * rdsValue } - composite := g.combiner.Combine(comps.Mono, comps.Stereo, comps.Pilot, rdsValue) - composite *= lp.OutputDrive - - if lp.LimiterEnabled && g.limiter != nil { - composite = g.limiter.Process(composite) + // Final composite safety clip — only fires on brief limiter + // overshoots during fast transients. Clips the entire composite, + // not individual audio bands, so harmonics don't target RDS. + if lp.LimiterEnabled { composite = dsp.HardClip(composite, ceiling) } diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 0a411f8..24b6b8b 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -83,8 +83,11 @@ func TestLimiterPreventsClipping(t *testing.T) { cfg.FM.FMModulationEnabled = false cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) + // Total composite (audio + pilot + RDS) should stay within ceiling. + // Audio ceiling is auto-reduced to leave headroom for pilot + RDS. + maxAllowed := cfg.FM.LimiterCeiling + 0.02 // small tolerance for limiter settling for i, s := range frame.Samples { - if math.Abs(float64(s.I)) > 1.01 { t.Fatalf("sample %d: %.4f exceeds ceiling", i, s.I) } + if math.Abs(float64(s.I)) > maxAllowed { t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) } } } diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index 8abcaf5..fd54455 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -178,14 +178,17 @@ func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 { if e.sampleCount >= e.spb { if e.bitPos >= bitsPerGroup { // Apply live text updates at group boundaries (~88ms at 228kHz). - // This is the only place we read the atomics — zero per-sample overhead. + // Atomics are consumed (cleared) after reading to prevent + // re-applying the same text every group and toggling A/B flag. if ps, ok := e.livePS.Load().(string); ok && ps != "" { e.scheduler.cfg.PS = ps + e.livePS.Store("") // consumed } if rt, ok := e.liveRT.Load().(string); ok && rt != "" { e.scheduler.cfg.RT = rt e.scheduler.rtIdx = 0 // restart RT transmission for new text e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec + e.liveRT.Store("") // consumed } e.getRDSGroup() e.bitPos = 0 diff --git a/stream_tx.bat b/stream_tx.bat new file mode 100644 index 0000000..69a9bfb --- /dev/null +++ b/stream_tx.bat @@ -0,0 +1,2 @@ +@echo off +ffmpeg -i "http://stream.srg-ssr.ch/m/drs3/mp3_128" -f s16le -ar 44100 -ac 2 - | fmrtx.exe --tx --tx-auto-start --audio-stdin --config docs/config.plutosdr.json \ No newline at end of file From 934e601755aaa68733fdfb2974cbb3cb4773c929 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 22:10:33 +0200 Subject: [PATCH 2/7] refactor: harden broadcast DSP chain around pilot and RDS protection Upgrade the audio path to a broadcast-style chain with a 4th-order 15 kHz low-pass, 19 kHz pilot notch, audio-only composite clipping, and post-clip protection notches at 19/57 kHz before adding clean pilot and RDS carriers. This reorders the processing so pilot and RDS stay fixed and unclipped while audio dynamics are constrained within the available modulation budget. --- internal/dsp/biquad.go | 134 ++++++++++++++++++++++++++-------- internal/offline/generator.go | 105 ++++++++++++++++---------- 2 files changed, 172 insertions(+), 67 deletions(-) diff --git a/internal/dsp/biquad.go b/internal/dsp/biquad.go index 90270aa..827810d 100644 --- a/internal/dsp/biquad.go +++ b/internal/dsp/biquad.go @@ -2,35 +2,63 @@ package dsp import "math" -// BiquadLPF is a second-order Butterworth lowpass filter (biquad, direct form II transposed). -// Used after the audio limiter to remove intermodulation products and harmonics -// that could fall into the 19kHz pilot, 38kHz stereo sub, or 57kHz RDS bands. -// -// At 228kHz with fc=15kHz: -// 15kHz: -3 dB (corner) -// 19kHz: -5 dB -// 38kHz: -18 dB -// 57kHz: -27 dB ← protects RDS band -type BiquadLPF struct { +// Biquad is a generic second-order IIR filter (direct form II transposed). +type Biquad struct { b0, b1, b2 float64 a1, a2 float64 - z1, z2 float64 // state (direct form II transposed) + z1, z2 float64 } -// NewBiquadLPF creates a 2nd-order Butterworth lowpass at the given cutoff. -func NewBiquadLPF(cutoffHz, sampleRate float64) *BiquadLPF { - if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { - // Passthrough: return unity filter - return &BiquadLPF{b0: 1} +// Process filters one sample. +func (f *Biquad) Process(in float64) float64 { + out := f.b0*in + f.z1 + f.z1 = f.b1*in - f.a1*out + f.z2 + f.z2 = f.b2*in - f.a2*out + return out +} + +// Reset clears state. +func (f *Biquad) Reset() { f.z1 = 0; f.z2 = 0 } + +// FilterChain cascades multiple biquad sections in series. +// Used for higher-order filters (e.g. 4th-order = 2 biquads). +type FilterChain struct { + Stages []Biquad +} + +// Process runs input through all stages in series. +func (c *FilterChain) Process(in float64) float64 { + x := in + for i := range c.Stages { + x = c.Stages[i].Process(x) } + return x +} + +// Reset clears all filter state. +func (c *FilterChain) Reset() { + for i := range c.Stages { + c.Stages[i].Reset() + } +} +// --- Factory functions --- + +// NewBiquadLPF creates a 2nd-order Butterworth lowpass (Q = 1/√2). +func NewBiquadLPF(cutoffHz, sampleRate float64) *Biquad { + return newBiquadLPFWithQ(cutoffHz, sampleRate, math.Sqrt2/2) +} + +func newBiquadLPFWithQ(cutoffHz, sampleRate, q float64) *Biquad { + if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { + return &Biquad{b0: 1} // passthrough + } omega := 2 * math.Pi * cutoffHz / sampleRate cosW := math.Cos(omega) sinW := math.Sin(omega) - alpha := sinW / (2 * math.Sqrt2) // Q = 1/√2 for Butterworth - + alpha := sinW / (2 * q) a0 := 1 + alpha - return &BiquadLPF{ + return &Biquad{ b0: (1 - cosW) / 2 / a0, b1: (1 - cosW) / a0, b2: (1 - cosW) / 2 / a0, @@ -39,16 +67,64 @@ func NewBiquadLPF(cutoffHz, sampleRate float64) *BiquadLPF { } } -// Process filters a single sample. -func (f *BiquadLPF) Process(in float64) float64 { - out := f.b0*in + f.z1 - f.z1 = f.b1*in - f.a1*out + f.z2 - f.z2 = f.b2*in - f.a2*out - return out +// NewLPF4 creates a 4th-order Butterworth lowpass (two cascaded biquads). +// Provides -24dB/octave rolloff. At 228kHz with fc=15kHz: +// +// 15kHz: -6dB, 19kHz: -14dB, 38kHz: -36dB, 57kHz: -54dB +func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { + // 4th-order Butterworth: cascade two 2nd-order sections with Q values + // derived from the pole angles: π/8 and 3π/8 + q1 := 1.0 / (2 * math.Cos(math.Pi/8)) // ≈ 0.5412 + q2 := 1.0 / (2 * math.Cos(3*math.Pi/8)) // ≈ 1.3066 + return &FilterChain{ + Stages: []Biquad{ + *newBiquadLPFWithQ(cutoffHz, sampleRate, q1), + *newBiquadLPFWithQ(cutoffHz, sampleRate, q2), + }, + } +} + +// NewNotch creates a 2nd-order IIR notch (bandstop) filter. +// Q controls width: higher Q = narrower notch. +// Typical: Q=5 → ~4kHz wide at -3dB, Q=10 → ~2kHz wide. +func NewNotch(centerHz, sampleRate, q float64) *Biquad { + if centerHz <= 0 || sampleRate <= 0 || centerHz >= sampleRate/2 { + return &Biquad{b0: 1} + } + omega := 2 * math.Pi * centerHz / sampleRate + cosW := math.Cos(omega) + alpha := math.Sin(omega) / (2 * q) + a0 := 1 + alpha + return &Biquad{ + b0: 1 / a0, + b1: -2 * cosW / a0, + b2: 1 / a0, + a1: -2 * cosW / a0, + a2: (1 - alpha) / a0, + } +} + +// --- Broadcast-specific filter factories --- + +// NewAudioLPF creates the broadcast-standard 15kHz audio lowpass. +// 4th-order Butterworth ensures the guard band (15–23kHz) is clean. +func NewAudioLPF(sampleRate float64) *FilterChain { + return NewLPF4(15000, sampleRate) +} + +// NewPilotNotch creates a narrow notch at 19kHz to kill residual audio +// energy at the pilot frequency. Applied BEFORE stereo encoding. +// Q=5: -3dB width ~4kHz, >40dB rejection at 19kHz, <0.5dB loss at 15kHz. +func NewPilotNotch(sampleRate float64) *Biquad { + return NewNotch(19000, sampleRate, 5) } -// Reset clears the filter state. -func (f *BiquadLPF) Reset() { - f.z1 = 0 - f.z2 = 0 +// NewCompositeProtection creates notch filters for the composite clipper. +// Applied to clipped audio composite (mono+stereo_sub) to remove clip +// harmonics from the pilot (19kHz) and RDS (57kHz) bands before adding +// the actual pilot and RDS signals at clean, fixed levels. +func NewCompositeProtection(sampleRate float64) (notch19, notch57 *Biquad) { + notch19 = NewNotch(19000, sampleRate, 3) // wider Q for broadband clip artifacts + notch57 = NewNotch(57000, sampleRate, 3) + return } diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 51ec8b1..c4170f3 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -77,13 +77,24 @@ type Generator struct { stereoEncoder stereo.StereoEncoder rdsEnc *rds.Encoder combiner mpx.DefaultCombiner - limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding - lpfL, lpfR *dsp.BiquadLPF // 15kHz lowpass after limiter, protects RDS band + limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding fmMod *dsp.FMModulator sampleRate float64 initialized bool frameSeq uint64 + // Broadcast-standard audio filter chain (per channel, L and R): + // Pre-emphasis → 15kHz LPF (4th-order) → 19kHz Notch → Drive → Limiter + audioLPF_L *dsp.FilterChain // 4th-order Butterworth 15kHz + audioLPF_R *dsp.FilterChain + pilotNotchL *dsp.Biquad // 19kHz notch (guard band protection) + pilotNotchR *dsp.Biquad + + // Composite clipper protection (post-clip notch filters): + // Audio composite → clip → notch 19kHz → notch 57kHz → + pilot → + RDS + mpxNotch19 *dsp.Biquad // removes clip harmonics at pilot freq + mpxNotch57 *dsp.Biquad // removes clip harmonics at RDS freq + // Pre-allocated frame buffer — reused every GenerateFrame call. frameBuf *output.CompositeFrame bufCap int @@ -160,10 +171,16 @@ func (g *Generator) init() { if g.cfg.FM.LimiterEnabled { g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate) } - // 15kHz lowpass after limiter — removes limiter gain-step intermodulation - // products that would otherwise fall into pilot/stereo/RDS bands. - g.lpfL = dsp.NewBiquadLPF(15000, g.sampleRate) - g.lpfR = dsp.NewBiquadLPF(15000, g.sampleRate) + + // Broadcast-standard filter chain: + // 1) 15kHz 4th-order Butterworth LPF — steep guard band, -14dB@19kHz, -54dB@57kHz + g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate) + g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate) + // 2) 19kHz notch — kills residual audio at pilot freq, >40dB rejection + g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate) + g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate) + // 3) Composite clipper protection notches at 19kHz + 57kHz + g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) if g.cfg.FM.FMModulationEnabled { g.fmMod = dsp.NewFMModulator(g.sampleRate) if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } @@ -223,52 +240,71 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} } - // Signal path (matches professional broadcast processors): - // Audio L/R → × Drive → Stereo-linked limiter → Stereo encoder - // → Mono + Stereo sub (from limited audio, natural levels) - // → + Pilot (fixed) → + RDS (fixed) → FM modulator + // Broadcast-standard FM MPX signal chain: // - // The limiter never sees the 38kHz subcarrier, so it can't pump - // the stereo difference signal. Pilot and RDS are post-encoder - // at fixed amplitudes, unaffected by audio dynamics. + // Audio L/R + // → PreEmphasis (50µs EU / 75µs US) + // → 15kHz LPF (4th-order Butterworth, -14dB@19kHz, -54dB@57kHz) + // → 19kHz Notch (>40dB rejection, guard band protection) + // → × OutputDrive + // → StereoLimiter (instant attack, smooth release) + // → Stereo Encode → Mono (L+R)/2 + Stereo Sub (L-R)/2 × 38kHz + // Audio MPX composite + // → HardClip at audioCeiling (catches limiter overshoots) + // → 19kHz Notch (removes clip harmonics at pilot freq) + // → 57kHz Notch (removes clip harmonics at RDS freq) + // + Pilot 19kHz (fixed amplitude, post-clip) + // + RDS 57kHz (fixed amplitude, post-clip) + // → FM Modulator // - // Audio ceiling is auto-reduced to leave headroom for pilot + RDS, - // so total composite stays within ±ceiling (= ±75kHz deviation). + // Key: Pilot and RDS are NEVER clipped or filtered. They're added + // after all audio processing at constant amplitude. ceiling := lp.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } pilotAmp := lp.PilotLevel * lp.OutputDrive rdsAmp := lp.RDSInjection * lp.OutputDrive audioCeiling := ceiling - pilotAmp - rdsAmp - if audioCeiling < 0.3 { audioCeiling = 0.3 } // safety floor + if audioCeiling < 0.3 { audioCeiling = 0.3 } for i := 0; i < samples; i++ { in := g.source.NextFrame() - // --- Stage 1: Band-limit pre-emphasized audio --- - // The 15kHz LPF goes BEFORE drive+limiter. Pre-emphasis boosts - // HF by up to +13.5dB. Without the LPF, the limiter would waste - // gain reduction on HF peaks that get filtered later, causing - // wild modulation swings (30-163%). With LPF first, the limiter - // sees the final audio bandwidth and sets gain correctly. - l := g.lpfL.Process(float64(in.L)) - r := g.lpfR.Process(float64(in.R)) - - // --- Stage 2: Scale and limit --- + // --- Stage 1: Audio filtering (per-channel) --- + // 15kHz LPF removes out-of-band pre-emphasis energy. + // 19kHz notch kills residual energy at pilot frequency. + // Both run BEFORE drive+limiter so the limiter sees the + // actual audio bandwidth, not wasted HF energy. + l := g.audioLPF_L.Process(float64(in.L)) + l = g.pilotNotchL.Process(l) + r := g.audioLPF_R.Process(float64(in.R)) + r = g.pilotNotchR.Process(r) + + // --- Stage 2: Drive + Limit --- l *= lp.OutputDrive r *= lp.OutputDrive - if lp.LimiterEnabled && g.limiter != nil { l, r = g.limiter.Process(l, r) } - // --- Stage 3: Stereo encode the limited, filtered audio --- + // --- Stage 3: Stereo encode --- limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) comps := g.stereoEncoder.Encode(limited) - // --- Stage 3: Combine at fixed levels --- - composite := float64(comps.Mono) + // --- Stage 4: Audio composite clip + protection --- + // Clip the audio-only composite (mono + stereo sub) to budget. + // Then notch-filter the clip harmonics out of the pilot (19kHz) + // and RDS (57kHz) bands before adding the real pilot and RDS. + audioMPX := float64(comps.Mono) + if lp.StereoEnabled { + audioMPX += float64(comps.Stereo) + } + audioMPX = dsp.HardClip(audioMPX, audioCeiling) + audioMPX = g.mpxNotch19.Process(audioMPX) + audioMPX = g.mpxNotch57.Process(audioMPX) + + // --- Stage 5: Add protected components at fixed levels --- + composite := audioMPX if lp.StereoEnabled { - composite += float64(comps.Stereo) composite += pilotAmp * comps.Pilot } if g.rdsEnc != nil && lp.RDSEnabled { @@ -277,13 +313,6 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame composite += rdsAmp * rdsValue } - // Final composite safety clip — only fires on brief limiter - // overshoots during fast transients. Clips the entire composite, - // not individual audio bands, so harmonics don't target RDS. - if lp.LimiterEnabled { - composite = dsp.HardClip(composite, ceiling) - } - if g.fmMod != nil { iq_i, iq_q := g.fmMod.Modulate(composite) frame.Samples[i] = output.IQSample{I: float32(iq_i), Q: float32(iq_q)} From 213069a11aae21dc79e2efe27c23100617846807 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Fri, 3 Apr 2026 23:38:50 +0200 Subject: [PATCH 3/7] refactor: switch FM path to clip-filter-clip processing Rework the DSP chain to a clip-filter-clip architecture with cascaded 14 kHz low-pass stages, double 19/57 kHz protection notches, fixed pilot/RDS injection semantics, and explicit MPX gain calibration support. Update config defaults and tests to match the new broadcast-style modulation budgeting and protected composite path. --- docs/config.plutosdr.json | 7 +- internal/app/engine.go | 5 ++ internal/config/config.go | 16 ++-- internal/dsp/biquad.go | 71 ++++++++++----- internal/offline/generator.go | 136 ++++++++++++++++------------- internal/offline/generator_test.go | 34 +++++--- internal/rds/encoder.go | 12 +++ 7 files changed, 180 insertions(+), 101 deletions(-) diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 51c3b28..1f9cec8 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -16,10 +16,11 @@ "fm": { "frequencyMHz": 100.0, "stereoEnabled": true, - "pilotLevel": 0.041, - "rdsInjection": 0.021, + "pilotLevel": 0.09, + "rdsInjection": 0.04, "preEmphasisTauUS": 50, - "outputDrive": 2.2, + "outputDrive": 4.3, + "mpxGain": 1.0, "compositeRateHz": 228000, "maxDeviationHz": 75000, "limiterEnabled": true, diff --git a/internal/app/engine.go b/internal/app/engine.go index 0139a9b..3e84b34 100644 --- a/internal/app/engine.go +++ b/internal/app/engine.go @@ -115,6 +115,11 @@ func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { if maxDev <= 0 { maxDev = 75000 } + // mpxGain scales the FM deviation to compensate for hardware + // DAC/SDR scaling factors. DSP chain stays at logical 0-1.0 levels. + if cfg.FM.MpxGain > 0 && cfg.FM.MpxGain != 1.0 { + maxDev *= cfg.FM.MpxGain + } upsampler = dsp.NewFMUpsampler(compositeRate, deviceRate, maxDev) log.Printf("engine: split-rate mode — DSP@%.0fHz → upsample@%.0fHz (ratio %.2f)", compositeRate, deviceRate, deviceRate/compositeRate) diff --git a/internal/config/config.go b/internal/config/config.go index 584d189..2ea3c11 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,8 +35,8 @@ type RDSConfig struct { type FMConfig struct { FrequencyMHz float64 `json:"frequencyMHz"` StereoEnabled bool `json:"stereoEnabled"` - PilotLevel float64 `json:"pilotLevel"` // linear injection level in composite (e.g. 0.1 = 10%) - RDSInjection float64 `json:"rdsInjection"` // linear injection level in composite (e.g. 0.05 = 5%) + PilotLevel float64 `json:"pilotLevel"` // fraction of ±75kHz deviation (0.09 = 9%, ITU standard) + RDSInjection float64 `json:"rdsInjection"` // fraction of ±75kHz deviation (0.04 = 4%, typical) PreEmphasisTauUS float64 `json:"preEmphasisTauUS"` // time constant in µs: 50 (EU) or 75 (US), 0=off OutputDrive float64 `json:"outputDrive"` CompositeRateHz int `json:"compositeRateHz"` // internal DSP/MPX sample rate @@ -44,6 +44,7 @@ type FMConfig struct { LimiterEnabled bool `json:"limiterEnabled"` LimiterCeiling float64 `json:"limiterCeiling"` FMModulationEnabled bool `json:"fmModulationEnabled"` + MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) } type BackendConfig struct { @@ -64,8 +65,8 @@ func Default() Config { FM: FMConfig{ FrequencyMHz: 100.0, StereoEnabled: true, - PilotLevel: 0.1, - RDSInjection: 0.05, + PilotLevel: 0.09, + RDSInjection: 0.04, PreEmphasisTauUS: 50, OutputDrive: 0.5, CompositeRateHz: 228000, @@ -73,6 +74,7 @@ func Default() Config { LimiterEnabled: true, LimiterCeiling: 1.0, FMModulationEnabled: true, + MpxGain: 1.0, }, Backend: BackendConfig{Kind: "file", OutputPath: "build/out/composite.f32"}, Control: ControlConfig{ListenAddress: "127.0.0.1:8088"}, @@ -128,7 +130,7 @@ func (c Config) Validate() error { if c.FM.RDSInjection < 0 || c.FM.RDSInjection > 0.15 { return fmt.Errorf("fm.rdsInjection out of range") } - if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 3 { + if c.FM.OutputDrive < 0 || c.FM.OutputDrive > 10 { return fmt.Errorf("fm.outputDrive out of range (0..3)") } if c.FM.CompositeRateHz < 96000 || c.FM.CompositeRateHz > 1520000 { @@ -143,6 +145,10 @@ func (c Config) Validate() error { if c.FM.LimiterCeiling < 0 || c.FM.LimiterCeiling > 2 { return fmt.Errorf("fm.limiterCeiling out of range") } + if c.FM.MpxGain == 0 { c.FM.MpxGain = 1.0 } // default if omitted from JSON + if c.FM.MpxGain < 0.1 || c.FM.MpxGain > 5 { + return fmt.Errorf("fm.mpxGain out of range (0.1..5)") + } if c.Backend.Kind == "" { return fmt.Errorf("backend.kind is required") } diff --git a/internal/dsp/biquad.go b/internal/dsp/biquad.go index 827810d..39f6048 100644 --- a/internal/dsp/biquad.go +++ b/internal/dsp/biquad.go @@ -68,12 +68,7 @@ func newBiquadLPFWithQ(cutoffHz, sampleRate, q float64) *Biquad { } // NewLPF4 creates a 4th-order Butterworth lowpass (two cascaded biquads). -// Provides -24dB/octave rolloff. At 228kHz with fc=15kHz: -// -// 15kHz: -6dB, 19kHz: -14dB, 38kHz: -36dB, 57kHz: -54dB func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { - // 4th-order Butterworth: cascade two 2nd-order sections with Q values - // derived from the pole angles: π/8 and 3π/8 q1 := 1.0 / (2 * math.Cos(math.Pi/8)) // ≈ 0.5412 q2 := 1.0 / (2 * math.Cos(3*math.Pi/8)) // ≈ 1.3066 return &FilterChain{ @@ -84,6 +79,26 @@ func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { } } +// NewLPF8 creates an 8th-order Butterworth lowpass (four cascaded biquads). +// Provides -48dB/octave rolloff. At 228kHz with fc=15kHz: +// +// 15kHz: -6dB, 19kHz: -28dB, 38kHz: -72dB, 57kHz: -108dB +func NewLPF8(cutoffHz, sampleRate float64) *FilterChain { + // 8th-order Butterworth pole angles: π/16, 3π/16, 5π/16, 7π/16 + q1 := 1.0 / (2 * math.Cos(math.Pi/16)) // ≈ 0.5098 + q2 := 1.0 / (2 * math.Cos(3*math.Pi/16)) // ≈ 0.6013 + q3 := 1.0 / (2 * math.Cos(5*math.Pi/16)) // ≈ 0.8999 + q4 := 1.0 / (2 * math.Cos(7*math.Pi/16)) // ≈ 2.5629 + return &FilterChain{ + Stages: []Biquad{ + *newBiquadLPFWithQ(cutoffHz, sampleRate, q1), + *newBiquadLPFWithQ(cutoffHz, sampleRate, q2), + *newBiquadLPFWithQ(cutoffHz, sampleRate, q3), + *newBiquadLPFWithQ(cutoffHz, sampleRate, q4), + }, + } +} + // NewNotch creates a 2nd-order IIR notch (bandstop) filter. // Q controls width: higher Q = narrower notch. // Typical: Q=5 → ~4kHz wide at -3dB, Q=10 → ~2kHz wide. @@ -106,25 +121,41 @@ func NewNotch(centerHz, sampleRate, q float64) *Biquad { // --- Broadcast-specific filter factories --- -// NewAudioLPF creates the broadcast-standard 15kHz audio lowpass. -// 4th-order Butterworth ensures the guard band (15–23kHz) is clean. +// NewAudioLPF creates the broadcast-standard audio lowpass at 14kHz. +// 8th-order Butterworth: -21dB@19kHz per pass. Two passes through the +// clip-filter-clip loop give -42dB broadband floor at 19kHz. func NewAudioLPF(sampleRate float64) *FilterChain { - return NewLPF4(15000, sampleRate) + return NewLPF8(14000, sampleRate) } -// NewPilotNotch creates a narrow notch at 19kHz to kill residual audio -// energy at the pilot frequency. Applied BEFORE stereo encoding. -// Q=5: -3dB width ~4kHz, >40dB rejection at 19kHz, <0.5dB loss at 15kHz. -func NewPilotNotch(sampleRate float64) *Biquad { - return NewNotch(19000, sampleRate, 5) +// NewPilotNotch creates a double-cascade 19kHz notch for maximum +// rejection at the pilot frequency. Two stages give >60dB rejection. +// Applied BEFORE stereo encoding to kill audio energy at 19kHz. +func NewPilotNotch(sampleRate float64) *FilterChain { + return &FilterChain{ + Stages: []Biquad{ + *NewNotch(19000, sampleRate, 5), + *NewNotch(19000, sampleRate, 5), + }, + } } -// NewCompositeProtection creates notch filters for the composite clipper. -// Applied to clipped audio composite (mono+stereo_sub) to remove clip -// harmonics from the pilot (19kHz) and RDS (57kHz) bands before adding -// the actual pilot and RDS signals at clean, fixed levels. -func NewCompositeProtection(sampleRate float64) (notch19, notch57 *Biquad) { - notch19 = NewNotch(19000, sampleRate, 3) // wider Q for broadband clip artifacts - notch57 = NewNotch(57000, sampleRate, 3) +// NewCompositeProtection creates double-cascade notch filters for the +// composite clipper. Each band gets two notch stages for >60dB rejection. +// Applied to clipped audio composite to remove clip harmonics from the +// pilot (19kHz) and RDS (57kHz) bands. +func NewCompositeProtection(sampleRate float64) (notch19, notch57 *FilterChain) { + notch19 = &FilterChain{ + Stages: []Biquad{ + *NewNotch(19000, sampleRate, 3), + *NewNotch(19000, sampleRate, 3), + }, + } + notch57 = &FilterChain{ + Stages: []Biquad{ + *NewNotch(57000, sampleRate, 3), + *NewNotch(57000, sampleRate, 3), + }, + } return } diff --git a/internal/offline/generator.go b/internal/offline/generator.go index c4170f3..9dfcc72 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -31,6 +31,7 @@ type LiveParams struct { RDSEnabled bool LimiterEnabled bool LimiterCeiling float64 + MpxGain float64 // hardware calibration factor for composite output } // PreEmphasizedSource wraps an audio source and applies pre-emphasis. @@ -77,23 +78,28 @@ type Generator struct { stereoEncoder stereo.StereoEncoder rdsEnc *rds.Encoder combiner mpx.DefaultCombiner - limiter *dsp.StereoLimiter // stereo-linked, operates on L/R BEFORE stereo encoding fmMod *dsp.FMModulator sampleRate float64 initialized bool frameSeq uint64 - // Broadcast-standard audio filter chain (per channel, L and R): - // Pre-emphasis → 15kHz LPF (4th-order) → 19kHz Notch → Drive → Limiter - audioLPF_L *dsp.FilterChain // 4th-order Butterworth 15kHz - audioLPF_R *dsp.FilterChain - pilotNotchL *dsp.Biquad // 19kHz notch (guard band protection) - pilotNotchR *dsp.Biquad - - // Composite clipper protection (post-clip notch filters): - // Audio composite → clip → notch 19kHz → notch 57kHz → + pilot → + RDS - mpxNotch19 *dsp.Biquad // removes clip harmonics at pilot freq - mpxNotch57 *dsp.Biquad // removes clip harmonics at RDS freq + // Broadcast-standard clip-filter-clip chain (per channel L/R): + // + // PreEmph → LPF₁(14kHz) → Notch(19kHz) → ×Drive + // → StereoLimiter (slow AGC: raises average level) + // → Clip₁ → LPF₂(14kHz) [cleanup] → Clip₂ [catches LPF overshoots] + // → Stereo Encode → Composite Clip → Notch₁₉ → Notch₅₇ + // → + Pilot → + RDS → FM + // + audioLPF_L *dsp.FilterChain // 14kHz 8th-order (pre-clip) + audioLPF_R *dsp.FilterChain + pilotNotchL *dsp.FilterChain // 19kHz double-notch (guard band) + pilotNotchR *dsp.FilterChain + limiter *dsp.StereoLimiter // slow compressor (raises average, clips catch peaks) + cleanupLPF_L *dsp.FilterChain // 14kHz 8th-order (post-clip cleanup) + cleanupLPF_R *dsp.FilterChain + mpxNotch19 *dsp.FilterChain // composite clipper protection + mpxNotch57 *dsp.FilterChain // Pre-allocated frame buffer — reused every GenerateFrame call. frameBuf *output.CompositeFrame @@ -130,7 +136,7 @@ func (g *Generator) CurrentLiveParams() LiveParams { if lp := g.liveParams.Load(); lp != nil { return *lp } - return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} + return LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } // RDSEncoder returns the live RDS encoder, or nil if RDS is disabled. @@ -163,27 +169,32 @@ func (g *Generator) init() { } ceiling := g.cfg.FM.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } - // Audio ceiling leaves headroom for pilot + RDS so total ≤ ceiling - pilotAmp := g.cfg.FM.PilotLevel * g.cfg.FM.OutputDrive - rdsAmp := g.cfg.FM.RDSInjection * g.cfg.FM.OutputDrive - audioCeiling := ceiling - pilotAmp - rdsAmp - if audioCeiling < 0.3 { audioCeiling = 0.3 } - if g.cfg.FM.LimiterEnabled { - g.limiter = dsp.NewStereoLimiter(audioCeiling, 0.5, 100, g.sampleRate) - } - // Broadcast-standard filter chain: - // 1) 15kHz 4th-order Butterworth LPF — steep guard band, -14dB@19kHz, -54dB@57kHz + // Broadcast clip-filter-clip chain: + // Pre-clip: 14kHz LPF (8th-order) + 19kHz double-notch (per channel) g.audioLPF_L = dsp.NewAudioLPF(g.sampleRate) g.audioLPF_R = dsp.NewAudioLPF(g.sampleRate) - // 2) 19kHz notch — kills residual audio at pilot freq, >40dB rejection g.pilotNotchL = dsp.NewPilotNotch(g.sampleRate) g.pilotNotchR = dsp.NewPilotNotch(g.sampleRate) - // 3) Composite clipper protection notches at 19kHz + 57kHz + // Slow compressor: 5ms attack / 200ms release. Brings average level UP. + // The clips after it catch the peaks the limiter's attack time misses. + // This is the "slow-to-fast progression" from broadcast processing: + // slow limiter → fast clips. + g.limiter = dsp.NewStereoLimiter(ceiling, 5, 200, g.sampleRate) + // Post-clip cleanup: second 14kHz LPF pass (removes clip harmonics) + g.cleanupLPF_L = dsp.NewAudioLPF(g.sampleRate) + g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate) + // Composite clipper protection: double-notch at 19kHz + 57kHz g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) if g.cfg.FM.FMModulationEnabled { g.fmMod = dsp.NewFMModulator(g.sampleRate) - if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } + maxDev := g.cfg.FM.MaxDeviationHz + if maxDev > 0 { + if g.cfg.FM.MpxGain > 0 && g.cfg.FM.MpxGain != 1.0 { + maxDev *= g.cfg.FM.MpxGain + } + g.fmMod.MaxDeviation = maxDev + } } // Seed initial live params from config @@ -195,6 +206,7 @@ func (g *Generator) init() { RDSEnabled: g.cfg.RDS.Enabled, LimiterEnabled: g.cfg.FM.LimiterEnabled, LimiterCeiling: ceiling, + MpxGain: g.cfg.FM.MpxGain, }) g.initialized = true @@ -237,72 +249,74 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame lp := g.liveParams.Load() if lp == nil { // Fallback: should never happen after init(), but be safe - lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0} + lp = &LiveParams{OutputDrive: 1.0, LimiterCeiling: 1.0, MpxGain: 1.0} } - // Broadcast-standard FM MPX signal chain: + // Broadcast clip-filter-clip FM MPX signal chain: // - // Audio L/R - // → PreEmphasis (50µs EU / 75µs US) - // → 15kHz LPF (4th-order Butterworth, -14dB@19kHz, -54dB@57kHz) - // → 19kHz Notch (>40dB rejection, guard band protection) - // → × OutputDrive - // → StereoLimiter (instant attack, smooth release) - // → Stereo Encode → Mono (L+R)/2 + Stereo Sub (L-R)/2 × 38kHz - // Audio MPX composite - // → HardClip at audioCeiling (catches limiter overshoots) - // → 19kHz Notch (removes clip harmonics at pilot freq) - // → 57kHz Notch (removes clip harmonics at RDS freq) - // + Pilot 19kHz (fixed amplitude, post-clip) - // + RDS 57kHz (fixed amplitude, post-clip) + // Audio L/R → PreEmphasis + // → LPF₁ (14kHz, 8th-order) → 19kHz Notch (double) + // → × OutputDrive → HardClip₁ (ceiling) + // → LPF₂ (14kHz, 8th-order) [removes clip₁ harmonics] + // → HardClip₂ (ceiling) [catches LPF₂ overshoots] + // → Stereo Encode + // Audio MPX (mono + stereo sub) + // → HardClip₃ (ceiling) [composite deviation control] + // → 19kHz Notch (double) [protect pilot band] + // → 57kHz Notch (double) [protect RDS band] + // + Pilot 19kHz (fixed, NEVER clipped) + // + RDS 57kHz (fixed, NEVER clipped) // → FM Modulator // - // Key: Pilot and RDS are NEVER clipped or filtered. They're added - // after all audio processing at constant amplitude. + // Guard band depth at 19kHz: LPF₁(-21dB) + Notch(-60dB) + LPF₂(-21dB) + // + CompNotch(-60dB) → broadband floor -42dB, exact 19kHz >-90dB ceiling := lp.LimiterCeiling if ceiling <= 0 { ceiling = 1.0 } - pilotAmp := lp.PilotLevel * lp.OutputDrive - rdsAmp := lp.RDSInjection * lp.OutputDrive - audioCeiling := ceiling - pilotAmp - rdsAmp - if audioCeiling < 0.3 { audioCeiling = 0.3 } + // Pilot and RDS are FIXED injection levels, independent of OutputDrive. + // Config values directly represent percentage of ±75kHz deviation: + // pilotLevel: 0.09 = 9% = ±6.75kHz (ITU standard) + // rdsInjection: 0.04 = 4% = ±3.0kHz (typical) + pilotAmp := lp.PilotLevel + rdsAmp := lp.RDSInjection for i := 0; i < samples; i++ { in := g.source.NextFrame() - // --- Stage 1: Audio filtering (per-channel) --- - // 15kHz LPF removes out-of-band pre-emphasis energy. - // 19kHz notch kills residual energy at pilot frequency. - // Both run BEFORE drive+limiter so the limiter sees the - // actual audio bandwidth, not wasted HF energy. + // --- Stage 1: Band-limit pre-emphasized audio --- l := g.audioLPF_L.Process(float64(in.L)) l = g.pilotNotchL.Process(l) r := g.audioLPF_R.Process(float64(in.R)) r = g.pilotNotchR.Process(r) - // --- Stage 2: Drive + Limit --- + // --- Stage 2: Drive + Compress + Clip₁ --- l *= lp.OutputDrive r *= lp.OutputDrive - if lp.LimiterEnabled && g.limiter != nil { + if g.limiter != nil { l, r = g.limiter.Process(l, r) } + l = dsp.HardClip(l, ceiling) + r = dsp.HardClip(r, ceiling) + + // --- Stage 3: Cleanup LPF + Clip₂ (overshoot compensator) --- + l = g.cleanupLPF_L.Process(l) + r = g.cleanupLPF_R.Process(r) + l = dsp.HardClip(l, ceiling) + r = dsp.HardClip(r, ceiling) - // --- Stage 3: Stereo encode --- + // --- Stage 4: Stereo encode --- limited := audio.NewFrame(audio.Sample(l), audio.Sample(r)) comps := g.stereoEncoder.Encode(limited) - // --- Stage 4: Audio composite clip + protection --- - // Clip the audio-only composite (mono + stereo sub) to budget. - // Then notch-filter the clip harmonics out of the pilot (19kHz) - // and RDS (57kHz) bands before adding the real pilot and RDS. + // --- Stage 5: Composite clip + protection --- audioMPX := float64(comps.Mono) if lp.StereoEnabled { audioMPX += float64(comps.Stereo) } - audioMPX = dsp.HardClip(audioMPX, audioCeiling) + audioMPX = dsp.HardClip(audioMPX, ceiling) audioMPX = g.mpxNotch19.Process(audioMPX) audioMPX = g.mpxNotch57.Process(audioMPX) - // --- Stage 5: Add protected components at fixed levels --- + // --- Stage 6: Add protected components --- composite := audioMPX if lp.StereoEnabled { composite += pilotAmp * comps.Pilot diff --git a/internal/offline/generator_test.go b/internal/offline/generator_test.go index 24b6b8b..637ac29 100644 --- a/internal/offline/generator_test.go +++ b/internal/offline/generator_test.go @@ -83,9 +83,11 @@ func TestLimiterPreventsClipping(t *testing.T) { cfg.FM.FMModulationEnabled = false cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) - // Total composite (audio + pilot + RDS) should stay within ceiling. - // Audio ceiling is auto-reduced to leave headroom for pilot + RDS. - maxAllowed := cfg.FM.LimiterCeiling + 0.02 // small tolerance for limiter settling + // Audio clipped to ceiling, pilot+RDS added on top (standard broadcast). + // Total = ceiling + pilotLevel*drive + rdsInjection*drive + maxAllowed := cfg.FM.LimiterCeiling + + cfg.FM.PilotLevel*cfg.FM.OutputDrive + + cfg.FM.RDSInjection*cfg.FM.OutputDrive + 0.02 for i, s := range frame.Samples { if math.Abs(float64(s.I)) > maxAllowed { t.Fatalf("sample %d: %.4f exceeds max %.4f", i, s.I, maxAllowed) } } @@ -113,20 +115,28 @@ func TestFMModDisabledMeansComposite(t *testing.T) { } } -func TestLimiterDisabledAllowsHigherPeaks(t *testing.T) { +func TestClipFilterClipAlwaysActive(t *testing.T) { + // With clip-filter-clip architecture, peak control is always active + // regardless of LimiterEnabled (legacy flag). Both configs should + // produce the same peak level. base := cfgpkg.Default() base.FM.FMModulationEnabled = false base.Audio.ToneAmplitude = 0.9; base.Audio.Gain = 2.0; base.FM.OutputDrive = 1.0 - cfgLim := base; cfgLim.FM.LimiterEnabled = true; cfgLim.FM.LimiterCeiling = 1.0 - cfgNoLim := base; cfgNoLim.FM.LimiterEnabled = false + cfgA := base; cfgA.FM.LimiterEnabled = true; cfgA.FM.LimiterCeiling = 1.0 + cfgB := base; cfgB.FM.LimiterEnabled = false; cfgB.FM.LimiterCeiling = 1.0 - fLim := NewGenerator(cfgLim).GenerateFrame(50 * time.Millisecond) - fNoLim := NewGenerator(cfgNoLim).GenerateFrame(50 * time.Millisecond) + fA := NewGenerator(cfgA).GenerateFrame(50 * time.Millisecond) + fB := NewGenerator(cfgB).GenerateFrame(50 * time.Millisecond) - var maxLim, maxNoLim float64 - for _, s := range fLim.Samples { if math.Abs(float64(s.I)) > maxLim { maxLim = math.Abs(float64(s.I)) } } - for _, s := range fNoLim.Samples { if math.Abs(float64(s.I)) > maxNoLim { maxNoLim = math.Abs(float64(s.I)) } } + var maxA, maxB float64 + for _, s := range fA.Samples { if math.Abs(float64(s.I)) > maxA { maxA = math.Abs(float64(s.I)) } } + for _, s := range fB.Samples { if math.Abs(float64(s.I)) > maxB { maxB = math.Abs(float64(s.I)) } } - if maxNoLim <= maxLim { t.Fatalf("limiter disabled should allow higher peaks: lim=%.4f nolim=%.4f", maxLim, maxNoLim) } + // Both should be within ceiling + pilot + RDS + maxAllowed := cfgA.FM.LimiterCeiling + + cfgA.FM.PilotLevel*cfgA.FM.OutputDrive + + cfgA.FM.RDSInjection*cfgA.FM.OutputDrive + 0.02 + if maxA > maxAllowed { t.Fatalf("cfgA peak %.4f exceeds %.4f", maxA, maxAllowed) } + if maxB > maxAllowed { t.Fatalf("cfgB peak %.4f exceeds %.4f", maxB, maxAllowed) } } diff --git a/internal/rds/encoder.go b/internal/rds/encoder.go index fd54455..fbe7b93 100644 --- a/internal/rds/encoder.go +++ b/internal/rds/encoder.go @@ -119,6 +119,18 @@ func NewEncoder(cfg RDSConfig) (*Encoder, error) { waveform[i] = refWaveform[idx] } } + // Normalize to peak=1.0 so rdsInjection directly maps to injection %. + // The raw PiFmRds waveform peaks at ~0.543, which would make config + // values misleading (0.05 would give 2.7% instead of 5%). + var peak float64 + for _, v := range waveform { + if a := math.Abs(v); a > peak { peak = a } + } + if peak > 0 { + for i := range waveform { + waveform[i] /= peak + } + } ringSize := spb + wfLen From 6bb289ebc94640b9f90d4b4ef465268bf2a1e936 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sat, 4 Apr 2026 00:13:27 +0200 Subject: [PATCH 4/7] feat: add BS.412 limiter and document the clip-filter-clip chain Introduce an optional ITU-R BS.412 MPX power limiter, tighten the low-pass/notch filter design around the protected composite path, and document the full DSP architecture and recommended Pluto configuration in detail. --- docs/DSP-CHAIN.md | 287 ++++++++++++++++++++++++++++++++++ docs/config.plutosdr.json | 6 +- internal/config/config.go | 2 + internal/dsp/biquad.go | 97 ++++++++++-- internal/dsp/bs412.go | 154 ++++++++++++++++++ internal/offline/generator.go | 31 ++++ 6 files changed, 560 insertions(+), 17 deletions(-) create mode 100644 docs/DSP-CHAIN.md create mode 100644 internal/dsp/bs412.go diff --git a/docs/DSP-CHAIN.md b/docs/DSP-CHAIN.md new file mode 100644 index 0000000..3a287f6 --- /dev/null +++ b/docs/DSP-CHAIN.md @@ -0,0 +1,287 @@ +# fm-rds-tx — DSP Signal Chain & Konfiguration + +## Übersicht + +fm-rds-tx ist ein broadcast-konformer FM-Stereo-MPX-Encoder mit RDS für PlutoSDR/SoapySDR. +Die DSP-Kette folgt dem Industriestandard (Omnia, Orban, Stereo Tool) mit Clip-Filter-Clip- +Architektur und ITU-R BS.412 MPX Power Limiting. + +--- + +## Signalkette + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ AUDIO INPUT │ +│ S16LE Stereo PCM via stdin (ffmpeg) oder interner Tongenerator │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 1: Pre-Emphasis + Band-Limiting (pro Kanal L/R) │ +│ │ +│ Audio × gain ──→ Pre-Emphasis (50µs EU / 75µs US) │ +│ ──→ 15kHz LPF (8th-order Chebyshev Type I, 0.5dB Ripple) │ +│ ──→ 19kHz Notch (Q=15, double-cascade) │ +│ │ +│ Frequenzantwort (verifiziert): │ +│ 10kHz: +0.1dB (flat) 15kHz: -0.2dB │ +│ 17kHz: -21dB 18.5kHz: -40dB │ +│ 19kHz: -155dB (tot) 22kHz: -51dB │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 2: Drive + Kompression + Clip₁ │ +│ │ +│ × OutputDrive │ +│ ──→ StereoLimiter (5ms Attack / 200ms Release, ceiling) │ +│ ──→ HardClip₁ (ceiling) │ +│ │ +│ Der Limiter komprimiert die Dynamik (bringt Average hoch). │ +│ Der Clip fängt Peaks die der Limiter's Attack verpasst. │ +│ "Slow-to-fast Progression" — Broadcast-Standard. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 3: Cleanup LPF + Clip₂ (Overshoot-Kompensator) │ +│ │ +│ ──→ 15kHz LPF (8th-order Chebyshev, identisch zu Stage 1) │ +│ ──→ HardClip₂ (ceiling) │ +│ │ +│ Der zweite LPF-Pass entfernt Clip₁-Harmonische. │ +│ Clip₂ fängt die LPF-Overshoots (IIR-Filter erzeugen diese). │ +│ Doppelter LPF-Pass verdoppelt die Guard-Band-Dämpfung. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 4: Stereo-Encode │ +│ │ +│ L/R ──→ Mono: (L+R)/2 (0–15kHz Baseband) │ +│ ──→ Stereo: (L-R)/2 × sin(38kHz) (23–53kHz DSB-SC) │ +│ ──→ Pilot: sin(19kHz) (phase-locked, kohärent) │ +│ ──→ RDS Carrier: sin(57kHz) (3× Pilot-Phase, kohärent) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 5: Composite Clip + Schutzfilter │ +│ │ +│ Audio-MPX (Mono + Stereo-Sub) │ +│ ──→ HardClip₃ (ceiling) — finale Deviations-Kontrolle │ +│ ──→ 19kHz Notch (Q=10, double) — Clip-Harmonische bei Pilot │ +│ ──→ 57kHz Notch (Q=10, double) — Clip-Harmonische bei RDS │ +│ │ +│ Guard Bands (total, 2× LPF + Notches): │ +│ 19kHz: >-80dB broadband, >-90dB exakt │ +│ 57kHz: >-100dB │ +│ (Omnia 11 Spezifikation: >80dB — wir sind on par) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 6: BS.412 MPX Power Limiter (optional) │ +│ │ +│ ──→ × BS412 Gain │ +│ │ +│ Rolling 60-Sekunden RMS-Messung auf dem Audio-Composite. │ +│ Langsamer Gain-Regler (2s Attack / 5s Release). │ +│ Zieht Pilot+RDS-Power automatisch vom Budget ab. │ +│ Pflicht in CH, DE, NL, FR für lizenzierte FM-Sender. │ +│ ~5dB Lautheitsverlust bei 0 dBr Threshold. │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 7: Pilot + RDS Injection (fixe Amplitude) │ +│ │ +│ composite = audioMPX │ +│ + pilotLevel × sin(19kHz) — IMMER 9% │ +│ + rdsInjection × rdsWaveform — IMMER 4% │ +│ │ +│ Pilot und RDS werden NIE geclippt, NIE gefiltert, NIE vom │ +│ BS.412-Limiter berührt. Konstante Amplitude, immer. │ +│ │ +│ Peak Composite = ceiling + pilotLevel + rdsInjection ≈ 113% │ +│ (Standard-Broadcast-Praxis — Pilot/RDS werden von den meisten │ +│ Regulierungsbehörden aus dem Modulationslimit ausgenommen) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ STAGE 8: FM-Modulation │ +│ │ +│ Split-Rate: Composite @ 228kHz ──→ FMUpsampler ──→ IQ @ 2.28MHz│ +│ maxDeviation × mpxGain = effektive Deviation │ +│ composite=1.0 → ±75kHz Deviation (bei mpxGain=1.0) │ +│ │ +│ Ausgabe: IQ-Samples (float32) an PlutoSDR via libiio │ +└──────────────────────────────────────────────────────────────────┘ +``` + +--- + +## Konfiguration + +### Audio + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `audio.gain` | float | 1.0 | Eingangsverstärkung vor Pre-Emphasis. 1.0 = unity. | +| `audio.inputPath` | string | "" | WAV-Datei als Quelle (leer = stdin oder Tongenerator) | + +**Empfehlung:** `gain: 1.0`. Pegel-Kontrolle über `outputDrive`. + +### FM — Audio-Processing + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `outputDrive` | float | 0.5 | 0–10 | Eingangsverstärkung vor Limiter/Clip. Bestimmt wie aggressiv die Kompression arbeitet. | +| `limiterEnabled` | bool | true | — | Aktiviert den StereoLimiter (5ms/200ms). | +| `limiterCeiling` | float | 1.0 | 0–2 | Maximum-Amplitude für Audio L/R und Composite. 1.0 = ±75kHz. | +| `preEmphasisTauUS` | float | 50 | 0/50/75 | Pre-Emphasis Zeitkonstante. 50µs = Europa/CH, 75µs = USA, 0 = aus. | + +**outputDrive im Detail:** + +Der Drive bestimmt den *Klangcharakter*, nicht die Lautstärke (wenn BS.412 aktiv ist): + +| Drive | Effekt | Einsatz | +|---|---|---| +| 1–2 | Wenig Kompression, dynamisch, sauber | Klassik, Jazz, Wortbeiträge | +| 3–4 | Moderate Kompression, ausgewogen | **Empfohlen für die meisten Formate** | +| 5–7 | Aggressive Kompression, dichter Sound | Pop/Rock-Formatradio | +| 8–10 | Maximale Dichte, hörbare Clip-Artefakte | Nicht empfohlen | + +**Empfehlung:** `outputDrive: 3.0` für sauberen, broadcast-fähigen Sound. + +### FM — Pilot & RDS + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `pilotLevel` | float | 0.09 | 0–0.2 | 19kHz Pilot-Amplitude. **Direkte Prozentangabe von ±75kHz.** 0.09 = 9% = ITU-Standard. | +| `rdsInjection` | float | 0.04 | 0–0.15 | RDS-Amplitude. **Direkte Prozentangabe.** 0.04 = 4%. Waveform ist unity-normalisiert. | +| `stereoEnabled` | bool | true | — | Stereo-Encode an/aus. Aus = nur Mono (L+R)/2, kein Pilot. | + +**Empfehlung:** `pilotLevel: 0.09`, `rdsInjection: 0.04`. Nicht ändern ausser es gibt einen guten Grund. + +Pilot und RDS sind **unabhängig vom OutputDrive** — sie bleiben immer bei der konfigurierten Amplitude, egal wie hart das Audio komprimiert wird. + +### FM — BS.412 (ITU-R MPX Power Limiter) + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `bs412Enabled` | bool | false | Aktiviert den BS.412 MPX Power Limiter. **Pflicht in CH, DE, NL, FR.** | +| `bs412ThresholdDBr` | float | 0 | Power-Limit in dBr. 0 = Standard. +3 = relaxiert. | + +**Was BS.412 macht:** +Begrenzt die durchschnittliche MPX-Leistung über ein rollendes 60-Sekunden-Fenster. +Reduziert die Audio-Amplitude langsam wenn die Power den Threshold überschreitet. +Pilot und RDS werden automatisch vom Power-Budget abgezogen. + +**Effekt auf den Sound:** +- ~5dB Lautheitsverlust bei 0 dBr mit aggressiver Kompression +- Weniger Verlust bei dynamischerem Material +- OutputDrive beeinflusst bei aktivem BS.412 nur den Klangcharakter, nicht die Lautheit + +**Empfehlung:** `bs412Enabled: true`, `bs412ThresholdDBr: 0` für BAKOM-Compliance. + +### FM — Hardware-Kalibrierung + +| Parameter | Typ | Default | Bereich | Beschreibung | +|---|---|---|---|---| +| `mpxGain` | float | 1.0 | 0.1–5 | Skaliert die FM-Deviation (nicht den Composite!). Kompensiert DAC/SDR-Hardware-Faktoren. | +| `maxDeviationHz` | float | 75000 | 0–150000 | Maximale FM-Deviation in Hz. 75000 = Standard. | +| `compositeRateHz` | int | 228000 | — | Interne DSP-Sample-Rate. 228000 = 12×19kHz (optimal für Pilot-Kohärenz). | + +**MpxTool-Kalibrierung:** +1. `mpxGain: 1.0` setzen (keine Skalierung) +2. MpxTool Ref Level so einstellen dass **Pilot Level = 9.0%** anzeigt +3. Für PlutoSDR typisch: Ref Level ca. **-7.5 dBFS** +4. Einmal kalibrieren, nie wieder anfassen + +**Empfehlung:** `mpxGain: 1.0`, `maxDeviationHz: 75000`. Kalibrierung über MpxTool Ref Level. + +### RDS + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `rds.enabled` | bool | true | RDS an/aus | +| `rds.pi` | string | "1234" | Programme Identification (4-stellig hex). Muss mit BAKOM-Zuteilung übereinstimmen. | +| `rds.ps` | string | "FMRTX" | Programme Service Name (max 8 Zeichen). Stationsname auf dem Display. | +| `rds.radioText` | string | "" | Radio Text (max 64 Zeichen). Scrolltext auf dem Display. | +| `rds.pty` | int | 0 | Programme Type. 0=undefined, 1=News, 3=Info, 10=Pop, 15=Other Music, etc. | + +### Backend + +| Parameter | Typ | Default | Beschreibung | +|---|---|---|---| +| `backend.kind` | string | "file" | `"pluto"` für PlutoSDR, `"soapy"` für SoapySDR, `"file"` für Dateiausgabe | +| `backend.device` | string | "" | Device-String. PlutoSDR: `"usb:"` oder `"ip:192.168.2.1"` | +| `backend.deviceSampleRateHz` | float | 0 | SDR-Device-Rate. 2280000 = 10× compositeRate (optimal). | + +--- + +## Referenz-Konfiguration (BAKOM-konform, PlutoSDR) + +```json +{ + "audio": { + "gain": 1.0 + }, + "rds": { + "enabled": true, + "pi": "BEEF", + "ps": "RADIO-ZH", + "radioText": "Ihr Zürcher Kurzradio", + "pty": 0 + }, + "fm": { + "frequencyMHz": 100.0, + "stereoEnabled": true, + "pilotLevel": 0.09, + "rdsInjection": 0.04, + "preEmphasisTauUS": 50, + "outputDrive": 3.0, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "bs412Enabled": true, + "bs412ThresholdDBr": 0, + "mpxGain": 1.0, + "compositeRateHz": 228000, + "maxDeviationHz": 75000, + "fmModulationEnabled": true + }, + "backend": { + "kind": "pluto", + "device": "usb:", + "deviceSampleRateHz": 2280000 + }, + "control": { + "listenAddress": "127.0.0.1:8088" + } +} +``` + +--- + +## Audio-Streaming (Produktionsbetrieb) + +```bash +ffmpeg -i http://stream-url/stream -f s16le -ar 44100 -ac 2 - | fmrtx.exe --tx --tx-auto-start --audio-stdin --audio-rate 44100 --config config.json +``` + +**Hinweis:** Unter Windows `cmd.exe` verwenden, nicht PowerShell (korrumpiert die Binary-Pipe). + +--- + +## Verifizierte Messwerte (MpxTool, PlutoSDR @ 100MHz) + +| Parameter | Messung | Soll | +|---|---|---| +| Pilot Level | 9.0% | 9% ✓ | +| RDS Injection | 3.4% | 4% (≈, BPSK-Mittelung) | +| MPX Peak | 105–110% | 100–113% ✓ | +| Guard Band 19kHz | >-80dB | >-80dB (Omnia 11: >80dB) ✓ | +| Audio Bandwidth | flat bis 15kHz | 15kHz ✓ | diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 1f9cec8..1676050 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -10,16 +10,18 @@ "enabled": true, "pi": "BEEF", "ps": "PLUTO-TX", - "radioText": "Hello from PlutoSDR", + "radioText": "TESTATSSENDUNG 1mW", "pty": 0 }, "fm": { + "bs412Enabled": true, + "bs412ThresholdDBr": 0, "frequencyMHz": 100.0, "stereoEnabled": true, "pilotLevel": 0.09, "rdsInjection": 0.04, "preEmphasisTauUS": 50, - "outputDrive": 4.3, + "outputDrive": 1.0, "mpxGain": 1.0, "compositeRateHz": 228000, "maxDeviationHz": 75000, diff --git a/internal/config/config.go b/internal/config/config.go index 2ea3c11..2f48f6a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,8 @@ type FMConfig struct { LimiterCeiling float64 `json:"limiterCeiling"` FMModulationEnabled bool `json:"fmModulationEnabled"` MpxGain float64 `json:"mpxGain"` // hardware calibration: scales entire composite output (default 1.0) + BS412Enabled bool `json:"bs412Enabled"` // ITU-R BS.412 MPX power limiter (EU requirement) + BS412ThresholdDBr float64 `json:"bs412ThresholdDBr"` // power limit in dBr (0 = standard, +3 = relaxed) } type BackendConfig struct { diff --git a/internal/dsp/biquad.go b/internal/dsp/biquad.go index 39f6048..11d7732 100644 --- a/internal/dsp/biquad.go +++ b/internal/dsp/biquad.go @@ -119,42 +119,109 @@ func NewNotch(centerHz, sampleRate, q float64) *Biquad { } } +// NewChebyshevI creates an Nth-order Chebyshev Type I lowpass filter. +// Passband ripple in dB (typ. 0.5), then steep rolloff into stopband. +// Much steeper transition band than Butterworth at the same order. +// At 228kHz, 8th-order, 0.5dB ripple, fc=15kHz: -40dB@19kHz (vs -17dB Butterworth). +func NewChebyshevI(order int, rippleDB, cutoffHz, sampleRate float64) *FilterChain { + if order < 2 || order%2 != 0 { + return &FilterChain{Stages: []Biquad{{b0: 1}}} + } + if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { + return &FilterChain{Stages: []Biquad{{b0: 1}}} + } + + N := order + nSections := N / 2 + + // Chebyshev parameters + epsilon := math.Sqrt(math.Pow(10, rippleDB/10) - 1) + v := math.Asinh(1/epsilon) / float64(N) + + // Bilinear transform constant and frequency pre-warp + c := 2.0 * sampleRate + warp := c * math.Tan(math.Pi*cutoffHz/sampleRate) + + stages := make([]Biquad, nSections) + + for i := 0; i < nSections; i++ { + // Analog prototype pole (normalized Ωc=1) + angle := float64(2*i+1) * math.Pi / float64(2*N) + sigmaN := -math.Sinh(v) * math.Sin(angle) + omegaN := math.Cosh(v) * math.Cos(angle) + + // Scale to actual cutoff frequency + sigma := sigmaN * warp + omega := omegaN * warp + + // Analog section: H(s) = A / (s² + Bs + A) + A := sigma*sigma + omega*omega + B := -2 * sigma // positive (sigma is negative) + + // Bilinear transform to digital biquad + c2 := c * c + a0 := c2 + B*c + A + + stages[i] = Biquad{ + b0: A / a0, + b1: 2 * A / a0, + b2: A / a0, + a1: (-2*c2 + 2*A) / a0, + a2: (c2 - B*c + A) / a0, + } + } + + // Normalize DC gain to unity (Chebyshev even-order has -ripple at DC) + dcGain := 1.0 + for _, s := range stages { + dcGain *= (s.b0 + s.b1 + s.b2) / (1 + s.a1 + s.a2) + } + if dcGain > 0 { + corr := 1.0 / dcGain + stages[0].b0 *= corr + stages[0].b1 *= corr + stages[0].b2 *= corr + } + + return &FilterChain{Stages: stages} +} + // --- Broadcast-specific filter factories --- -// NewAudioLPF creates the broadcast-standard audio lowpass at 14kHz. -// 8th-order Butterworth: -21dB@19kHz per pass. Two passes through the -// clip-filter-clip loop give -42dB broadband floor at 19kHz. +// NewAudioLPF creates the broadcast-standard audio lowpass at 15kHz. +// 8th-order Chebyshev Type I with 0.5dB passband ripple. +// Flat to 15kHz, then steep wall: -40dB@19kHz (vs -17dB Butterworth). +// Two passes through clip-filter-clip: -80dB broadband at 19kHz. func NewAudioLPF(sampleRate float64) *FilterChain { - return NewLPF8(14000, sampleRate) + return NewChebyshevI(8, 0.5, 15000, sampleRate) } // NewPilotNotch creates a double-cascade 19kHz notch for maximum -// rejection at the pilot frequency. Two stages give >60dB rejection. -// Applied BEFORE stereo encoding to kill audio energy at 19kHz. +// rejection at the pilot frequency. Q=15: only 1.3kHz wide (18.4–19.6kHz). +// The 8th-order LPF handles broadband; this kills the exact 19kHz peak. func NewPilotNotch(sampleRate float64) *FilterChain { return &FilterChain{ Stages: []Biquad{ - *NewNotch(19000, sampleRate, 5), - *NewNotch(19000, sampleRate, 5), + *NewNotch(19000, sampleRate, 15), + *NewNotch(19000, sampleRate, 15), }, } } // NewCompositeProtection creates double-cascade notch filters for the -// composite clipper. Each band gets two notch stages for >60dB rejection. -// Applied to clipped audio composite to remove clip harmonics from the -// pilot (19kHz) and RDS (57kHz) bands. +// composite clipper. Q=10: ~1.9kHz wide at 19kHz, ~5.7kHz wide at 57kHz. +// Narrow enough to preserve audio/stereo, deep enough to protect pilot/RDS. func NewCompositeProtection(sampleRate float64) (notch19, notch57 *FilterChain) { notch19 = &FilterChain{ Stages: []Biquad{ - *NewNotch(19000, sampleRate, 3), - *NewNotch(19000, sampleRate, 3), + *NewNotch(19000, sampleRate, 10), + *NewNotch(19000, sampleRate, 10), }, } notch57 = &FilterChain{ Stages: []Biquad{ - *NewNotch(57000, sampleRate, 3), - *NewNotch(57000, sampleRate, 3), + *NewNotch(57000, sampleRate, 10), + *NewNotch(57000, sampleRate, 10), }, } return diff --git a/internal/dsp/bs412.go b/internal/dsp/bs412.go new file mode 100644 index 0000000..527c709 --- /dev/null +++ b/internal/dsp/bs412.go @@ -0,0 +1,154 @@ +package dsp + +import "math" + +// BS412Limiter implements ITU-R BS.412 MPX power limiting. +// Measures the rolling 60-second average power of the composite signal +// and reduces audio gain when the power exceeds the threshold. +// +// The threshold is specified in dBr where 0 dBr is the reference power +// of a fully modulated mono signal (composite peak = 1.0, power = 0.5). +// +// Pilot and RDS power are accounted for: the audio power budget is +// reduced by their constant contribution so the total stays within limits. +type BS412Limiter struct { + enabled bool + thresholdPow float64 // linear power threshold for total MPX + audioBudget float64 // = thresholdPow - pilotPow - rdsPow + + // Rolling 60-second power integrator + powerBuf []float64 // per-chunk average power values + bufIdx int + bufFull bool // true once the buffer has wrapped at least once + powerSum float64 + + // Slow gain controller + gain float64 // current output gain (0..1) + attackCoeff float64 // gain reduction speed + releaseCoeff float64 // gain recovery speed +} + +// NewBS412Limiter creates a BS.412 MPX power limiter. +// +// Parameters: +// - thresholdDBr: power limit in dBr (0 = standard, +3 = relaxed) +// - pilotLevel: pilot amplitude in composite (e.g. 0.09) +// - rdsInjection: RDS amplitude in composite (e.g. 0.04) +// - chunkDurationSec: duration of each processing chunk (e.g. 0.05 for 50ms) +func NewBS412Limiter(thresholdDBr, pilotLevel, rdsInjection, chunkDurationSec float64) *BS412Limiter { + // Reference power: 0 dBr = power of mono sine at peak=1.0 = 0.5 + refPower := 0.5 + thresholdPow := refPower * math.Pow(10, thresholdDBr/10) + + // Constant power contributions from pilot and RDS + pilotPow := pilotLevel * pilotLevel / 2 // sine wave RMS² + rdsPow := rdsInjection * rdsInjection / 4 // BPSK has ~half the power of a sine + + audioBudget := thresholdPow - pilotPow - rdsPow + if audioBudget < 0.01 { + audioBudget = 0.01 + } + + // 60-second window in chunks + windowSec := 60.0 + bufLen := int(math.Ceil(windowSec / chunkDurationSec)) + if bufLen < 10 { + bufLen = 10 + } + + // Attack: ~2 seconds (slow, avoids pumping) + // Release: ~5 seconds (very slow, smooth recovery) + attackTC := 2.0 / chunkDurationSec // time constant in chunks + releaseTC := 5.0 / chunkDurationSec + + return &BS412Limiter{ + enabled: true, + thresholdPow: thresholdPow, + audioBudget: audioBudget, + powerBuf: make([]float64, bufLen), + gain: 1.0, + attackCoeff: 1.0 - math.Exp(-1.0/attackTC), + releaseCoeff: 1.0 - math.Exp(-1.0/releaseTC), + } +} + +// ProcessChunk measures the audio power of a chunk and returns the +// gain factor to apply to the audio composite for BS.412 compliance. +// Call once per chunk with the average audio power of that chunk. +// +// audioPower = (1/N) × Σ sample² over the chunk's audio composite samples. +func (l *BS412Limiter) ProcessChunk(audioPower float64) float64 { + if !l.enabled { + return 1.0 + } + + // Update rolling 60-second power average + old := l.powerBuf[l.bufIdx] + l.powerBuf[l.bufIdx] = audioPower + l.powerSum += audioPower - old + l.bufIdx++ + if l.bufIdx >= len(l.powerBuf) { + l.bufIdx = 0 + l.bufFull = true + } + + // Calculate average power over the window + var count int + if l.bufFull { + count = len(l.powerBuf) + } else { + count = l.bufIdx + } + if count < 1 { + return 1.0 + } + avgPower := l.powerSum / float64(count) + + // Target gain: bring average audio power to budget + targetGain := 1.0 + if avgPower > l.audioBudget && avgPower > 0 { + targetGain = math.Sqrt(l.audioBudget / avgPower) + } + + // Smooth gain changes (slow attack, slower release) + if targetGain < l.gain { + l.gain += l.attackCoeff * (targetGain - l.gain) + } else { + l.gain += l.releaseCoeff * (targetGain - l.gain) + } + + // Clamp + if l.gain < 0.01 { + l.gain = 0.01 + } + if l.gain > 1.0 { + l.gain = 1.0 + } + + return l.gain +} + +// CurrentGain returns the current gain factor (0..1). +// Called at the start of each chunk to get the gain to apply. +func (l *BS412Limiter) CurrentGain() float64 { + return l.gain +} + +// CurrentGainDB returns the current gain reduction in dB (negative = reducing). +func (l *BS412Limiter) CurrentGainDB() float64 { + if l.gain <= 0 { + return -100 + } + return 20 * math.Log10(l.gain) +} + +// Reset clears the power history and restores unity gain. +func (l *BS412Limiter) Reset() { + for i := range l.powerBuf { + l.powerBuf[i] = 0 + } + l.bufIdx = 0 + l.bufFull = false + l.powerSum = 0 + l.gain = 1.0 +} diff --git a/internal/offline/generator.go b/internal/offline/generator.go index 9dfcc72..dd6afde 100644 --- a/internal/offline/generator.go +++ b/internal/offline/generator.go @@ -100,6 +100,7 @@ type Generator struct { cleanupLPF_R *dsp.FilterChain mpxNotch19 *dsp.FilterChain // composite clipper protection mpxNotch57 *dsp.FilterChain + bs412 *dsp.BS412Limiter // ITU-R BS.412 MPX power limiter (optional) // Pre-allocated frame buffer — reused every GenerateFrame call. frameBuf *output.CompositeFrame @@ -186,6 +187,16 @@ func (g *Generator) init() { g.cleanupLPF_R = dsp.NewAudioLPF(g.sampleRate) // Composite clipper protection: double-notch at 19kHz + 57kHz g.mpxNotch19, g.mpxNotch57 = dsp.NewCompositeProtection(g.sampleRate) + // BS.412 MPX power limiter (EU/CH requirement for licensed FM) + if g.cfg.FM.BS412Enabled { + chunkSec := 0.05 // 50ms chunks (matches engine default) + g.bs412 = dsp.NewBS412Limiter( + g.cfg.FM.BS412ThresholdDBr, + g.cfg.FM.PilotLevel, + g.cfg.FM.RDSInjection, + chunkSec, + ) + } if g.cfg.FM.FMModulationEnabled { g.fmMod = dsp.NewFMModulator(g.sampleRate) maxDev := g.cfg.FM.MaxDeviationHz @@ -279,6 +290,14 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame pilotAmp := lp.PilotLevel rdsAmp := lp.RDSInjection + // BS.412 MPX power limiter: uses previous chunk's measurement to set gain. + // Power is measured during this chunk and fed back at the end. + bs412Gain := 1.0 + var bs412PowerAccum float64 + if g.bs412 != nil { + bs412Gain = g.bs412.CurrentGain() + } + for i := 0; i < samples; i++ { in := g.source.NextFrame() @@ -316,6 +335,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame audioMPX = g.mpxNotch19.Process(audioMPX) audioMPX = g.mpxNotch57.Process(audioMPX) + // BS.412: apply gain and measure power + if bs412Gain < 1.0 { + audioMPX *= bs412Gain + } + bs412PowerAccum += audioMPX * audioMPX + // --- Stage 6: Add protected components --- composite := audioMPX if lp.StereoEnabled { @@ -334,6 +359,12 @@ func (g *Generator) GenerateFrame(duration time.Duration) *output.CompositeFrame frame.Samples[i] = output.IQSample{I: float32(composite), Q: 0} } } + + // BS.412: feed this chunk's average audio power for next chunk's gain calculation + if g.bs412 != nil && samples > 0 { + g.bs412.ProcessChunk(bs412PowerAccum / float64(samples)) + } + return frame } From ce12ff92b8050360a60c361548d85d931182ad97 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sat, 4 Apr 2026 10:11:03 +0200 Subject: [PATCH 5/7] feat: add driver/uri/deviceArgs backend config plumbing --- .gitignore | 2 ++ cmd/fmrtx/main.go | 10 +++++++++- docs/config.plutosdr.json | 3 +++ internal/config/config.go | 11 +++++++---- 4 files changed, 21 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 04837f0..fc557ca 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ build/ *.iq *.raw *.bak +*.zip +*.exe diff --git a/cmd/fmrtx/main.go b/cmd/fmrtx/main.go index 069f91e..a25a9b8 100644 --- a/cmd/fmrtx/main.go +++ b/cmd/fmrtx/main.go @@ -153,9 +153,17 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool, a // OutputDrive controls composite signal level, NOT hardware gain. // Hardware TX gain is always 0 dB (max power). Use external attenuator for power control. soapyCfg := platform.SoapyConfig{ - Driver: cfg.Backend.Device, + Driver: cfg.Backend.Driver, + Device: cfg.Backend.Device, CenterFreqHz: cfg.FM.FrequencyMHz * 1e6, GainDB: 0, // 0 dB = max TX power on PlutoSDR + DeviceArgs: map[string]string{}, + } + if cfg.Backend.URI != "" { + soapyCfg.DeviceArgs["uri"] = cfg.Backend.URI + } + for k, v := range cfg.Backend.DeviceArgs { + soapyCfg.DeviceArgs[k] = v } soapyCfg.SampleRateHz = cfg.EffectiveDeviceRate() diff --git a/docs/config.plutosdr.json b/docs/config.plutosdr.json index 1676050..1cb9738 100644 --- a/docs/config.plutosdr.json +++ b/docs/config.plutosdr.json @@ -32,6 +32,9 @@ "backend": { "kind": "pluto", "device": "usb:", + "driver": "", + "uri": "", + "deviceArgs": {}, "outputPath": "", "deviceSampleRateHz": 2280000 }, diff --git a/internal/config/config.go b/internal/config/config.go index 2f48f6a..768a40a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -50,10 +50,13 @@ type FMConfig struct { } type BackendConfig struct { - Kind string `json:"kind"` - Device string `json:"device"` - OutputPath string `json:"outputPath"` - DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz + Kind string `json:"kind"` + Driver string `json:"driver,omitempty"` + Device string `json:"device"` + URI string `json:"uri,omitempty"` + DeviceArgs map[string]string `json:"deviceArgs,omitempty"` + OutputPath string `json:"outputPath"` + DeviceSampleRateHz float64 `json:"deviceSampleRateHz"` // actual SDR device rate; 0 = same as compositeRateHz } type ControlConfig struct { From c97422431221e9d7880e7c605440b62432a0f03f Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sat, 4 Apr 2026 10:11:04 +0200 Subject: [PATCH 6/7] feat: overhaul web control UI with live status and draft handling --- internal/control/ui.html | 2158 ++++++++++++++++++++++++++++---------- 1 file changed, 1631 insertions(+), 527 deletions(-) diff --git a/internal/control/ui.html b/internal/control/ui.html index 1a70e9c..8445eca 100644 --- a/internal/control/ui.html +++ b/internal/control/ui.html @@ -9,11 +9,15 @@ :root { --bg: #0a0a0c; + --bg-2: #0f1015; --surface: #111116; --surface2: #18181e; + --surface3: #1f2028; --border: #2a2a35; + --border-strong: #3a3a49; --text: #d4d4dc; - --text-dim: #6a6a78; + --text-dim: #8b8b99; + --text-muted: #666674; --accent: #ff3b30; --accent-glow: #ff3b3044; --green: #30d158; @@ -21,15 +25,20 @@ --amber: #ff9f0a; --amber-glow: #ff9f0a44; --blue: #0a84ff; + --blue-glow: #0a84ff33; --mono: 'JetBrains Mono', monospace; --display: 'Archivo Black', sans-serif; - --radius: 6px; + --radius: 8px; + --shadow: 0 10px 30px rgba(0,0,0,.25); } * { box-sizing: border-box; margin: 0; padding: 0; } - +html { color-scheme: dark; } body { - background: var(--bg); + background: + radial-gradient(circle at top right, rgba(10,132,255,.06), transparent 28%), + radial-gradient(circle at top left, rgba(255,59,48,.06), transparent 30%), + var(--bg); color: var(--text); font-family: var(--mono); font-size: 13px; @@ -38,52 +47,83 @@ body { overflow-x: hidden; } -/* Scan lines overlay */ body::before { content: ''; position: fixed; inset: 0; - background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.03) 2px, rgba(0,0,0,0.03) 4px); - pointer-events: none; z-index: 1000; + background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.015) 2px, rgba(255,255,255,0.015) 4px); + pointer-events: none; + z-index: 1000; } +button, input { font: inherit; } +button { user-select: none; } + .app { - max-width: 900px; + max-width: 1120px; margin: 0 auto; - padding: 16px; + padding: 18px; } -/* Header */ .header { display: flex; - align-items: center; + align-items: flex-start; justify-content: space-between; - padding: 16px 0 24px; + gap: 18px; + padding: 8px 0 22px; border-bottom: 1px solid var(--border); - margin-bottom: 20px; + margin-bottom: 18px; +} +.header-main { + display: flex; + flex-direction: column; + gap: 8px; } - .header h1 { font-family: var(--display); - font-size: 22px; + font-size: 24px; letter-spacing: 2px; text-transform: uppercase; color: var(--accent); text-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow); } - +.header-sub { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +.badge { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 28px; + padding: 0 10px; + border: 1px solid var(--border); + border-radius: 999px; + background: rgba(255,255,255,0.02); + color: var(--text-dim); + font-size: 11px; + text-transform: uppercase; + letter-spacing: .8px; +} +.badge strong { + color: var(--text); + font-weight: 700; +} .header-status { display: flex; align-items: center; - gap: 12px; + gap: 10px; + padding-top: 6px; } -/* LED indicator */ .led { - width: 10px; height: 10px; + width: 10px; + height: 10px; border-radius: 50%; background: #333; box-shadow: none; - transition: all 0.3s; + transition: all .25s ease; + flex-shrink: 0; } .led.on-green { background: var(--green); @@ -97,779 +137,1843 @@ body::before { background: var(--amber); box-shadow: 0 0 8px var(--amber), 0 0 20px var(--amber-glow); } +.led.on-blue { + background: var(--blue); + box-shadow: 0 0 8px var(--blue), 0 0 20px var(--blue-glow); +} -/* TX control bar */ -.tx-bar { - display: flex; - gap: 10px; - align-items: center; - background: var(--surface); +.status-text { + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1.2px; +} + +.layout { + display: grid; + grid-template-columns: minmax(0, 1.35fr) minmax(310px, .75fr); + gap: 14px; + align-items: start; +} +.stack { display: flex; flex-direction: column; gap: 12px; } + +.card { + background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01)); border: 1px solid var(--border); border-radius: var(--radius); - padding: 12px 16px; - margin-bottom: 16px; + box-shadow: var(--shadow); +} + +.hero { + padding: 16px; + position: relative; + overflow: hidden; +} +.hero.tx-live::after { + content: ''; + position: absolute; + inset: -40%; + background: radial-gradient(circle, rgba(48,209,88,.12), transparent 55%); + animation: pulseGlow 2.8s ease-in-out infinite; + pointer-events: none; +} +.hero.tx-busy::after { + content: ''; + position: absolute; + inset: -50%; + background: conic-gradient(from 0deg, transparent, rgba(255,159,10,.12), transparent 45%); + animation: spinWash 2s linear infinite; + pointer-events: none; +} +@keyframes pulseGlow { + 0%, 100% { transform: scale(.95); opacity: .45; } + 50% { transform: scale(1.05); opacity: .8; } +} +@keyframes spinWash { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} +@keyframes blinkSoft { + 0%, 100% { opacity: 1; } + 50% { opacity: .55; } } -.tx-bar .freq-display { +.tx-bar { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(180px, 250px) 1fr auto; + gap: 14px; + align-items: center; +} + +.freq-display-wrap { + display: flex; + flex-direction: column; + gap: 6px; +} +.freq-display-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 1.4px; + color: var(--text-dim); +} +.freq-display { font-family: var(--display); - font-size: 32px; + font-size: 38px; color: var(--green); text-shadow: 0 0 15px var(--green-glow); letter-spacing: 1px; - min-width: 200px; + line-height: 1; } -.tx-bar .freq-display .unit { +.freq-display .unit { font-family: var(--mono); font-size: 14px; color: var(--text-dim); - margin-left: 4px; + margin-left: 5px; } -.tx-btn { - padding: 8px 20px; - border: 1px solid var(--border); +.tx-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { + min-height: 40px; + padding: 0 18px; border-radius: var(--radius); + border: 1px solid var(--border); background: var(--surface2); color: var(--text); - font-family: var(--mono); - font-size: 12px; - font-weight: 600; cursor: pointer; + font-size: 12px; + font-weight: 700; text-transform: uppercase; letter-spacing: 1px; - transition: all 0.15s; + transition: all .16s ease; +} +.tx-btn:hover, .ghost-btn:hover, .apply-btn:hover, .preset-btn:hover, .danger-btn:hover { + transform: translateY(-1px); + border-color: var(--border-strong); +} +.tx-btn:disabled, .ghost-btn:disabled, .apply-btn:disabled, .preset-btn:disabled, .danger-btn:disabled { + opacity: .45; + cursor: not-allowed; + transform: none; } -.tx-btn:hover { border-color: var(--text-dim); } .tx-btn.start { border-color: var(--green); color: var(--green); } -.tx-btn.start:hover { background: var(--green); color: var(--bg); } +.tx-btn.start:hover:not(:disabled) { background: rgba(48,209,88,.1); } .tx-btn.stop { border-color: var(--accent); color: var(--accent); } -.tx-btn.stop:hover { background: var(--accent); color: #fff; } +.tx-btn.stop:hover:not(:disabled) { background: rgba(255,59,48,.1); } +.ghost-btn { color: var(--text-dim); } +.danger-btn { + border-color: rgba(255,59,48,.45); + color: var(--accent); + background: rgba(255,59,48,.04); +} +.danger-btn:hover:not(:disabled) { + background: rgba(255,59,48,.12); +} +.tx-state-wrap { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 6px; +} .tx-state { font-size: 11px; text-transform: uppercase; letter-spacing: 2px; color: var(--text-dim); - margin-left: auto; } -.tx-state.running { color: var(--green); } -.tx-state.idle { color: var(--text-dim); } +.tx-state.running { color: var(--green); animation: blinkSoft 2s ease-in-out infinite; } +.tx-state.idle, .tx-state.stopped { color: var(--text-dim); } +.tx-state.starting, .tx-state.stopping, .tx-state.working { color: var(--amber); animation: blinkSoft 1.1s ease-in-out infinite; } +.tx-state.error { color: var(--accent); } +.status-hint { + font-size: 10px; + color: var(--text-muted); + text-align: right; +} -/* Telemetry strip */ -.telem { - display: flex; - gap: 1px; - background: var(--border); - border-radius: var(--radius); - overflow: hidden; - margin-bottom: 16px; +.quick-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; } -.telem-cell { - flex: 1; - background: var(--surface); - padding: 10px 12px; - text-align: center; +.quick-item { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-2); } -.telem-cell .label { +.quick-item .label { font-size: 9px; text-transform: uppercase; - letter-spacing: 1.5px; + letter-spacing: 1.4px; color: var(--text-dim); - margin-bottom: 4px; + margin-bottom: 6px; } -.telem-cell .value { - font-size: 16px; +.quick-item .value { + font-size: 18px; font-weight: 700; color: var(--text); } -.telem-cell .value.warn { color: var(--amber); } -.telem-cell .value.err { color: var(--accent); } +.quick-item .value.warn { color: var(--amber); } +.quick-item .value.err { color: var(--accent); } +.quick-item .value.good { color: var(--green); } -/* Section panels */ -.panel { - background: var(--surface); +.signal-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} +.signal-card { + padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); - margin-bottom: 12px; + background: var(--bg-2); +} +.signal-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 8px; +} +.signal-title { + font-size: 10px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1.2px; +} +.signal-value { + font-size: 11px; + color: var(--text); + font-weight: 700; +} +.meter { + width: 100%; + height: 10px; + border-radius: 999px; + background: #171821; + border: 1px solid var(--border); + overflow: hidden; +} +.meter-fill { + height: 100%; + width: 0%; + transition: width .25s ease, background-color .25s ease; + background: linear-gradient(90deg, var(--green), #5cff90); +} +.meter-fill.warn { + background: linear-gradient(90deg, var(--amber), #ffc45b); +} +.meter-fill.err { + background: linear-gradient(90deg, var(--accent), #ff6b63); +} +.spark { + width: 100%; + height: 34px; + margin-top: 10px; + border-radius: 6px; + background: rgba(255,255,255,0.01); + border: 1px solid rgba(255,255,255,0.03); +} +.spark path.line { + fill: none; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} +.spark path.area { + opacity: .14; +} +.spark.good path.line { stroke: var(--green); } +.spark.good path.area { fill: var(--green); } +.spark.warn path.line { stroke: var(--amber); } +.spark.warn path.area { fill: var(--amber); } +.spark.err path.line { stroke: var(--accent); } +.spark.err path.area { fill: var(--accent); } + +.panel { overflow: hidden; } .panel-head { display: flex; align-items: center; gap: 8px; - padding: 10px 14px; + padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--surface2); cursor: pointer; user-select: none; } .panel-head h2 { - font-family: var(--mono); font-size: 11px; - font-weight: 600; + font-weight: 700; text-transform: uppercase; - letter-spacing: 2px; + letter-spacing: 1.6px; color: var(--text-dim); } -.panel-head .chevron { +.panel-head .meta { margin-left: auto; + margin-right: 8px; + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} +.panel-head .chevron { color: var(--text-dim); - transition: transform 0.2s; + transition: transform .2s ease; font-size: 10px; } .panel-head.collapsed .chevron { transform: rotate(-90deg); } .panel-body { padding: 14px; } .panel-body.collapsed { display: none; } -/* Form controls */ +.section-note { + font-size: 11px; + color: var(--text-muted); + margin-bottom: 12px; +} +.shortcuts-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; +} +.shortcut-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-bottom: 1px solid #1a1a22; +} +.shortcut-line:last-child { border-bottom: none; } +.shortcut-line .name { font-size: 11px; color: var(--text-dim); } +.shortcut-line .keys { + display: inline-flex; + gap: 6px; + flex-wrap: wrap; +} +.kbd { + min-width: 28px; + padding: 3px 7px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 6px; + background: var(--bg-2); + font-size: 10px; + color: var(--text); + text-align: center; +} + +.preset-row { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} +.preset-btn { + min-height: 34px; + padding: 0 12px; + font-size: 11px; + letter-spacing: .8px; + color: var(--text-dim); +} +.preset-btn.active { + border-color: var(--blue); + color: var(--blue); + background: rgba(10,132,255,.08); +} +.preset-btn.rds { + text-transform: none; + font-weight: 600; +} + .ctrl-row { display: flex; align-items: center; gap: 12px; - padding: 6px 0; + padding: 10px 0; border-bottom: 1px solid #1a1a22; } .ctrl-row:last-child { border-bottom: none; } - +.ctrl-label-wrap { + min-width: 130px; + display: flex; + flex-direction: column; + gap: 2px; +} .ctrl-label { font-size: 11px; color: var(--text-dim); - min-width: 110px; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: .8px; +} +.ctrl-sub { + font-size: 10px; + color: var(--text-muted); } - .ctrl-input { flex: 1; display: flex; align-items: center; - gap: 8px; + gap: 10px; } input[type="range"] { -webkit-appearance: none; appearance: none; flex: 1; - height: 4px; - background: var(--border); - border-radius: 2px; + height: 6px; + background: linear-gradient(90deg, var(--border), var(--surface3)); + border-radius: 999px; outline: none; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; - width: 14px; height: 14px; + width: 16px; + height: 16px; border-radius: 50%; background: var(--text); border: 2px solid var(--bg); cursor: pointer; - transition: background 0.15s; + transition: background .15s ease, transform .15s ease; +} +input[type="range"]::-webkit-slider-thumb:hover { + background: var(--accent); + transform: scale(1.06); } -input[type="range"]::-webkit-slider-thumb:hover { background: var(--accent); } - input[type="number"], input[type="text"] { background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: 6px; color: var(--text); - font-family: var(--mono); - font-size: 13px; - padding: 5px 8px; - width: 80px; + padding: 8px 10px; outline: none; - transition: border-color 0.15s; + transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease; +} +input[type="number"] { + width: 92px; + text-align: right; } input[type="text"] { width: 100%; } -input:focus { border-color: var(--accent); } - +input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,59,48,.12); +} +input.input-dirty { + border-color: var(--amber); + box-shadow: 0 0 0 3px rgba(255,159,10,.08); +} +input.input-error { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255,59,48,.14); + background: rgba(255,59,48,.04); +} .val-display { - font-size: 12px; - font-weight: 600; - min-width: 55px; + min-width: 64px; text-align: right; + font-size: 12px; + font-weight: 700; color: var(--text); } +.unit-label { + font-size: 11px; + color: var(--text-dim); + min-width: 44px; +} +.field-error { + display: none; + margin-top: 8px; + font-size: 11px; + color: var(--accent); +} +.field-error.show { display: block; } -/* Toggle switch */ +.toggle-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + padding: 12px 0; + border-bottom: 1px solid #1a1a22; +} +.toggle-row:last-child { border-bottom: none; } +.toggle-copy { + display: flex; + flex-direction: column; + gap: 3px; +} +.toggle-copy .title { + font-size: 12px; + color: var(--text); + font-weight: 700; +} +.toggle-copy .sub { + font-size: 10px; + color: var(--text-muted); +} +.toggle-ctl { + display: flex; + align-items: center; + gap: 10px; +} .toggle { position: relative; - width: 36px; height: 20px; + width: 42px; + height: 24px; background: var(--border); - border-radius: 10px; + border-radius: 999px; cursor: pointer; - transition: background 0.2s; + transition: all .2s ease; flex-shrink: 0; } -.toggle.on { background: var(--green); } .toggle::after { content: ''; position: absolute; - top: 2px; left: 2px; - width: 16px; height: 16px; + top: 3px; + left: 3px; + width: 18px; + height: 18px; background: var(--text); border-radius: 50%; - transition: transform 0.2s; + transition: transform .2s ease; +} +.toggle.on { background: var(--green); } +.toggle.on::after { transform: translateX(18px); } +.toggle.busy { opacity: .55; pointer-events: none; } +.toggle-state { + min-width: 52px; + text-align: right; + font-size: 11px; + color: var(--text-dim); + text-transform: uppercase; + letter-spacing: 1px; } -.toggle.on::after { transform: translateX(16px); } -/* RDS section */ +.rds-grid { + display: grid; + gap: 12px; +} +.rds-field { + display: flex; + flex-direction: column; + gap: 6px; +} .rds-input { width: 100%; background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; + border-radius: 6px; color: var(--green); font-family: var(--mono); font-size: 15px; font-weight: 700; - padding: 8px 10px; + padding: 10px 12px; outline: none; letter-spacing: 2px; text-transform: uppercase; - transition: border-color 0.15s; } -.rds-input:focus { border-color: var(--accent); } .rds-input.rt { - font-size: 12px; - font-weight: 400; - letter-spacing: 0.5px; - text-transform: none; color: var(--text); + text-transform: none; + letter-spacing: .5px; + font-size: 12px; + font-weight: 500; } .rds-charcount { font-size: 10px; color: var(--text-dim); text-align: right; - margin-top: 2px; } -/* Apply button */ +.actions-row { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-top: 14px; +} .apply-btn { - display: block; - width: 100%; - padding: 10px; - margin-top: 8px; background: var(--accent); - border: none; - border-radius: var(--radius); + border-color: transparent; color: #fff; - font-family: var(--mono); - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 2px; - cursor: pointer; - transition: all 0.15s; - opacity: 0; - transform: translateY(-4px); - pointer-events: none; } -.apply-btn.visible { - opacity: 1; - transform: translateY(0); - pointer-events: auto; +.apply-btn.secondary { + background: var(--surface2); + color: var(--text-dim); + border-color: var(--border); } -.apply-btn:hover { filter: brightness(1.2); } -.apply-btn.sending { - opacity: 0.6; - pointer-events: none; +.apply-btn.ok { background: var(--green); color: var(--bg); } + +.sidebar-card { + padding: 14px; } -.apply-btn.ok { - background: var(--green); +.sidebar-section + .sidebar-section { + margin-top: 14px; + padding-top: 14px; + border-top: 1px solid var(--border); } - -/* Toast notification */ -.toast { - position: fixed; - bottom: 20px; - right: 20px; - padding: 10px 16px; - border-radius: var(--radius); +.sidebar-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.4px; + color: var(--text-dim); + margin-bottom: 10px; +} +.kv { + display: grid; + grid-template-columns: auto 1fr; + gap: 8px 12px; + align-items: start; +} +.kv .k { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 1px; +} +.kv .v { font-size: 12px; - font-weight: 600; - z-index: 2000; - transform: translateY(60px); - opacity: 0; - transition: all 0.3s; + color: var(--text); + word-break: break-word; } -.toast.show { transform: translateY(0); opacity: 1; } -.toast.ok { background: var(--green); color: var(--bg); } -.toast.err { background: var(--accent); color: #fff; } +.health-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 0; + border-bottom: 1px solid #1a1a22; +} +.health-line:last-child { border-bottom: none; } +.health-line .name { + font-size: 11px; + color: var(--text-dim); +} +.health-line .val { + font-size: 11px; + color: var(--text); + text-align: right; +} +.health-line .val.good { color: var(--green); } +.health-line .val.warn { color: var(--amber); } +.health-line .val.err { color: var(--accent); } -/* Log */ .log { background: var(--bg); border: 1px solid var(--border); - border-radius: 4px; - padding: 8px 10px; + border-radius: 6px; + padding: 10px; font-size: 10px; color: var(--text-dim); - max-height: 120px; + max-height: 220px; overflow-y: auto; white-space: pre-wrap; - word-break: break-all; + word-break: break-word; } -.log .entry { padding: 1px 0; } +.log .entry { padding: 3px 0; } .log .entry.err { color: var(--accent); } .log .entry.ok { color: var(--green); } +.log .entry.warn { color: var(--amber); } +.log .entry.info { color: var(--blue); } +.empty-log { + color: var(--text-muted); +} -/* Responsive */ -@media (max-width: 600px) { - .tx-bar { flex-wrap: wrap; } - .tx-bar .freq-display { font-size: 24px; min-width: auto; } - .telem { flex-wrap: wrap; } - .telem-cell { flex: 1 1 30%; } - .ctrl-row { flex-wrap: wrap; } - .ctrl-label { min-width: auto; width: 100%; } +.toast { + position: fixed; + right: 16px; + bottom: 16px; + max-width: min(420px, calc(100vw - 24px)); + padding: 12px 15px; + border-radius: var(--radius); + font-size: 12px; + font-weight: 700; + z-index: 2000; + transform: translateY(60px); + opacity: 0; + transition: all .25s ease; + box-shadow: var(--shadow); +} +.toast.show { transform: translateY(0); opacity: 1; } +.toast.ok { background: var(--green); color: var(--bg); } +.toast.err { background: var(--accent); color: #fff; } +.toast.info { background: var(--blue); color: #fff; } +.toast.warn { background: var(--amber); color: #141414; } + +@media (max-width: 980px) { + .layout { grid-template-columns: 1fr; } + .tx-bar { + grid-template-columns: 1fr; + align-items: stretch; + } + .tx-state-wrap { + align-items: flex-start; + } + .status-hint { text-align: left; } + .quick-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .signal-grid { grid-template-columns: 1fr; } +} + +@media (max-width: 640px) { + .app { padding: 12px; } + .header { flex-direction: column; align-items: stretch; gap: 10px; } + .header h1 { font-size: 22px; } + .header-sub { gap: 6px; } + .badge { width: 100%; justify-content: space-between; } + .quick-grid { grid-template-columns: 1fr 1fr; gap: 8px; } + .quick-item { padding: 10px; } + .quick-item .value { font-size: 16px; } + .ctrl-row { flex-direction: column; align-items: stretch; } + .ctrl-label-wrap { min-width: auto; } + .ctrl-input { flex-wrap: wrap; } + input[type="number"] { width: 100%; text-align: left; } + .actions-row, .tx-actions { flex-direction: column; } + .tx-btn, .ghost-btn, .apply-btn, .preset-btn, .danger-btn { width: 100%; } + .panel-head { padding: 11px 12px; } + .panel-body, .sidebar-card { padding: 12px; } + .freq-display { font-size: 31px; } + .preset-row { flex-direction: column; } + .shortcuts-grid { grid-template-columns: 1fr; } } -
- - +
-

FM-RDS-TX

+
+

FM-RDS-TX

+
+
Backend--
+
ModeControl Plane
+
Live Config--
+
+
- connecting +
connecting
- -
-
---.--MHz
- - -
--
-
+
+
+
+
+
+
Carrier
+
---.-MHz
+
+ +
+ + + +
+ +
+
IDLE
+
Awaiting runtime data
+
+
- -
-
Chunks
--
-
Samples
--
-
Underruns
0
-
Uptime
--
-
Rate
--
-
+
+
+
Chunks
+
--
+
+
+
Samples
+
--
+
+
+
Underruns
+
--
+
+
+
Uptime
+
--
+
+
+
Rate
+
--
+
+
- -
-
-
-

Frequency

- -
-
-
- TX Freq -
- - - MHz +
+
+
+
Audio Buffer
+
--
+
+
+ +
+
+
+
Stream Health
+
--
+
+
+ +
+
+
+
TX Activity
+
--
+
+
+ +
- -
-
- -
-
-
-

Levels

- -
-
-
- Output Drive -
- - -- +
+
+
+

Frequency

+
Live-tunable
+
-
-
- Pilot Level -
- - -- +
+
Tune the RF carrier without restarting the control plane. Draft values stay local until you apply them.
+ +
+ + + + + +
+ +
+
+ TX Freq + Valid range 65–110 MHz +
+
+ + + MHz +
+
+
+
+ + +
-
- RDS Inject -
- - -- + +
+
+
+

Switches

+
Live
+ +
+
+
These switches apply immediately and show a busy state while the request is in flight.
+ +
+
+
Stereo
+
19 kHz pilot + 38 kHz DSB-SC
+
+
+
+
--
+
+
+ +
+
+
RDS
+
57 kHz subcarrier encoder
+
+
+
+
--
+
+
+ +
+
+
Limiter
+
MPX peak protection
+
+
+
+
--
+
+
-
- Limiter Ceil -
- - -- + +
+
+
+

RDS Text

+
PS + RT
+ +
+
+
Edit Program Service and RadioText without losing in-progress typing when the page refreshes itself.
+ +
+ + + + +
+ +
+
+ Program Service (PS) + +
0/8
+
+
+
+ RadioText (RT) + +
0/64
+
+
+
+
+ + +
-
-
- -
-
-
-

Switches

- -
-
-
- Stereo -
-
- -- +
+ -
- RDS -
-
- -- + +
-
- Limiter -
-
- -- + +
+
+

Shortcuts

+
keyboard
+ +
+
+
Fast control, as long as you're not typing in an input field.
+
+
+
Start TXt
+
Stop TXShiftt
+
Refreshr
+
+
+
Next Freq Preset]
+
Prev Freq Preset[
+
Apply DraftEnter
+
+
-
-
- -
-
-
-

RDS

- -
-
-
- Program Service (PS) - -
0/8
-
-
- RadioText (RT) - -
0/64
+
+
+

Danger Zone

+
tx control
+ +
+
+
Fast emergency controls. Nothing hidden here — just clearer separation from normal controls.
+
+ + +
+
- -
-
- -
-
-

Log

- -
-
-
+
+
+

Activity Log

+
recent events
+ +
+
+
+ +
+
No events yet.
+
+
-
-
- + \ No newline at end of file From 88a1a7736a4300776325baaeb3a852171cf26940 Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Sat, 4 Apr 2026 10:11:04 +0200 Subject: [PATCH 7/7] feat: add Linux PlutoSDR support and Orange Pi build tooling --- docs/config.orangepi-pluto-soapy.json | 44 +++ internal/platform/plutosdr/available_pluto.go | 2 +- internal/platform/plutosdr/pluto_linux.go | 364 +++++++++++++++++ internal/platform/plutosdr/stub.go | 2 +- internal/platform/soapysdr/lib_unix.go | 86 +++-- scripts/orangepi-build-libiio.sh | 365 ++++++++++++++++++ 6 files changed, 834 insertions(+), 29 deletions(-) create mode 100644 docs/config.orangepi-pluto-soapy.json create mode 100644 internal/platform/plutosdr/pluto_linux.go create mode 100644 scripts/orangepi-build-libiio.sh diff --git a/docs/config.orangepi-pluto-soapy.json b/docs/config.orangepi-pluto-soapy.json new file mode 100644 index 0000000..6a5e9b2 --- /dev/null +++ b/docs/config.orangepi-pluto-soapy.json @@ -0,0 +1,44 @@ +{ + "audio": { + "inputPath": "", + "gain": 1.0, + "toneLeftHz": 1000, + "toneRightHz": 1600, + "toneAmplitude": 0.2 + }, + "rds": { + "enabled": true, + "pi": "BEEF", + "ps": "PLUTOOPI", + "radioText": "Orange Pi Pluto Soapy test", + "pty": 0 + }, + "fm": { + "frequencyMHz": 100.0, + "stereoEnabled": true, + "pilotLevel": 0.09, + "rdsInjection": 0.04, + "preEmphasisTauUS": 50, + "outputDrive": 0.5, + "compositeRateHz": 228000, + "maxDeviationHz": 75000, + "limiterEnabled": true, + "limiterCeiling": 1.0, + "fmModulationEnabled": true, + "mpxGain": 1.0, + "bs412Enabled": false, + "bs412ThresholdDBr": 0 + }, + "backend": { + "kind": "soapy", + "driver": "plutosdr", + "device": "", + "uri": "ip:pluto.local", + "deviceArgs": {}, + "outputPath": "", + "deviceSampleRateHz": 2280000 + }, + "control": { + "listenAddress": "127.0.0.1:8088" + } +} diff --git a/internal/platform/plutosdr/available_pluto.go b/internal/platform/plutosdr/available_pluto.go index 6ca6e11..06955c6 100644 --- a/internal/platform/plutosdr/available_pluto.go +++ b/internal/platform/plutosdr/available_pluto.go @@ -1,4 +1,4 @@ -//go:build pluto && windows +//go:build pluto && (windows || linux) package plutosdr diff --git a/internal/platform/plutosdr/pluto_linux.go b/internal/platform/plutosdr/pluto_linux.go new file mode 100644 index 0000000..206c0a1 --- /dev/null +++ b/internal/platform/plutosdr/pluto_linux.go @@ -0,0 +1,364 @@ +//go:build pluto && linux + +package plutosdr + +/* +#cgo pkg-config: libiio +#include +#include +#include +*/ +import "C" + +import ( + "context" + "fmt" + "log" + "sync" + "sync/atomic" + "time" + "unsafe" + + "github.com/jan/fm-rds-tx/internal/output" + "github.com/jan/fm-rds-tx/internal/platform" +) + +type PlutoDriver struct { + mu sync.Mutex + cfg platform.SoapyConfig + + ctx *C.struct_iio_context + txDev *C.struct_iio_device + phyDev *C.struct_iio_device + chanI *C.struct_iio_channel + chanQ *C.struct_iio_channel + chanLO *C.struct_iio_channel + buf *C.struct_iio_buffer + bufSize int + + started bool + configured bool + framesWritten atomic.Uint64 + samplesWritten atomic.Uint64 + underruns atomic.Uint64 + lastError string + lastErrorAt string + layoutLogged bool +} + +func NewPlutoDriver() platform.SoapyDriver { + return &PlutoDriver{} +} + +func (d *PlutoDriver) Name() string { return "pluto-iio" } + +func (d *PlutoDriver) Configure(_ context.Context, cfg platform.SoapyConfig) error { + d.mu.Lock() + defer d.mu.Unlock() + + d.cleanup() + d.cfg = cfg + + uri := "usb:" + if cfg.Device != "" && cfg.Device != "plutosdr" { + uri = cfg.Device + } + if v, ok := cfg.DeviceArgs["uri"]; ok && v != "" { + uri = v + } + + cURI := C.CString(uri) + defer C.free(unsafe.Pointer(cURI)) + ctx := C.iio_create_context_from_uri(cURI) + if ctx == nil { + return fmt.Errorf("pluto: failed to create IIO context (uri=%s)", uri) + } + d.ctx = ctx + + txDev := d.findDevice("cf-ad9361-dds-core-lpc") + if txDev == nil { + return fmt.Errorf("pluto: TX device 'cf-ad9361-dds-core-lpc' not found") + } + d.txDev = txDev + + phyDev := d.findDevice("ad9361-phy") + if phyDev == nil { + return fmt.Errorf("pluto: PHY device 'ad9361-phy' not found") + } + d.phyDev = phyDev + + phyChanTX := d.findChannel(phyDev, "voltage3", true) + if phyChanTX == nil { + phyChanTX = d.findChannel(phyDev, "voltage0", true) + } + if phyChanTX == nil { + return fmt.Errorf("pluto: PHY TX channel not found (tried voltage3, voltage0)") + } + + rate := int64(cfg.SampleRateHz) + if rate < 2084000 { + rate = 2084000 + } + d.cfg.SampleRateHz = float64(rate) + if err := d.writeChanAttrLL(phyChanTX, "sampling_frequency", rate); err != nil { + return err + } + + bw := rate + if bw > 2000000 { + bw = 2000000 + } + if err := d.writeChanAttrLL(phyChanTX, "rf_bandwidth", bw); err != nil { + return err + } + + phyChanLO := d.findChannel(phyDev, "altvoltage1", true) + d.chanLO = phyChanLO + if phyChanLO != nil { + freqHz := int64(cfg.CenterFreqHz) + if freqHz <= 0 { + freqHz = 100000000 + } + if err := d.writeChanAttrLL(phyChanLO, "frequency", freqHz); err != nil { + return err + } + } + + attenDB := int64(0) + if cfg.GainDB > 0 { + attenDB = -int64(89 - cfg.GainDB) + if attenDB > 0 { + attenDB = 0 + } + if attenDB < -89 { + attenDB = -89 + } + } + _ = d.writeChanAttrLL(phyChanTX, "hardwaregain", attenDB*1000) + + chanI := d.findChannel(txDev, "voltage0", true) + chanQ := d.findChannel(txDev, "voltage1", true) + if chanI == nil || chanQ == nil { + return fmt.Errorf("pluto: TX I/Q channels not found on streaming device") + } + C.iio_channel_enable(chanI) + C.iio_channel_enable(chanQ) + d.chanI = chanI + d.chanQ = chanQ + + d.bufSize = int(rate) / 20 + if d.bufSize < 4096 { + d.bufSize = 4096 + } + buf := C.iio_device_create_buffer(txDev, C.size_t(d.bufSize), C.bool(false)) + if buf == nil { + return fmt.Errorf("pluto: failed to create TX buffer (size=%d)", d.bufSize) + } + d.buf = buf + d.configured = true + return nil +} + +func (d *PlutoDriver) Capabilities(_ context.Context) (platform.DeviceCaps, error) { + return platform.DeviceCaps{ + MinSampleRate: 521e3, + MaxSampleRate: 61.44e6, + HasGain: true, + GainMinDB: -89, + GainMaxDB: 0, + Channels: []int{0}, + }, nil +} + +func (d *PlutoDriver) Start(_ context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + if !d.configured { + return fmt.Errorf("pluto: not configured") + } + if d.started { + return fmt.Errorf("pluto: already started") + } + d.started = true + return nil +} + +func (d *PlutoDriver) Write(_ context.Context, frame *output.CompositeFrame) (int, error) { + d.mu.Lock() + buf := d.buf + chanI := d.chanI + chanQ := d.chanQ + started := d.started + bufSize := d.bufSize + d.mu.Unlock() + + if !started || buf == nil { + return 0, fmt.Errorf("pluto: not active") + } + if frame == nil || len(frame.Samples) == 0 { + return 0, nil + } + + written := 0 + total := len(frame.Samples) + + for written < total { + chunk := total - written + if chunk > bufSize { + chunk = bufSize + } + + step := uintptr(C.iio_buffer_step(buf)) + if step == 0 { + return written, fmt.Errorf("pluto: buffer step is 0") + } + + ptrI := uintptr(C.iio_buffer_first(buf, chanI)) + ptrQ := uintptr(C.iio_buffer_first(buf, chanQ)) + if ptrI == 0 || ptrQ == 0 { + return written, fmt.Errorf("pluto: buffer_first returned null") + } + + end := uintptr(C.iio_buffer_end(buf)) + d.mu.Lock() + if !d.layoutLogged { + delta := int64(ptrQ) - int64(ptrI) + span := int64(0) + if end > ptrI { + span = int64(end - ptrI) + } + log.Printf("pluto-linux: buffer layout step=%d ptrI=%#x ptrQ=%#x delta=%d end=%#x span=%d bufSize=%d", step, ptrI, ptrQ, delta, end, span, bufSize) + d.layoutLogged = true + } + d.mu.Unlock() + if end > 0 { + bufSamples := int((end - ptrI) / step) + if bufSamples > 0 && chunk > bufSamples { + chunk = bufSamples + } + } + + for i := 0; i < chunk; i++ { + s := frame.Samples[written+i] + *(*int16)(unsafe.Pointer(ptrI)) = int16(s.I * 32767) + *(*int16)(unsafe.Pointer(ptrQ)) = int16(s.Q * 32767) + ptrI += step + ptrQ += step + } + + pushed := int(C.iio_buffer_push(buf)) + if pushed < 0 { + d.mu.Lock() + d.lastError = fmt.Sprintf("buffer_push: %d", pushed) + d.lastErrorAt = time.Now().UTC().Format(time.RFC3339) + d.underruns.Add(1) + d.mu.Unlock() + return written, fmt.Errorf("pluto: buffer_push returned %d", pushed) + } + + written += chunk + } + + d.framesWritten.Add(1) + d.samplesWritten.Add(uint64(written)) + return written, nil +} + +func (d *PlutoDriver) Stop(_ context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + d.started = false + return nil +} + +func (d *PlutoDriver) Flush(_ context.Context) error { return nil } + +func (d *PlutoDriver) Tune(_ context.Context, freqHz float64) error { + d.mu.Lock() + defer d.mu.Unlock() + if !d.configured || d.chanLO == nil { + return fmt.Errorf("pluto: not configured or LO channel not available") + } + return d.writeChanAttrLL(d.chanLO, "frequency", int64(freqHz)) +} + +func (d *PlutoDriver) Close(_ context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() + d.started = false + d.cleanup() + return nil +} + +func (d *PlutoDriver) Stats() platform.RuntimeStats { + d.mu.Lock() + defer d.mu.Unlock() + return platform.RuntimeStats{ + TXEnabled: d.started, + StreamActive: d.started && d.buf != nil, + FramesWritten: d.framesWritten.Load(), + SamplesWritten: d.samplesWritten.Load(), + Underruns: d.underruns.Load(), + LastError: d.lastError, + LastErrorAt: d.lastErrorAt, + EffectiveRate: d.cfg.SampleRateHz, + } +} + +func (d *PlutoDriver) cleanup() { + if d.buf != nil { + C.iio_buffer_destroy(d.buf) + d.buf = nil + } + if d.chanI != nil { + C.iio_channel_disable(d.chanI) + d.chanI = nil + } + if d.chanQ != nil { + C.iio_channel_disable(d.chanQ) + d.chanQ = nil + } + d.chanLO = nil + if d.ctx != nil { + C.iio_context_destroy(d.ctx) + d.ctx = nil + } + d.txDev = nil + d.phyDev = nil + d.configured = false + d.layoutLogged = false +} + +func (d *PlutoDriver) findDevice(name string) *C.struct_iio_device { + if d.ctx == nil { + return nil + } + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + return C.iio_context_find_device(d.ctx, cName) +} + +func (d *PlutoDriver) findChannel(dev *C.struct_iio_device, name string, isOutput bool) *C.struct_iio_channel { + if dev == nil { + return nil + } + cName := C.CString(name) + defer C.free(unsafe.Pointer(cName)) + if isOutput { + return C.iio_device_find_channel(dev, cName, C.bool(true)) + } + return C.iio_device_find_channel(dev, cName, C.bool(false)) +} + +func (d *PlutoDriver) writeChanAttrLL(ch *C.struct_iio_channel, attr string, val int64) error { + if ch == nil { + return fmt.Errorf("pluto: channel missing for attr %s", attr) + } + cAttr := C.CString(attr) + defer C.free(unsafe.Pointer(cAttr)) + ret := C.iio_channel_attr_write_longlong(ch, cAttr, C.longlong(val)) + if ret < 0 { + return fmt.Errorf("pluto: write attr %s failed (rc=%d)", attr, int(ret)) + } + return nil +} diff --git a/internal/platform/plutosdr/stub.go b/internal/platform/plutosdr/stub.go index 96c5db4..13f3144 100644 --- a/internal/platform/plutosdr/stub.go +++ b/internal/platform/plutosdr/stub.go @@ -1,4 +1,4 @@ -//go:build !pluto || !windows +//go:build !pluto || (!windows && !linux) package plutosdr diff --git a/internal/platform/soapysdr/lib_unix.go b/internal/platform/soapysdr/lib_unix.go index 3afe80b..659533d 100644 --- a/internal/platform/soapysdr/lib_unix.go +++ b/internal/platform/soapysdr/lib_unix.go @@ -4,7 +4,9 @@ package soapysdr import ( "fmt" + "log" "math" + "sort" "unsafe" ) @@ -29,6 +31,13 @@ static const char* soapy_dlerror() { return dlerror(); } +// Try to resolve SoapySDR_getLastError dynamically when available. +typedef const char* (*last_error_fn)(void); +static const char* call_last_error(void* fn) { + if (fn == NULL) return NULL; + return ((last_error_fn)fn)(); +} + // Function call trampolines — we call function pointers loaded via dlsym. // These avoid the complexity of calling C function pointers from Go directly. @@ -102,22 +111,23 @@ static void call_kwargs_set(void* fn, void* kw, const char* key, const char* val import "C" type soapyLib struct { - handle unsafe.Pointer - fnEnumerate unsafe.Pointer - fnKwargsListClear unsafe.Pointer - fnKwargsSet unsafe.Pointer - fnMake unsafe.Pointer - fnUnmake unsafe.Pointer - fnSetSampleRate unsafe.Pointer - fnSetFrequency unsafe.Pointer - fnSetGain unsafe.Pointer - fnGetGainRange unsafe.Pointer - fnSetupStream unsafe.Pointer - fnCloseStream unsafe.Pointer - fnGetStreamMTU unsafe.Pointer - fnActivateStream unsafe.Pointer - fnDeactivateStream unsafe.Pointer - fnWriteStream unsafe.Pointer + handle unsafe.Pointer + fnEnumerate unsafe.Pointer + fnKwargsListClear unsafe.Pointer + fnKwargsSet unsafe.Pointer + fnMake unsafe.Pointer + fnUnmake unsafe.Pointer + fnSetSampleRate unsafe.Pointer + fnSetFrequency unsafe.Pointer + fnSetGain unsafe.Pointer + fnGetGainRange unsafe.Pointer + fnSetupStream unsafe.Pointer + fnCloseStream unsafe.Pointer + fnGetStreamMTU unsafe.Pointer + fnActivateStream unsafe.Pointer + fnDeactivateStream unsafe.Pointer + fnWriteStream unsafe.Pointer + fnGetLastError unsafe.Pointer } var libNames = []string{ @@ -164,6 +174,7 @@ func loadSoapyLib() (*soapyLib, error) { fnActivateStream: sym("SoapySDRDevice_activateStream"), fnDeactivateStream: sym("SoapySDRDevice_deactivateStream"), fnWriteStream: sym("SoapySDRDevice_writeStream"), + fnGetLastError: sym("SoapySDR_getLastError"), }, nil } @@ -192,20 +203,20 @@ func (lib *soapyLib) enumerate() ([]map[string]string, error) { } }() - devices := make([]map[string]string, int(length)) - kwSize := unsafe.Sizeof(kwargs{}) + devices := make([]map[string]string, 0, int(length)) + kwSize := unsafe.Sizeof(C.GoKwargs{}) base := uintptr(ret) for i := 0; i < int(length); i++ { - kw := (*kwargs)(unsafe.Pointer(base + uintptr(i)*kwSize)) + entry := (*C.GoKwargs)(unsafe.Pointer(base + uintptr(i)*kwSize)) m := make(map[string]string) - for j := 0; j < int(kw.size); j++ { - keyPtr := *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(kw.keys)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) - valPtr := *(*uintptr)(unsafe.Pointer(uintptr(unsafe.Pointer(kw.vals)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) - if keyPtr != 0 && valPtr != 0 { - m[C.GoString((*C.char)(unsafe.Pointer(keyPtr)))] = C.GoString((*C.char)(unsafe.Pointer(valPtr))) + for j := 0; j < int(entry.size); j++ { + keyPtr := *(**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(entry.keys)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) + valPtr := *(**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(entry.vals)) + uintptr(j)*unsafe.Sizeof(uintptr(0)))) + if keyPtr != nil && valPtr != nil { + m[C.GoString(keyPtr)] = C.GoString(valPtr) } } - devices[i] = m + devices = append(devices, m) } return devices, nil } @@ -217,9 +228,30 @@ func (lib *soapyLib) makeDevice(driver, device string, args map[string]string) ( var kw kwargs if driver != "" { lib.kwargsSet(&kw, "driver", driver) } if device != "" { lib.kwargsSet(&kw, "device", device) } - for k, v := range args { lib.kwargsSet(&kw, k, v) } + + keys := make([]string, 0, len(args)) + for k := range args { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + lib.kwargsSet(&kw, k, args[k]) + } + + log.Printf("soapy: makeDevice driver=%q device=%q args=%v", driver, device, args) ret := C.call_make(lib.fnMake, unsafe.Pointer(&kw)) - if ret == nil { return 0, fmt.Errorf("soapy: failed to open device") } + if ret == nil { + msg := "" + if lib.fnGetLastError != nil { + if p := C.call_last_error(lib.fnGetLastError); p != nil { + msg = C.GoString(p) + } + } + if msg != "" { + return 0, fmt.Errorf("soapy: failed to open device: %s", msg) + } + return 0, fmt.Errorf("soapy: failed to open device") + } return uintptr(ret), nil } diff --git a/scripts/orangepi-build-libiio.sh b/scripts/orangepi-build-libiio.sh new file mode 100644 index 0000000..42c2bad --- /dev/null +++ b/scripts/orangepi-build-libiio.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Orange Pi Plus 2E / Armbian Bookworm build helper for fm-rds-tx +# +# Goals: +# - install build + runtime dependencies +# - install libiio / Pluto-related userspace bits +# - build fmrtx for Linux ARM +# - collect binary + shared libraries into dist/orangepi/ +# +# Notes: +# - Linux Pluto build is libiio-first (`-tags pluto`). +# - SoapySDR is optional fallback/debug tooling, not the primary Pluto path. +# - Windows Pluto path remains separate and untouched by this script. +# +# Usage: +# chmod +x scripts/orangepi-build-libiio.sh +# ./scripts/orangepi-build-libiio.sh +# +# Optional env: +# PREFIX=/opt/fm-rds-tx +# DIST_DIR=dist/orangepi +# GO_VERSION=1.22.12 +# SKIP_APT=1 +# SKIP_GO_INSTALL=1 +# BUILD_TAGS=pluto +# +# If you want to install the packaged result into a target directory: +# PREFIX=/opt/fm-rds-tx ./scripts/orangepi-build-libiio.sh + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(cd -- "${SCRIPT_DIR}/.." && pwd)" +DIST_DIR="${DIST_DIR:-${REPO_DIR}/dist/orangepi}" +BUILD_DIR="${DIST_DIR}/build" +RUNTIME_DIR="${DIST_DIR}/runtime" +PREFIX="${PREFIX:-/opt/fm-rds-tx}" +GO_VERSION="${GO_VERSION:-1.22.12}" +BUILD_TAGS="${BUILD_TAGS:-pluto}" +ARCH="$(dpkg --print-architecture 2>/dev/null || true)" + +log() { + printf '\n[%s] %s\n' "$(date '+%H:%M:%S')" "$*" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "Missing required command: $1" >&2 + exit 1 + } +} + +apt_install_if_missing() { + local missing=() + for pkg in "$@"; do + if ! dpkg -s "$pkg" >/dev/null 2>&1; then + missing+=("$pkg") + fi + done + if ((${#missing[@]})); then + log "Installing missing packages: ${missing[*]}" + sudo apt-get install -y "${missing[@]}" + fi +} + +install_go() { + if command -v go >/dev/null 2>&1; then + log "Go already present: $(go version)" + return + fi + + if [[ "${SKIP_GO_INSTALL:-0}" == "1" ]]; then + echo "go not found and SKIP_GO_INSTALL=1 set" >&2 + exit 1 + fi + + local go_arch + case "$ARCH" in + armhf) go_arch="armv6l" ;; + arm64) go_arch="arm64" ;; + amd64) go_arch="amd64" ;; + *) + echo "Unsupported architecture for automated Go install: $ARCH" >&2 + exit 1 + ;; + esac + + local tarball="go${GO_VERSION}.linux-${go_arch}.tar.gz" + local url="https://go.dev/dl/${tarball}" + local tmp="/tmp/${tarball}" + + log "Installing Go ${GO_VERSION} for ${go_arch}" + wget -O "$tmp" "$url" + sudo rm -rf /usr/local/go + sudo tar -C /usr/local -xzf "$tmp" + export PATH="/usr/local/go/bin:${PATH}" + + if ! grep -q '/usr/local/go/bin' "$HOME/.profile" 2>/dev/null; then + printf '\nexport PATH="/usr/local/go/bin:$PATH"\n' >> "$HOME/.profile" + fi + + log "Go installed: $(/usr/local/go/bin/go version)" +} + +resolve_lib() { + local name="$1" + local ldconfig_bin="" + if command -v ldconfig >/dev/null 2>&1; then + ldconfig_bin="$(command -v ldconfig)" + elif [[ -x /sbin/ldconfig ]]; then + ldconfig_bin="/sbin/ldconfig" + elif [[ -x /usr/sbin/ldconfig ]]; then + ldconfig_bin="/usr/sbin/ldconfig" + fi + + if [[ -n "$ldconfig_bin" ]]; then + "$ldconfig_bin" -p 2>/dev/null | awk -v lib="$name" '$1 == lib { print $NF; exit }' + return 0 + fi + + find /lib /usr/lib /usr/local/lib -name "$name" 2>/dev/null | head -n 1 +} + +copy_lib_if_found() { + local libname="$1" + local path + path="$(resolve_lib "$libname" || true)" + if [[ -z "$path" ]]; then + path="$(find /lib /usr/lib /usr/local/lib -name "$libname" 2>/dev/null | head -n 1 || true)" + fi + if [[ -n "$path" && -f "$path" ]]; then + cp -Lv "$path" "$RUNTIME_DIR/lib/" + else + log "Library not found: $libname" + fi +} + +find_soapy_plugin_path() { + local candidate="" + + if command -v SoapySDRUtil >/dev/null 2>&1; then + candidate="$(SoapySDRUtil --info 2>/dev/null | awk -F': ' '/Search path:/ {print $2; exit}')" + if [[ -n "$candidate" && -d "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + fi + + for candidate in \ + /usr/local/lib/SoapySDR/modules0.8-3 \ + /usr/local/lib/SoapySDR/modules0.8 \ + /usr/lib/arm-linux-gnueabihf/SoapySDR/modules0.8-3 \ + /usr/lib/arm-linux-gnueabihf/SoapySDR/modules0.8 \ + /usr/lib/SoapySDR/modules0.8-3 \ + /usr/lib/SoapySDR/modules0.8 + do + if [[ -d "$candidate" ]]; then + printf '%s\n' "$candidate" + return 0 + fi + done + + return 1 +} + +copy_soapy_plugins_if_found() { + local plugin_path + plugin_path="$(find_soapy_plugin_path || true)" + if [[ -n "$plugin_path" && -d "$plugin_path" ]]; then + mkdir -p "$RUNTIME_DIR/soapy-modules" + cp -Lv "$plugin_path"/* "$RUNTIME_DIR/soapy-modules/" 2>/dev/null || true + printf '%s\n' "$plugin_path" > "$DIST_DIR/SOAPY_PLUGIN_PATH.txt" + log "Copied Soapy plugins from: $plugin_path" + else + log "Soapy plugin path not found" + fi +} + +write_runner() { + cat > "${DIST_DIR}/run-fmrtx.sh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + +SYSTEM_LIB_DIRS="/usr/local/lib:/usr/lib:/usr/lib/arm-linux-gnueabihf:/lib:/lib/arm-linux-gnueabihf" +export LD_LIBRARY_PATH="${SCRIPT_DIR}/runtime/lib:${SYSTEM_LIB_DIRS}:${LD_LIBRARY_PATH:-}" + +if [[ -d "${SCRIPT_DIR}/runtime/soapy-modules" ]]; then + export SOAPY_SDR_PLUGIN_PATH="${SCRIPT_DIR}/runtime/soapy-modules:${SOAPY_SDR_PLUGIN_PATH:-}" +elif [[ -f "${SCRIPT_DIR}/SOAPY_PLUGIN_PATH.txt" ]]; then + export SOAPY_SDR_PLUGIN_PATH="$(cat "${SCRIPT_DIR}/SOAPY_PLUGIN_PATH.txt"):${SOAPY_SDR_PLUGIN_PATH:-}" +fi + +exec "${SCRIPT_DIR}/runtime/bin/fmrtx" "$@" +EOF + chmod +x "${DIST_DIR}/run-fmrtx.sh" +} + +write_install_helper() { + cat > "${DIST_DIR}/install.sh" </dev/null || true +cat <<'EON' +Installed. +Run with e.g.: + LD_LIBRARY_PATH=\$PREFIX/lib \$PREFIX/bin/fmrtx --help +EON +EOF + # replace placeholder introduced to avoid accidental shell expansion confusion + sed -i 's/{1:-/\${1:-/g' "${DIST_DIR}/install.sh" + chmod +x "${DIST_DIR}/install.sh" +} + +main() { + need_cmd bash + need_cmd uname + need_cmd awk + need_cmd sed + need_cmd cp + need_cmd mkdir + need_cmd ldd + + mkdir -p "$BUILD_DIR" "$RUNTIME_DIR/bin" "$RUNTIME_DIR/lib" + + if [[ "${SKIP_APT:-0}" != "1" ]]; then + log "Refreshing apt metadata" + sudo apt-get update + + apt_install_if_missing \ + ca-certificates \ + curl \ + wget \ + git \ + build-essential \ + pkg-config \ + gcc \ + g++ \ + make \ + file \ + binutils \ + tar \ + xz-utils \ + libiio0 \ + libiio-dev \ + libusb-1.0-0 \ + libxml2 \ + libxml2-dev + + # Optional / best-effort packages. Not all repos expose them on every arch. + sudo apt-get install -y soapysdr-tools libsoapysdr0.8 libsoapysdr-dev 2>/dev/null || true + sudo apt-get install -y soapy-module-plutosdr 2>/dev/null || true + sudo apt-get install -y iio-oscilloscope 2>/dev/null || true + sudo apt-get install -y libusb-1.0-0-dev 2>/dev/null || true + fi + + install_go + need_cmd go + + export PATH="/usr/local/go/bin:${PATH}" + export CGO_ENABLED=1 + export GOOS=linux + + case "$ARCH" in + armhf) + export GOARCH=arm + export GOARM=7 + ;; + arm64) + export GOARCH=arm64 + ;; + amd64) + export GOARCH=amd64 + ;; + *) + echo "Unsupported architecture: $ARCH" >&2 + exit 1 + ;; + esac + + log "Build environment" + go version + echo "ARCH=${ARCH} GOOS=${GOOS} GOARCH=${GOARCH:-} GOARM=${GOARM:-} CGO_ENABLED=${CGO_ENABLED}" + + log "Tidying modules" + (cd "$REPO_DIR" && go mod tidy) + + log "Building fmrtx with Linux Pluto/libiio-first backend (tags: $BUILD_TAGS)" + (cd "$REPO_DIR" && go build -v -tags "$BUILD_TAGS" -o "$RUNTIME_DIR/bin/fmrtx" ./cmd/fmrtx) + + log "Collecting runtime libraries" + copy_lib_if_found "libiio.so.0" + copy_lib_if_found "libSoapySDR.so.0.8" + copy_lib_if_found "libusb-1.0.so.0" + copy_lib_if_found "libxml2.so.2" + copy_lib_if_found "libstdc++.so.6" + copy_lib_if_found "libgcc_s.so.1" + copy_lib_if_found "libm.so.6" + copy_lib_if_found "libc.so.6" + if [[ "$BUILD_TAGS" == *soapy* ]]; then + copy_soapy_plugins_if_found + else + log "Skipping Soapy plugin copy (BUILD_TAGS=$BUILD_TAGS)" + fi + + log "Writing helper scripts" + write_runner + write_install_helper + + log "Writing build manifest" + cat > "${DIST_DIR}/BUILD-INFO.txt" <