package dsp import "math" // Biquad is a generic second-order IIR filter (direct form II transposed). type Biquad struct { b0, b1, b2 float64 a1, a2 float64 z1, z2 float64 } // Process filters one sample. func (f *Biquad) Process(in float64) float64 { out := f.b0*in + f.z1 f.z1 = f.b1*in - f.a1*out + f.z2 f.z2 = f.b2*in - f.a2*out return out } // Reset clears state. func (f *Biquad) Reset() { f.z1 = 0; f.z2 = 0 } // FilterChain cascades multiple biquad sections in series. // Used for higher-order filters (e.g. 4th-order = 2 biquads). type FilterChain struct { Stages []Biquad } // Process runs input through all stages in series. func (c *FilterChain) Process(in float64) float64 { x := in for i := range c.Stages { x = c.Stages[i].Process(x) } return x } // Reset clears all filter state. func (c *FilterChain) Reset() { for i := range c.Stages { c.Stages[i].Reset() } } // --- Factory functions --- // NewBiquadLPF creates a 2nd-order Butterworth lowpass (Q = 1/√2). func NewBiquadLPF(cutoffHz, sampleRate float64) *Biquad { return newBiquadLPFWithQ(cutoffHz, sampleRate, math.Sqrt2/2) } func newBiquadLPFWithQ(cutoffHz, sampleRate, q float64) *Biquad { if cutoffHz <= 0 || sampleRate <= 0 || cutoffHz >= sampleRate/2 { return &Biquad{b0: 1} // passthrough } omega := 2 * math.Pi * cutoffHz / sampleRate cosW := math.Cos(omega) sinW := math.Sin(omega) alpha := sinW / (2 * q) a0 := 1 + alpha return &Biquad{ b0: (1 - cosW) / 2 / a0, b1: (1 - cosW) / a0, b2: (1 - cosW) / 2 / a0, a1: (-2 * cosW) / a0, a2: (1 - alpha) / a0, } } // NewLPF4 creates a 4th-order Butterworth lowpass (two cascaded biquads). // Provides -24dB/octave rolloff. At 228kHz with fc=15kHz: // // 15kHz: -6dB, 19kHz: -14dB, 38kHz: -36dB, 57kHz: -54dB func NewLPF4(cutoffHz, sampleRate float64) *FilterChain { // 4th-order Butterworth: cascade two 2nd-order sections with Q values // derived from the pole angles: π/8 and 3π/8 q1 := 1.0 / (2 * math.Cos(math.Pi/8)) // ≈ 0.5412 q2 := 1.0 / (2 * math.Cos(3*math.Pi/8)) // ≈ 1.3066 return &FilterChain{ Stages: []Biquad{ *newBiquadLPFWithQ(cutoffHz, sampleRate, q1), *newBiquadLPFWithQ(cutoffHz, sampleRate, q2), }, } } // NewNotch creates a 2nd-order IIR notch (bandstop) filter. // Q controls width: higher Q = narrower notch. // Typical: Q=5 → ~4kHz wide at -3dB, Q=10 → ~2kHz wide. func NewNotch(centerHz, sampleRate, q float64) *Biquad { if centerHz <= 0 || sampleRate <= 0 || centerHz >= sampleRate/2 { return &Biquad{b0: 1} } omega := 2 * math.Pi * centerHz / sampleRate cosW := math.Cos(omega) alpha := math.Sin(omega) / (2 * q) a0 := 1 + alpha return &Biquad{ b0: 1 / a0, b1: -2 * cosW / a0, b2: 1 / a0, a1: -2 * cosW / a0, a2: (1 - alpha) / a0, } } // --- Broadcast-specific filter factories --- // NewAudioLPF creates the broadcast-standard 15kHz audio lowpass. // 4th-order Butterworth ensures the guard band (15–23kHz) is clean. func NewAudioLPF(sampleRate float64) *FilterChain { return NewLPF4(15000, sampleRate) } // NewPilotNotch creates a narrow notch at 19kHz to kill residual audio // energy at the pilot frequency. Applied BEFORE stereo encoding. // Q=5: -3dB width ~4kHz, >40dB rejection at 19kHz, <0.5dB loss at 15kHz. func NewPilotNotch(sampleRate float64) *Biquad { return NewNotch(19000, sampleRate, 5) } // NewCompositeProtection creates notch filters for the composite clipper. // Applied to clipped audio composite (mono+stereo_sub) to remove clip // harmonics from the pilot (19kHz) and RDS (57kHz) bands before adding // the actual pilot and RDS signals at clean, fixed levels. func NewCompositeProtection(sampleRate float64) (notch19, notch57 *Biquad) { notch19 = NewNotch(19000, sampleRate, 3) // wider Q for broadband clip artifacts notch57 = NewNotch(57000, sampleRate, 3) return }