| @@ -58,12 +58,19 @@ type streamSession struct { | |||||
| deemphL float64 | deemphL float64 | ||||
| deemphR float64 | deemphR float64 | ||||
| // Stereo decode: phase-continuous 38kHz oscillator | |||||
| stereoPhase float64 | |||||
| // Stereo lock state for live WFM streaming | // Stereo lock state for live WFM streaming | ||||
| stereoEnabled bool | stereoEnabled bool | ||||
| stereoOnCount int | stereoOnCount int | ||||
| stereoOffCount 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) | // Polyphase resampler (replaces integer-decimate hack) | ||||
| monoResampler *dsp.Resampler | monoResampler *dsp.Resampler | ||||
| @@ -73,18 +80,20 @@ type streamSession struct { | |||||
| // AQ-4: Stateful FIR filters for click-free stereo decode | // AQ-4: Stateful FIR filters for click-free stereo decode | ||||
| stereoFilterRate int | 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 | // Stateful pre-demod anti-alias FIR (eliminates cold-start transients | ||||
| // and avoids per-frame FIR recomputation) | // 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) | // AQ-2: De-emphasis config (µs, 0 = disabled) | ||||
| deemphasisUs float64 | deemphasisUs float64 | ||||
| @@ -732,8 +741,23 @@ func (sess *streamSession) processSnippet(snippet []complex64, snipRate int) ([] | |||||
| return audio, streamAudioRate | 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. | // Reuses session scratch buffers to minimize allocations. | ||||
| func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) ([]float32, bool) { | func (sess *streamSession) stereoDecodeStateful(mono []float32, sampleRate int) ([]float32, bool) { | ||||
| if len(mono) == 0 || sampleRate <= 0 { | 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.stereoBPHi = dsp.NewStatefulFIRReal(dsp.LowpassFIR(53000, sampleRate, 101)) | ||||
| sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101)) | sess.stereoBPLo = dsp.NewStatefulFIRReal(dsp.LowpassFIR(23000, sampleRate, 101)) | ||||
| sess.stereoLRLPF = dsp.NewStatefulFIRReal(lp) | 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 | 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++ { | 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 pilotPower float64 | ||||
| var totalPower 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) | out := make([]float32, n*2) | ||||
| for i := 0; i < n; i++ { | 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. | // dspStateSnapshot captures persistent DSP state for segment splits. | ||||
| type dspStateSnapshot struct { | 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 | monoResampler *dsp.Resampler | ||||
| monoResamplerRate int | monoResamplerRate int | ||||
| stereoResampler *dsp.StereoResampler | stereoResampler *dsp.StereoResampler | ||||
| stereoResamplerRate int | stereoResamplerRate int | ||||
| stereoLPF *dsp.StatefulFIRReal | stereoLPF *dsp.StatefulFIRReal | ||||
| stereoFilterRate int | 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 { | func (sess *streamSession) captureDSPState() dspStateSnapshot { | ||||
| return 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, | monoResampler: sess.monoResampler, | ||||
| monoResamplerRate: sess.monoResamplerRate, | monoResamplerRate: sess.monoResamplerRate, | ||||
| stereoResampler: sess.stereoResampler, | stereoResampler: sess.stereoResampler, | ||||
| stereoResamplerRate: sess.stereoResamplerRate, | stereoResamplerRate: sess.stereoResamplerRate, | ||||
| stereoLPF: sess.stereoLPF, | stereoLPF: sess.stereoLPF, | ||||
| stereoFilterRate: sess.stereoFilterRate, | 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.overlapIQ = s.overlapIQ | ||||
| sess.deemphL = s.deemphL | sess.deemphL = s.deemphL | ||||
| sess.deemphR = s.deemphR | 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.monoResampler = s.monoResampler | ||||
| sess.monoResamplerRate = s.monoResamplerRate | sess.monoResamplerRate = s.monoResamplerRate | ||||
| sess.stereoResampler = s.stereoResampler | sess.stereoResampler = s.stereoResampler | ||||
| @@ -852,6 +962,8 @@ func (sess *streamSession) restoreDSPState(s dspStateSnapshot) { | |||||
| sess.stereoBPLo = s.stereoBPLo | sess.stereoBPLo = s.stereoBPLo | ||||
| sess.stereoLRLPF = s.stereoLRLPF | sess.stereoLRLPF = s.stereoLRLPF | ||||
| sess.stereoAALPF = s.stereoAALPF | sess.stereoAALPF = s.stereoAALPF | ||||
| sess.pilotLPFHi = s.pilotLPFHi | |||||
| sess.pilotLPFLo = s.pilotLPFLo | |||||
| sess.preDemodFIR = s.preDemodFIR | sess.preDemodFIR = s.preDemodFIR | ||||
| sess.preDemodDecim = s.preDemodDecim | sess.preDemodDecim = s.preDemodDecim | ||||
| sess.preDemodRate = s.preDemodRate | sess.preDemodRate = s.preDemodRate | ||||