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 + usagemaster
| @@ -355,6 +355,20 @@ func (rt *dspRuntime) captureSpectrum(srcMgr *sourceManager, rec *recorder.Manag | |||||
| if rec != nil { | if rec != nil { | ||||
| rec.Ingest(time.Now(), allIQ) | 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) | logging.Debug("capture", "iq_len", "len", len(allIQ), "surv_fft", rt.cfg.FFTSize, "detail_fft", rt.detailFFT) | ||||
| survIQ := allIQ | survIQ := allIQ | ||||
| if len(allIQ) > rt.cfg.FFTSize { | if len(allIQ) > rt.cfg.FFTSize { | ||||
| @@ -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]) | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -64,3 +64,36 @@ func Decimate(iq []complex64, factor int) []complex64 { | |||||
| } | } | ||||
| return out | 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 | |||||
| } | |||||
| @@ -61,8 +61,11 @@ func NewResampler(inRate, outRate, tapsPerPhase int) *Resampler { | |||||
| protoLen++ // ensure odd length for symmetric filter | 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)) | proto := windowedSinc(protoLen, fc, float64(l)) | ||||
| // Decompose prototype into L polyphase arms | // Decompose prototype into L polyphase arms | ||||
| @@ -101,6 +101,7 @@ type streamSession struct { | |||||
| preDemodDecim int // cached decimation factor | preDemodDecim int // cached decimation factor | ||||
| preDemodRate int // cached snipRate this FIR was built for | preDemodRate int // cached snipRate this FIR was built for | ||||
| preDemodCutoff float64 // cached cutoff | preDemodCutoff float64 // cached cutoff | ||||
| preDemodDecimPhase int // stateful decimation phase (index offset into next frame) | |||||
| // AQ-2: De-emphasis config (µs, 0 = disabled) | // AQ-2: De-emphasis config (µs, 0 = disabled) | ||||
| deemphasisUs float64 | deemphasisUs float64 | ||||
| @@ -737,10 +738,11 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] | |||||
| sess.preDemodRate = snipRate | sess.preDemodRate = snipRate | ||||
| sess.preDemodCutoff = cutoff | sess.preDemodCutoff = cutoff | ||||
| sess.preDemodDecim = decim1 | sess.preDemodDecim = decim1 | ||||
| sess.preDemodDecimPhase = 0 // reset decimation phase on FIR reinit | |||||
| } | } | ||||
| filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) | filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) | ||||
| dec = dsp.Decimate(filtered, decim1) | |||||
| dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase) | |||||
| } else { | } else { | ||||
| dec = fullSnip | dec = fullSnip | ||||
| } | } | ||||
| @@ -1009,6 +1011,7 @@ type dspStateSnapshot struct { | |||||
| preDemodDecim int | preDemodDecim int | ||||
| preDemodRate int | preDemodRate int | ||||
| preDemodCutoff float64 | preDemodCutoff float64 | ||||
| preDemodDecimPhase int | |||||
| } | } | ||||
| func (sess *streamSession) captureDSPState() dspStateSnapshot { | func (sess *streamSession) captureDSPState() dspStateSnapshot { | ||||
| @@ -1040,6 +1043,7 @@ func (sess *streamSession) captureDSPState() dspStateSnapshot { | |||||
| preDemodDecim: sess.preDemodDecim, | preDemodDecim: sess.preDemodDecim, | ||||
| preDemodRate: sess.preDemodRate, | preDemodRate: sess.preDemodRate, | ||||
| preDemodCutoff: sess.preDemodCutoff, | preDemodCutoff: sess.preDemodCutoff, | ||||
| preDemodDecimPhase: sess.preDemodDecimPhase, | |||||
| } | } | ||||
| } | } | ||||
| @@ -1071,6 +1075,7 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { | |||||
| sess.preDemodDecim = s.preDemodDecim | sess.preDemodDecim = s.preDemodDecim | ||||
| sess.preDemodRate = s.preDemodRate | sess.preDemodRate = s.preDemodRate | ||||
| sess.preDemodCutoff = s.preDemodCutoff | sess.preDemodCutoff = s.preDemodCutoff | ||||
| sess.preDemodDecimPhase = s.preDemodDecimPhase | |||||
| } | } | ||||
| // --------------------------------------------------------------------------- | // --------------------------------------------------------------------------- | ||||