From 740dbe512c1abc8afdca65ed1b3d774a7007864c Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Mon, 23 Mar 2026 14:41:24 +0100 Subject: [PATCH] fix: eliminate live audio clicks (buffer bloat + decimation phase + resampler BW) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three root causes identified for 4-5 clicks/sec in live audio streaming: 1. Buffer bloat in captureSpectrum (PRIMARY) allIQ reads drained the entire SDR buffer (196k-1M+ samples), causing extraction times to vary wildly. Large frames took >150ms to process, starving the next frame and creating a positive feedback loop. feed_gap warnings (152-218ms) directly correlated with audible clicks. Fix: cap allIQ to 2 frame intervals (~682k samples) after reading. Full buffer is still drained and ingested into the ring buffer; only the extraction/streaming path is capped. 2. Stateless decimation in processSnippet dsp.Decimate() restarted at index 0 every frame. When snippet length was not divisible by the decimation factor (e.g. 512kHz/3=170.6kHz, snippet % 3 != 0), a sample timing discontinuity occurred at each frame boundary. Fix: new dsp.DecimateStateful() preserves the decimation phase index across calls. Session field preDemodDecimPhase added to streamSession with proper snapshot/restore for segment splits. 3. Resampler bandwidth too narrow (10.8kHz instead of 15kHz) Polyphase resampler prototype cutoff fc=0.45/max(L,M) limited audio to 10.8kHz, cutting off FM stereo content above that. Fix: increase fc to 0.90/max(L,M), passing up to 22.8kHz. Kaiser window (β=6) maintains -60dB sidelobe suppression. Files changed: cmd/sdrd/pipeline_runtime.go - allIQ cap after buffer read internal/dsp/fir.go - DecimateStateful() internal/dsp/decimate_test.go - 5 tests for stateful decimation internal/dsp/resample.go - fc 0.45 → 0.90 internal/recorder/streamer.go - preDemodDecimPhase field + usage --- cmd/sdrd/pipeline_runtime.go | 14 ++++ internal/dsp/decimate_test.go | 142 ++++++++++++++++++++++++++++++++++ internal/dsp/fir.go | 33 ++++++++ internal/dsp/resample.go | 7 +- internal/recorder/streamer.go | 7 +- 5 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 internal/dsp/decimate_test.go diff --git a/cmd/sdrd/pipeline_runtime.go b/cmd/sdrd/pipeline_runtime.go index 6d7f3c1..d2cec7f 100644 --- a/cmd/sdrd/pipeline_runtime.go +++ b/cmd/sdrd/pipeline_runtime.go @@ -355,6 +355,20 @@ func (rt *dspRuntime) captureSpectrum(srcMgr *sourceManager, rec *recorder.Manag if rec != nil { rec.Ingest(time.Now(), allIQ) } + // Cap allIQ for downstream extraction to prevent buffer bloat. + // Without this cap, buffer accumulation during processing stalls causes + // increasingly large reads → longer extraction → more accumulation + // (positive feedback loop). For audio streaming this creates >150ms + // feed gaps that produce audible clicks. + // The ring buffer (Ingest above) gets the full data; only extraction is capped. + maxStreamSamples := rt.cfg.SampleRate / rt.cfg.FrameRate * 2 + if maxStreamSamples < required { + maxStreamSamples = required + } + maxStreamSamples = (maxStreamSamples / required) * required + if len(allIQ) > maxStreamSamples { + allIQ = allIQ[len(allIQ)-maxStreamSamples:] + } logging.Debug("capture", "iq_len", "len", len(allIQ), "surv_fft", rt.cfg.FFTSize, "detail_fft", rt.detailFFT) survIQ := allIQ if len(allIQ) > rt.cfg.FFTSize { diff --git a/internal/dsp/decimate_test.go b/internal/dsp/decimate_test.go new file mode 100644 index 0000000..56177bd --- /dev/null +++ b/internal/dsp/decimate_test.go @@ -0,0 +1,142 @@ +package dsp + +import ( + "testing" +) + +func TestDecimateStateful_ContinuousPhase(t *testing.T) { + // Simulate what happens in the streaming pipeline: + // Two consecutive frames with non-divisible lengths decimated by 3. + // Stateful version must produce the same output as decimating the + // concatenated input in one go. + + factor := 3 + // Frame lengths that don't divide evenly (like real WFM: 41666 % 3 = 2) + frame1 := make([]complex64, 41666) + frame2 := make([]complex64, 41666) + for i := range frame1 { + frame1[i] = complex(float32(i), 0) + } + for i := range frame2 { + frame2[i] = complex(float32(len(frame1)+i), 0) + } + + // Reference: concatenate and decimate in one shot + combined := make([]complex64, len(frame1)+len(frame2)) + copy(combined, frame1) + copy(combined[len(frame1):], frame2) + ref := Decimate(combined, factor) + + // Stateful: decimate frame by frame + phase := 0 + out1 := DecimateStateful(frame1, factor, &phase) + out2 := DecimateStateful(frame2, factor, &phase) + + got := make([]complex64, len(out1)+len(out2)) + copy(got, out1) + copy(got[len(out1):], out2) + + if len(got) != len(ref) { + t.Fatalf("length mismatch: stateful=%d reference=%d", len(got), len(ref)) + } + for i := range ref { + if got[i] != ref[i] { + t.Fatalf("sample %d: got %v want %v", i, got[i], ref[i]) + } + } +} + +func TestDecimateStateful_Factor4_NFM(t *testing.T) { + // NFM scenario: 200kHz/48kHz → decim=4, frame=16666 samples + // 16666 % 4 = 2 → phase slip every frame with stateless decimation + factor := 4 + frameLen := 16666 + nFrames := 10 + + // Build continuous signal + total := make([]complex64, frameLen*nFrames) + for i := range total { + total[i] = complex(float32(i), float32(-i)) + } + ref := Decimate(total, factor) + + // Stateful frame-by-frame + phase := 0 + var got []complex64 + for f := 0; f < nFrames; f++ { + chunk := total[f*frameLen : (f+1)*frameLen] + out := DecimateStateful(chunk, factor, &phase) + got = append(got, out...) + } + + if len(got) != len(ref) { + t.Fatalf("length mismatch: stateful=%d reference=%d (frames=%d)", len(got), len(ref), nFrames) + } + for i := range ref { + if got[i] != ref[i] { + t.Fatalf("frame-boundary glitch at sample %d: got %v want %v", i, got[i], ref[i]) + } + } +} + +func TestDecimateStateful_Factor1_Passthrough(t *testing.T) { + in := []complex64{1 + 2i, 3 + 4i, 5 + 6i} + phase := 0 + out := DecimateStateful(in, 1, &phase) + if len(out) != len(in) { + t.Fatalf("passthrough: got len %d want %d", len(out), len(in)) + } +} + +func TestDecimateStateful_ExactDivisible(t *testing.T) { + // When frame length is exactly divisible, phase should stay 0 + factor := 4 + frame := make([]complex64, 100) // 100 % 4 = 0 + for i := range frame { + frame[i] = complex(float32(i), 0) + } + phase := 0 + out := DecimateStateful(frame, factor, &phase) + if phase != 0 { + t.Fatalf("exact divisible: phase should be 0, got %d", phase) + } + if len(out) != 25 { + t.Fatalf("exact divisible: got %d samples, want 25", len(out)) + } +} + +func TestDecimateStateful_VaryingFrameSizes(t *testing.T) { + // Real-world: buffer jitter causes varying frame sizes + factor := 3 + frameSizes := []int{41600, 41700, 41666, 41650, 41680} + + // Build total + totalLen := 0 + for _, s := range frameSizes { + totalLen += s + } + total := make([]complex64, totalLen) + for i := range total { + total[i] = complex(float32(i), float32(i*2)) + } + ref := Decimate(total, factor) + + phase := 0 + var got []complex64 + offset := 0 + for _, sz := range frameSizes { + chunk := total[offset : offset+sz] + out := DecimateStateful(chunk, factor, &phase) + got = append(got, out...) + offset += sz + } + + if len(got) != len(ref) { + t.Fatalf("varying frames: stateful=%d reference=%d", len(got), len(ref)) + } + for i := range ref { + if got[i] != ref[i] { + t.Fatalf("varying frames: mismatch at %d: got %v want %v", i, got[i], ref[i]) + } + } +} diff --git a/internal/dsp/fir.go b/internal/dsp/fir.go index a2b1f92..c94ceeb 100644 --- a/internal/dsp/fir.go +++ b/internal/dsp/fir.go @@ -64,3 +64,36 @@ func Decimate(iq []complex64, factor int) []complex64 { } return out } + +// DecimateStateful keeps every nth sample, preserving the decimation phase +// across calls. *phase is the index offset into the next frame where the +// first output sample should be taken. It is updated on return so that +// consecutive calls produce a continuous, gap-free decimated stream. +// +// Initial value of *phase should be 0. +func DecimateStateful(iq []complex64, factor int, phase *int) []complex64 { + if factor <= 1 || phase == nil { + out := make([]complex64, len(iq)) + copy(out, iq) + return out + } + n := len(iq) + p := *phase + if p < 0 { + p = 0 + } + out := make([]complex64, 0, (n-p)/factor+1) + for i := p; i < n; i += factor { + out = append(out, iq[i]) + } + // Compute phase for next frame: how many samples past the end of this + // frame until the next decimation point. + if n > 0 && p < n { + lastTaken := p + ((n-1-p)/factor)*factor + *phase = lastTaken + factor - n + } else if p >= n { + // Entire frame was skipped (very short snippet) + *phase = p - n + } + return out +} diff --git a/internal/dsp/resample.go b/internal/dsp/resample.go index 50735d0..cf6def9 100644 --- a/internal/dsp/resample.go +++ b/internal/dsp/resample.go @@ -61,8 +61,11 @@ func NewResampler(inRate, outRate, tapsPerPhase int) *Resampler { protoLen++ // ensure odd length for symmetric filter } - // Normalized cutoff: passband edge relative to upsampled rate - fc := 0.45 / float64(max(l, m)) // 0.45 instead of 0.5 for transition margin + // Normalized cutoff: passband edge relative to upsampled rate. + // 0.90 passes up to ~95% of output Nyquist (≈22.8kHz at 48kHz out), + // providing full 15kHz FM stereo bandwidth. The Kaiser window (β=6) + // gives ≈-60dB sidelobe suppression for clean anti-alias rejection. + fc := 0.90 / float64(max(l, m)) proto := windowedSinc(protoLen, fc, float64(l)) // Decompose prototype into L polyphase arms diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index b26cea7..12e9de4 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -101,6 +101,7 @@ type streamSession struct { preDemodDecim int // cached decimation factor preDemodRate int // cached snipRate this FIR was built for preDemodCutoff float64 // cached cutoff + preDemodDecimPhase int // stateful decimation phase (index offset into next frame) // AQ-2: De-emphasis config (µs, 0 = disabled) deemphasisUs float64 @@ -737,10 +738,11 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] sess.preDemodRate = snipRate sess.preDemodCutoff = cutoff sess.preDemodDecim = decim1 + sess.preDemodDecimPhase = 0 // reset decimation phase on FIR reinit } filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) - dec = dsp.Decimate(filtered, decim1) + dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase) } else { dec = fullSnip } @@ -1009,6 +1011,7 @@ type dspStateSnapshot struct { preDemodDecim int preDemodRate int preDemodCutoff float64 + preDemodDecimPhase int } func (sess *streamSession) captureDSPState() dspStateSnapshot { @@ -1040,6 +1043,7 @@ func (sess *streamSession) captureDSPState() dspStateSnapshot { preDemodDecim: sess.preDemodDecim, preDemodRate: sess.preDemodRate, preDemodCutoff: sess.preDemodCutoff, + preDemodDecimPhase: sess.preDemodDecimPhase, } } @@ -1071,6 +1075,7 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { sess.preDemodDecim = s.preDemodDecim sess.preDemodRate = s.preDemodRate sess.preDemodCutoff = s.preDemodCutoff + sess.preDemodDecimPhase = s.preDemodDecimPhase } // ---------------------------------------------------------------------------