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.tags/v0.9.0
| @@ -12,6 +12,7 @@ import ( | |||||
| "time" | "time" | ||||
| apppkg "github.com/jan/fm-rds-tx/internal/app" | 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" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ctrlpkg "github.com/jan/fm-rds-tx/internal/control" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | 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)") | 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") | txAutoStart := flag.Bool("tx-auto-start", false, "auto-start TX on launch") | ||||
| listDevices := flag.Bool("list-devices", false, "enumerate SoapySDR devices and exit") | 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() | flag.Parse() | ||||
| // --- list-devices (SoapySDR) --- | // --- list-devices (SoapySDR) --- | ||||
| @@ -99,7 +102,7 @@ func main() { | |||||
| if driver == nil { | if driver == nil { | ||||
| log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") | log.Fatal("no hardware driver available — build with -tags pluto (or -tags soapy)") | ||||
| } | } | ||||
| runTXMode(cfg, driver, *txAutoStart) | |||||
| runTXMode(cfg, driver, *txAutoStart, *audioStdin, *audioRate) | |||||
| return | return | ||||
| } | } | ||||
| @@ -142,7 +145,7 @@ func selectDriver(cfg cfgpkg.Config) platform.SoapyDriver { | |||||
| return nil | 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()) | ctx, cancel := context.WithCancel(context.Background()) | ||||
| defer cancel() | defer cancel() | ||||
| @@ -172,10 +175,31 @@ func runTXMode(cfg cfgpkg.Config, driver platform.SoapyDriver, autoStart bool) { | |||||
| // Engine | // Engine | ||||
| engine := apppkg.NewEngine(cfg, driver) | 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 | // Control plane | ||||
| srv := ctrlpkg.NewServer(cfg) | srv := ctrlpkg.NewServer(cfg) | ||||
| srv.SetDriver(driver) | srv.SetDriver(driver) | ||||
| srv.SetTXController(&txBridge{engine: engine}) | srv.SetTXController(&txBridge{engine: engine}) | ||||
| if streamSrc != nil { | |||||
| srv.SetStreamSource(streamSrc) | |||||
| } | |||||
| if autoStart { | if autoStart { | ||||
| log.Println("TX: auto-start enabled") | log.Println("TX: auto-start enabled") | ||||
| @@ -218,3 +218,103 @@ These cannot be hot-reloaded (they affect DSP pipeline structure): | |||||
| - `rds.pi` / `rds.pty` — rarely change, baked into encoder init | - `rds.pi` / `rds.pty` — rarely change, baked into encoder init | ||||
| - `audio.inputPath` — audio source selection | - `audio.inputPath` — audio source selection | ||||
| - `backend.kind` / `backend.device` — hardware 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. | |||||
| @@ -8,6 +8,7 @@ import ( | |||||
| "sync/atomic" | "sync/atomic" | ||||
| "time" | "time" | ||||
| "github.com/jan/fm-rds-tx/internal/audio" | |||||
| cfgpkg "github.com/jan/fm-rds-tx/internal/config" | cfgpkg "github.com/jan/fm-rds-tx/internal/config" | ||||
| "github.com/jan/fm-rds-tx/internal/dsp" | "github.com/jan/fm-rds-tx/internal/dsp" | ||||
| offpkg "github.com/jan/fm-rds-tx/internal/offline" | offpkg "github.com/jan/fm-rds-tx/internal/offline" | ||||
| @@ -70,6 +71,30 @@ type Engine struct { | |||||
| // Live config: pending frequency change, applied between chunks | // Live config: pending frequency change, applied between chunks | ||||
| pendingFreq atomic.Pointer[float64] | 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 { | func NewEngine(cfg cfgpkg.Config, driver platform.SoapyDriver) *Engine { | ||||
| @@ -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) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -3,9 +3,11 @@ package control | |||||
| import ( | import ( | ||||
| _ "embed" | _ "embed" | ||||
| "encoding/json" | "encoding/json" | ||||
| "io" | |||||
| "net/http" | "net/http" | ||||
| "sync" | "sync" | ||||
| "github.com/jan/fm-rds-tx/internal/audio" | |||||
| "github.com/jan/fm-rds-tx/internal/config" | "github.com/jan/fm-rds-tx/internal/config" | ||||
| drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | drypkg "github.com/jan/fm-rds-tx/internal/dryrun" | ||||
| "github.com/jan/fm-rds-tx/internal/platform" | "github.com/jan/fm-rds-tx/internal/platform" | ||||
| @@ -39,10 +41,11 @@ type LivePatch struct { | |||||
| } | } | ||||
| type Server 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 { | type ConfigPatch struct { | ||||
| @@ -78,6 +81,12 @@ func (s *Server) SetDriver(drv platform.SoapyDriver) { | |||||
| s.mu.Unlock() | 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 { | func (s *Server) Handler() http.Handler { | ||||
| mux := http.NewServeMux() | mux := http.NewServeMux() | ||||
| mux.HandleFunc("/", s.handleUI) | mux.HandleFunc("/", s.handleUI) | ||||
| @@ -88,6 +97,7 @@ func (s *Server) Handler() http.Handler { | |||||
| mux.HandleFunc("/runtime", s.handleRuntime) | mux.HandleFunc("/runtime", s.handleRuntime) | ||||
| mux.HandleFunc("/tx/start", s.handleTXStart) | mux.HandleFunc("/tx/start", s.handleTXStart) | ||||
| mux.HandleFunc("/tx/stop", s.handleTXStop) | mux.HandleFunc("/tx/stop", s.handleTXStop) | ||||
| mux.HandleFunc("/audio/stream", s.handleAudioStream) | |||||
| return mux | return mux | ||||
| } | } | ||||
| @@ -128,6 +138,7 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { | |||||
| s.mu.RLock() | s.mu.RLock() | ||||
| drv := s.drv | drv := s.drv | ||||
| tx := s.tx | tx := s.tx | ||||
| stream := s.streamSrc | |||||
| s.mu.RUnlock() | s.mu.RUnlock() | ||||
| result := map[string]any{} | result := map[string]any{} | ||||
| @@ -137,10 +148,56 @@ func (s *Server) handleRuntime(w http.ResponseWriter, _ *http.Request) { | |||||
| if tx != nil { | if tx != nil { | ||||
| result["engine"] = tx.TXStats() | result["engine"] = tx.TXStats() | ||||
| } | } | ||||
| if stream != nil { | |||||
| result["audioStream"] = stream.Stats() | |||||
| } | |||||
| w.Header().Set("Content-Type", "application/json") | w.Header().Set("Content-Type", "application/json") | ||||
| _ = json.NewEncoder(w).Encode(result) | _ = 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) { | func (s *Server) handleTXStart(w http.ResponseWriter, r *http.Request) { | ||||
| if r.Method != http.MethodPost { | if r.Method != http.MethodPost { | ||||
| http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) | ||||
| @@ -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 | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -77,7 +77,8 @@ type Generator struct { | |||||
| stereoEncoder stereo.StereoEncoder | stereoEncoder stereo.StereoEncoder | ||||
| rdsEnc *rds.Encoder | rdsEnc *rds.Encoder | ||||
| combiner mpx.DefaultCombiner | 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 | fmMod *dsp.FMModulator | ||||
| sampleRate float64 | sampleRate float64 | ||||
| initialized bool | initialized bool | ||||
| @@ -89,12 +90,23 @@ type Generator struct { | |||||
| // Live-updatable DSP parameters — written by control API, read per chunk. | // Live-updatable DSP parameters — written by control API, read per chunk. | ||||
| liveParams atomic.Pointer[LiveParams] | 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 { | func NewGenerator(cfg cfgpkg.Config) *Generator { | ||||
| return &Generator{cfg: cfg} | 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, | // UpdateLive hot-swaps DSP parameters. Thread-safe — called from control API, | ||||
| // applied at the next chunk boundary by the DSP goroutine. | // applied at the next chunk boundary by the DSP goroutine. | ||||
| func (g *Generator) UpdateLive(p LiveParams) { | func (g *Generator) UpdateLive(p LiveParams) { | ||||
| @@ -140,9 +152,18 @@ func (g *Generator) init() { | |||||
| } | } | ||||
| ceiling := g.cfg.FM.LimiterCeiling | ceiling := g.cfg.FM.LimiterCeiling | ||||
| if ceiling <= 0 { ceiling = 1.0 } | 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 { | 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 { | if g.cfg.FM.FMModulationEnabled { | ||||
| g.fmMod = dsp.NewFMModulator(g.sampleRate) | g.fmMod = dsp.NewFMModulator(g.sampleRate) | ||||
| if g.cfg.FM.MaxDeviationHz > 0 { g.fmMod.MaxDeviation = g.cfg.FM.MaxDeviationHz } | 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) { | 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 g.cfg.Audio.InputPath != "" { | ||||
| if src, err := audio.LoadWAVSource(g.cfg.Audio.InputPath); err == nil { | 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} | 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} | 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 | ceiling := lp.LimiterCeiling | ||||
| if ceiling <= 0 { ceiling = 1.0 } | 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++ { | for i := 0; i < samples; i++ { | ||||
| in := g.source.NextFrame() | 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 { | if g.rdsEnc != nil && lp.RDSEnabled { | ||||
| rdsCarrier := g.stereoEncoder.RDSCarrier() | 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) | composite = dsp.HardClip(composite, ceiling) | ||||
| } | } | ||||
| @@ -83,8 +83,11 @@ func TestLimiterPreventsClipping(t *testing.T) { | |||||
| cfg.FM.FMModulationEnabled = false | cfg.FM.FMModulationEnabled = false | ||||
| cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 | cfg.Audio.ToneAmplitude = 0.9; cfg.Audio.Gain = 2.0; cfg.FM.OutputDrive = 1.0 | ||||
| frame := NewGenerator(cfg).GenerateFrame(50 * time.Millisecond) | 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 { | 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) } | |||||
| } | } | ||||
| } | } | ||||
| @@ -178,14 +178,17 @@ func (e *Encoder) NextSampleWithCarrier(carrier float64) float64 { | |||||
| if e.sampleCount >= e.spb { | if e.sampleCount >= e.spb { | ||||
| if e.bitPos >= bitsPerGroup { | if e.bitPos >= bitsPerGroup { | ||||
| // Apply live text updates at group boundaries (~88ms at 228kHz). | // 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 != "" { | if ps, ok := e.livePS.Load().(string); ok && ps != "" { | ||||
| e.scheduler.cfg.PS = ps | e.scheduler.cfg.PS = ps | ||||
| e.livePS.Store("") // consumed | |||||
| } | } | ||||
| if rt, ok := e.liveRT.Load().(string); ok && rt != "" { | if rt, ok := e.liveRT.Load().(string); ok && rt != "" { | ||||
| e.scheduler.cfg.RT = rt | e.scheduler.cfg.RT = rt | ||||
| e.scheduler.rtIdx = 0 // restart RT transmission for new text | e.scheduler.rtIdx = 0 // restart RT transmission for new text | ||||
| e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec | e.scheduler.rtABFlag = !e.scheduler.rtABFlag // toggle A/B per RDS spec | ||||
| e.liveRT.Store("") // consumed | |||||
| } | } | ||||
| e.getRDSGroup() | e.getRDSGroup() | ||||
| e.bitPos = 0 | e.bitPos = 0 | ||||
| @@ -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 | |||||