diff --git a/internal/recorder/streamer.go b/internal/recorder/streamer.go index 2a7d111..37daa99 100644 --- a/internal/recorder/streamer.go +++ b/internal/recorder/streamer.go @@ -58,12 +58,19 @@ type streamSession struct { deemphL float64 deemphR float64 - // Stereo decode: phase-continuous 38kHz oscillator - stereoPhase float64 // Stereo lock state for live WFM streaming stereoEnabled bool stereoOnCount int stereoOffCount int + // Pilot-locked stereo PLL state (19kHz pilot) + pilotPhase float64 + pilotFreq float64 + pilotAlpha float64 + pilotBeta float64 + pilotErrAvg float64 + pilotI float64 + pilotQ float64 + pilotLPAlpha float64 // Polyphase resampler (replaces integer-decimate hack) monoResampler *dsp.Resampler @@ -73,18 +80,20 @@ type streamSession struct { // AQ-4: Stateful FIR filters for click-free stereo decode stereoFilterRate int - stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R - stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high - stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low - stereoLRLPF *dsp.StatefulFIRReal // 15kHz LP for demodulated L-R - stereoAALPF *dsp.StatefulFIRReal // Anti-alias LP for pre-decim (mono path) + stereoLPF *dsp.StatefulFIRReal // 15kHz lowpass for L+R + stereoBPHi *dsp.StatefulFIRReal // 53kHz LP for bandpass high + stereoBPLo *dsp.StatefulFIRReal // 23kHz LP for bandpass low + stereoLRLPF *dsp.StatefulFIRReal // 15kHz LP for demodulated L-R + stereoAALPF *dsp.StatefulFIRReal // Anti-alias LP for pre-decim (mono path) + pilotLPFHi *dsp.StatefulFIRReal // ~21kHz LP for pilot bandpass high + pilotLPFLo *dsp.StatefulFIRReal // ~17kHz LP for pilot bandpass low // Stateful pre-demod anti-alias FIR (eliminates cold-start transients // and avoids per-frame FIR recomputation) - preDemodFIR *dsp.StatefulFIRComplex - preDemodDecim int // cached decimation factor - preDemodRate int // cached snipRate this FIR was built for - preDemodCutoff float64 // cached cutoff + preDemodFIR *dsp.StatefulFIRComplex + preDemodDecim int // cached decimation factor + preDemodRate int // cached snipRate this FIR was built for + preDemodCutoff float64 // cached cutoff // AQ-2: De-emphasis config (µs, 0 = disabled) deemphasisUs float64 @@ -732,8 +741,23 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] return audio, streamAudioRate } -// stereoDecodeStateful: phase-continuous 38kHz oscillator for L-R extraction. -// AQ-4: Uses persistent FIR filter state across frames for click-free stereo. +// pllCoefficients returns the proportional (alpha) and integral (beta) gains +// for a Type-II PLL using the specified loop bandwidth and damping factor. +// loopBW is in Hz, sampleRate in samples/sec. +func pllCoefficients(loopBW, damping float64, sampleRate int) (float64, float64) { + if sampleRate <= 0 || loopBW <= 0 { + return 0, 0 + } + bl := loopBW / float64(sampleRate) + theta := bl / (damping + 0.25/damping) + d := 1 + 2*damping*theta + theta*theta + alpha := (4 * damping * theta) / d + beta := (4 * theta * theta) / d + return alpha, beta +} + +// 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. func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) ([]float32, bool) { if len(mono) == 0 || sampleRate <= 0 { @@ -748,41 +772,102 @@ func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) sess.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101)) sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101)) sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp) + // Narrow pilot bandpass via LPF(21k)-LPF(17k). + sess.pilotLPFHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(21000, sampleRate, 101)) + sess.pilotLPFLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(17000, sampleRate, 101)) sess.stereoFilterRate = sampleRate - } - - // Reuse scratch for intermediates: need 4*n float32 for bpf, lr, hi, lo - // plus 2*n for output. We'll use scratchAudio for bpf+lr (2*n) and - // allocate hi/lo from the stateful FIR ProcessInto. - scratch := sess.growAudio(n * 4) - bpf := scratch[:n] - lr := scratch[n : 2*n] - hiBuf := scratch[2*n : 3*n] - loBuf := scratch[3*n : 4*n] - - lpr := sess.stereoLPF.Process(mono) // allocates — but could use ProcessInto too - - sess.stereoBPHi.ProcessInto(mono, hiBuf) - sess.stereoBPLo.ProcessInto(mono, loBuf) + // Initialize PLL for 19kHz pilot tracking. + sess.pilotPhase = 0 + sess.pilotFreq = 2 * math.Pi * 19000 / float64(sampleRate) + sess.pilotAlpha, sess.pilotBeta = pllCoefficients(50, 0.707, sampleRate) + sess.pilotErrAvg = 0 + sess.pilotI = 0 + sess.pilotQ = 0 + sess.pilotLPAlpha = 1 - math.Exp(-2*math.Pi*200/float64(sampleRate)) + } + + // Reuse scratch for intermediates: lpr, bpfLR, lr, work1, work2. + scratch := sess.growAudio(n * 5) + lpr := scratch[:n] + bpfLR := scratch[n : 2*n] + lr := scratch[2*n : 3*n] + work1 := scratch[3*n : 4*n] + work2 := scratch[4*n : 5*n] + + sess.stereoLPF.ProcessInto(mono, lpr) + + // 23-53kHz bandpass for L-R DSB-SC. + sess.stereoBPHi.ProcessInto(mono, work1) + sess.stereoBPLo.ProcessInto(mono, work2) for i := 0; i < n; i++ { - bpf[i] = hiBuf[i] - loBuf[i] + bpfLR[i] = work1[i] - work2[i] } - phase := sess.stereoPhase - inc := 2 * math.Pi * 38000 / float64(sampleRate) + // 19kHz pilot bandpass for PLL. + sess.pilotLPFHi.ProcessInto(mono, work1) + sess.pilotLPFLo.ProcessInto(mono, work2) + for i := 0; i < n; i++ { + work1[i] = work1[i] - work2[i] + } + pilot := work1 + + phase := sess.pilotPhase + freq := sess.pilotFreq + alpha := sess.pilotAlpha + beta := sess.pilotBeta + iState := sess.pilotI + qState := sess.pilotQ + lpAlpha := sess.pilotLPAlpha + minFreq := 2 * math.Pi * 17000 / float64(sampleRate) + maxFreq := 2 * math.Pi * 21000 / float64(sampleRate) var pilotPower float64 var totalPower float64 - for i := range bpf { - phase += inc - v := bpf[i] * float32(2*math.Cos(phase)) - lr[i] = v - pilotPower += math.Abs(float64(bpf[i])) - totalPower += math.Abs(float64(mono[i])) + var errSum float64 + for i := 0; i < n; i++ { + p := float64(pilot[i]) + sinP, cosP := math.Sincos(phase) + iMix := p * cosP + qMix := p * -sinP + iState += lpAlpha * (iMix - iState) + qState += lpAlpha * (qMix - qState) + err := math.Atan2(qState, iState) + freq += beta * err + if freq < minFreq { + freq = minFreq + } else if freq > maxFreq { + freq = maxFreq + } + phase += freq + alpha*err + if phase > 2*math.Pi { + phase -= 2 * math.Pi + } else if phase < 0 { + phase += 2 * math.Pi + } + + totalPower += float64(mono[i]) * float64(mono[i]) + pilotPower += p * p + errSum += math.Abs(err) + + lr[i] = bpfLR[i] * float32(2*math.Sin(2*phase)) } - sess.stereoPhase = math.Mod(phase, 2*math.Pi) + sess.pilotPhase = phase + sess.pilotFreq = freq + sess.pilotI = iState + sess.pilotQ = qState + blockErr := errSum / float64(n) + sess.pilotErrAvg = 0.9*sess.pilotErrAvg + 0.1*blockErr + + lr = sess.stereoLRLPF.ProcessInto(lr, lr) - lr = sess.stereoLRLPF.Process(lr) - locked := totalPower > 0 && (pilotPower/totalPower) > 0.12 + pilotRatio := 0.0 + if totalPower > 0 { + pilotRatio = pilotPower / totalPower + } + freqHz := sess.pilotFreq * float64(sampleRate) / (2 * math.Pi) + // Lock heuristics: pilot power fraction and PLL phase error stability. + // Pilot power is a small but stable fraction of composite energy; require + // a modest floor plus PLL settling to avoid flapping in noise. + locked := pilotRatio > 0.003 && math.Abs(freqHz-19000) < 250 && sess.pilotErrAvg < 0.35 out := make([]float32, n*2) for i := 0; i < n; i++ { @@ -794,46 +879,64 @@ func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) // dspStateSnapshot captures persistent DSP state for segment splits. type dspStateSnapshot struct { - overlapIQ []complex64 - deemphL float64 - deemphR float64 - stereoPhase float64 + overlapIQ []complex64 + deemphL float64 + deemphR float64 + pilotPhase float64 + pilotFreq float64 + pilotAlpha float64 + pilotBeta float64 + pilotErrAvg float64 + pilotI float64 + pilotQ float64 + pilotLPAlpha float64 monoResampler *dsp.Resampler monoResamplerRate int stereoResampler *dsp.StereoResampler stereoResamplerRate int stereoLPF *dsp.StatefulFIRReal stereoFilterRate int - stereoBPHi *dsp.StatefulFIRReal - stereoBPLo *dsp.StatefulFIRReal - stereoLRLPF *dsp.StatefulFIRReal - stereoAALPF *dsp.StatefulFIRReal - preDemodFIR *dsp.StatefulFIRComplex - preDemodDecim int - preDemodRate int - preDemodCutoff float64 + stereoBPHi *dsp.StatefulFIRReal + stereoBPLo *dsp.StatefulFIRReal + stereoLRLPF *dsp.StatefulFIRReal + stereoAALPF *dsp.StatefulFIRReal + pilotLPFHi *dsp.StatefulFIRReal + pilotLPFLo *dsp.StatefulFIRReal + preDemodFIR *dsp.StatefulFIRComplex + preDemodDecim int + preDemodRate int + preDemodCutoff float64 } func (sess *streamSession) captureDSPState() dspStateSnapshot { return dspStateSnapshot{ - overlapIQ: sess.overlapIQ, - deemphL: sess.deemphL, - deemphR: sess.deemphR, - stereoPhase: sess.stereoPhase, + overlapIQ: sess.overlapIQ, + deemphL: sess.deemphL, + deemphR: sess.deemphR, + pilotPhase: sess.pilotPhase, + pilotFreq: sess.pilotFreq, + pilotAlpha: sess.pilotAlpha, + pilotBeta: sess.pilotBeta, + pilotErrAvg: sess.pilotErrAvg, + pilotI: sess.pilotI, + pilotQ: sess.pilotQ, + pilotLPAlpha: sess.pilotLPAlpha, monoResampler: sess.monoResampler, monoResamplerRate: sess.monoResamplerRate, stereoResampler: sess.stereoResampler, stereoResamplerRate: sess.stereoResamplerRate, stereoLPF: sess.stereoLPF, stereoFilterRate: sess.stereoFilterRate, - stereoBPHi: sess.stereoBPHi, - stereoBPLo: sess.stereoBPLo, - stereoLRLPF: sess.stereoLRLPF, - stereoAALPF: sess.stereoAALPF, - preDemodFIR: sess.preDemodFIR, - preDemodDecim: sess.preDemodDecim, - preDemodRate: sess.preDemodRate, - preDemodCutoff: sess.preDemodCutoff, + stereoBPHi: sess.stereoBPHi, + stereoBPLo: sess.stereoBPLo, + stereoLRLPF: sess.stereoLRLPF, + stereoAALPF: sess.stereoAALPF, + pilotLPFHi: sess.pilotLPFHi, + pilotLPFLo: sess.pilotLPFLo, + preDemodFIR: sess.preDemodFIR, + preDemodDecim: sess.preDemodDecim, + preDemodRate: sess.preDemodRate, + preDemodCutoff: sess.preDemodCutoff, } } @@ -841,7 +944,14 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { sess.overlapIQ = s.overlapIQ sess.deemphL = s.deemphL sess.deemphR = s.deemphR - sess.stereoPhase = s.stereoPhase + sess.pilotPhase = s.pilotPhase + sess.pilotFreq = s.pilotFreq + sess.pilotAlpha = s.pilotAlpha + sess.pilotBeta = s.pilotBeta + sess.pilotErrAvg = s.pilotErrAvg + sess.pilotI = s.pilotI + sess.pilotQ = s.pilotQ + sess.pilotLPAlpha = s.pilotLPAlpha sess.monoResampler = s.monoResampler sess.monoResamplerRate = s.monoResamplerRate sess.stereoResampler = s.stereoResampler @@ -852,6 +962,8 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { sess.stereoBPLo = s.stereoBPLo sess.stereoLRLPF = s.stereoLRLPF sess.stereoAALPF = s.stereoAALPF + sess.pilotLPFHi = s.pilotLPFHi + sess.pilotLPFLo = s.pilotLPFLo sess.preDemodFIR = s.preDemodFIR sess.preDemodDecim = s.preDemodDecim sess.preDemodRate = s.preDemodRate