diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index f25c1f3..30d5248 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -758,39 +758,38 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] if decim1 < 1 { decim1 = 1 } + // WFM override: force decim1=2 (256kHz) instead of round(512k/192k)=3 (170kHz). + // At decim1=3, Nyquist is 85kHz which clips FM broadcast ±75kHz deviation. + // At decim1=2, Nyquist is 128kHz → full FM deviation + stereo pilot + guard band. + // Bonus: 256000→48000 resampler ratio is L=3/M=16 (96 taps, 1kB) instead of + // the pathological L=24000/M=85333 (768k taps, 6MB) from 170666→48000. + if isWFM && decim1 > 2 && snipRate/2 >= 200000 { + decim1 = 2 + } actualDemodRate := snipRate / decim1 logging.Debug("demod", "rates", "snipRate", snipRate, "decim1", decim1, "actual", actualDemodRate) var dec []complex64 if decim1 > 1 { - // For WFM: skip the pre-demod anti-alias FIR entirely. - // FM broadcast has ±75kHz deviation. The old cutoff at - // actualDemodRate/2*0.8 = 68kHz was BELOW the FM deviation, - // clipping the modulation and causing amplitude dips that - // the FM discriminator turned into audible clicks (4-5/sec). - // The extraction stage already bandlimited to ±125kHz (BW/2 * bwMult), - // which is sufficient anti-alias protection for the 512k→170k decimation. - // - // For NFM/other: use a cutoff that preserves the full signal bandwidth. + // FIR cutoff: for WFM, use 90kHz (above ±75kHz FM deviation + guard). + // For NFM/other: use standard Nyquist*0.8 cutoff. + cutoff := float64(actualDemodRate) / 2.0 * 0.8 if isWFM { - // WFM: decimate without FIR — extraction filter is sufficient - dec = dsp.DecimateStateful(fullSnip, decim1, &sess.preDemodDecimPhase) - } else { - cutoff := float64(actualDemodRate) / 2.0 * 0.8 - - // Lazy-init or reinit stateful FIR if parameters changed - if sess.preDemodFIR == nil || sess.preDemodRate != snipRate || sess.preDemodCutoff != cutoff { - taps := dsp.LowpassFIR(cutoff, snipRate, 101) - sess.preDemodFIR = dsp.NewStatefulFIRComplex(taps) - sess.preDemodRate = snipRate - sess.preDemodCutoff = cutoff - sess.preDemodDecim = decim1 - sess.preDemodDecimPhase = 0 - } + cutoff = 90000 + } - filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) - dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase) + // Lazy-init or reinit stateful FIR if parameters changed + if sess.preDemodFIR == nil || sess.preDemodRate != snipRate || sess.preDemodCutoff != cutoff { + taps := dsp.LowpassFIR(cutoff, snipRate, 101) + sess.preDemodFIR = dsp.NewStatefulFIRComplex(taps) + sess.preDemodRate = snipRate + sess.preDemodCutoff = cutoff + sess.preDemodDecim = decim1 + sess.preDemodDecimPhase = 0 } + + filtered := sess.preDemodFIR.ProcessInto(fullSnip, sess.growIQ(len(fullSnip))) + dec = dsp.DecimateStateful(filtered, decim1, &sess.preDemodDecimPhase) } else { dec = fullSnip }