Sfoglia il codice sorgente

fix: reduce extractor resets and filter WFM audio

refactor/stateful-streaming-extractor
Jan Svabenik 5 ore fa
parent
commit
c3c11e7903
2 ha cambiato i file con 46 aggiunte e 12 eliminazioni
  1. +10
    -12
      internal/demod/gpudemod/streaming_types.go
  2. +36
    -0
      internal/recorder/streamer.go

+ 10
- 12
internal/demod/gpudemod/streaming_types.go Vedi File

@@ -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()
}

+ 36
- 0
internal/recorder/streamer.go Vedi File

@@ -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
}
}

Loading…
Annulla
Salva