Просмотр исходного кода

recorder: add pilot-locked WFM stereo PLL

master
Jan Svabenik 8 часов назад
Родитель
Сommit
16b0d2bdc6
1 измененных файлов: 177 добавлений и 65 удалений
  1. +177
    -65
      internal/recorder/streamer.go

+ 177
- 65
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


Загрузка…
Отмена
Сохранить