diff --git a/internal/demod/gpudemod/streaming_types.go b/internal/demod/gpudemod/streaming_types.go index bc8e7b6..fb15cb3 100644 --- a/internal/demod/gpudemod/streaming_types.go +++ b/internal/demod/gpudemod/streaming_types.go @@ -3,7 +3,6 @@ package gpudemod import ( "fmt" "hash/fnv" - "math" ) type StreamingExtractJob struct { @@ -49,18 +48,17 @@ func ResetExtractStreamState(state *ExtractStreamState, cfgHash uint64) { } func StreamingConfigHash(signalID int64, offsetHz float64, bandwidth float64, outRate int, numTaps int, sampleRate int) uint64 { - // Quantize offset and bandwidth to 1 kHz resolution before hashing. - // The detector's exponential smoothing causes CenterHz (and therefore offsetHz) - // to jitter by fractions of a Hz every frame. With %.9f formatting, this - // produced a new hash every frame → full state reset (NCOPhase=0, History=[], - // PhaseCount=0) → FIR settling + phase discontinuity → audible clicks. + // Hash only structural parameters that change the FIR/decimation geometry. + // Offset is NOT included because the NCO phase_inc tracks it smoothly each frame. + // Bandwidth is NOT included because taps are rebuilt every frame in getOrInitExtractState. + // A state reset (zeroing NCO phase, history, phase count) is only needed when + // decimation factor, tap count, or sample rate changes — all of which affect + // buffer sizes and polyphase structure. // - // The NCO phase_inc is computed from the exact offset each frame, so small - // frequency changes are tracked smoothly without a reset. Only structural - // changes (bandwidth affecting FIR taps, decimation, tap count) need a reset. - qOff := math.Round(offsetHz / 1000) * 1000 - qBW := math.Round(bandwidth / 1000) * 1000 + // Previous bug: offset and bandwidth were formatted at %.9f precision, causing + // a new hash (and full state reset) every single frame because the detector's + // exponential smoothing changes CenterHz by sub-Hz fractions each frame. h := fnv.New64a() - _, _ = h.Write([]byte(fmt.Sprintf("sig=%d|off=%.0f|bw=%.0f|out=%d|taps=%d|sr=%d", signalID, qOff, qBW, outRate, numTaps, sampleRate))) + _, _ = h.Write([]byte(fmt.Sprintf("sig=%d|out=%d|taps=%d|sr=%d", signalID, outRate, numTaps, sampleRate))) return h.Sum64() } diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index f5a650d..d7fee44 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -127,6 +127,13 @@ type streamSession struct { pilotLPFHi *dsp.StatefulFIRReal // ~21kHz LP for pilot bandpass high pilotLPFLo *dsp.StatefulFIRReal // ~17kHz LP for pilot bandpass low + // WFM 15kHz audio LPF — removes pilot (19kHz), L-R subcarrier (23-53kHz), + // and RDS (57kHz) from the FM discriminator output before resampling. + // Without this, the pilot leaks into the audio as a 19kHz tone (+55dB above + // noise floor) and L-R subcarrier energy causes audible click-like artifacts. + wfmAudioLPF *dsp.StatefulFIRReal + wfmAudioLPFRate int + // Stateful pre-demod anti-alias FIR (eliminates cold-start transients // and avoids per-frame FIR recomputation) preDemodFIR *dsp.StatefulFIRComplex @@ -1348,6 +1355,11 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int, col audio = stereoAudio } else { sess.stereoState = "mono-fallback" + // Apply 15kHz LPF before output: the raw discriminator contains + // the 19kHz pilot (+55dB), L-R subcarrier (23-53kHz), and RDS (57kHz). + // Without filtering, the pilot leaks into audio and subcarrier + // energy produces audible click-like artifacts. + audio = sess.wfmAudioFilter(audio, actualDemodRate) dual := make([]float32, len(audio)*2) for i, s := range audio { dual[i*2] = s @@ -1358,6 +1370,9 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int, col if (prevPlayback != sess.playbackMode || prevStereo != sess.stereoState) && len(sess.audioSubs) > 0 { sendAudioInfo(sess.audioSubs, sess.audioInfo()) } + } else if isWFM { + // Plain WFM (not stereo): also needs 15kHz LPF on discriminator output + audio = sess.wfmAudioFilter(audio, actualDemodRate) } // --- Polyphase resample to exact 48kHz --- @@ -1460,6 +1475,20 @@ func pllCoefficients(loopBW, damping float64, sampleRate int) (float64, float64) return alpha, beta } +// wfmAudioFilter applies a stateful 15kHz lowpass to WFM discriminator output. +// Removes the 19kHz stereo pilot, L-R DSB-SC subcarrier (23-53kHz), and RDS (57kHz) +// that would otherwise leak into the audio output as clicks and tonal artifacts. +func (sess *streamSession) wfmAudioFilter(audio []float32, sampleRate int) []float32 { + if len(audio) == 0 || sampleRate <= 0 { + return audio + } + if sess.wfmAudioLPF == nil || sess.wfmAudioLPFRate != sampleRate { + sess.wfmAudioLPF = dsp.NewStatefulFIRReal(dsp.LowpassFIR(15000, sampleRate, 101)) + sess.wfmAudioLPFRate = sampleRate + } + return sess.wfmAudioLPF.Process(audio) +} + // stereoDecodeStateful: pilot-locked 38kHz oscillator for L-R extraction. // Uses persistent FIR filter state across frames for click-free stereo. // Reuses session scratch buffers to minimize allocations. @@ -1612,6 +1641,8 @@ type dspStateSnapshot struct { preDemodRate int preDemodCutoff float64 preDemodDecimPhase int + wfmAudioLPF *dsp.StatefulFIRReal + wfmAudioLPFRate int } func (sess *streamSession) captureDSPState() dspStateSnapshot { @@ -1645,6 +1676,8 @@ func (sess *streamSession) captureDSPState() dspStateSnapshot { preDemodRate: sess.preDemodRate, preDemodCutoff: sess.preDemodCutoff, preDemodDecimPhase: sess.preDemodDecimPhase, + wfmAudioLPF: sess.wfmAudioLPF, + wfmAudioLPFRate: sess.wfmAudioLPFRate, } } @@ -1678,6 +1711,8 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { sess.preDemodRate = s.preDemodRate sess.preDemodCutoff = s.preDemodCutoff sess.preDemodDecimPhase = s.preDemodDecimPhase + sess.wfmAudioLPF = s.wfmAudioLPF + sess.wfmAudioLPFRate = s.wfmAudioLPFRate } // --------------------------------------------------------------------------- @@ -2071,5 +2106,6 @@ func (st *Streamer) ResetStreams() { sess.preDemodDecimPhase = 0 sess.stereoResampler = nil sess.monoResampler = nil + sess.wfmAudioLPF = nil } }