ソースを参照

fix: eliminate live audio clicks (buffer bloat + decimation phase + resampler BW)

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
master
Jan Svabenik 8時間前
コミット
740dbe512c
5個のファイルの変更200行の追加3行の削除
  1. +14
    -0
      cmd/sdrd/pipeline_runtime.go
  2. +142
    -0
      internal/dsp/decimate_test.go
  3. +33
    -0
      internal/dsp/fir.go
  4. +5
    -2
      internal/dsp/resample.go
  5. +6
    -1
      internal/recorder/streamer.go

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


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

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

+ 5
- 2
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


+ 6
- 1
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
}

// ---------------------------------------------------------------------------


読み込み中…
キャンセル
保存