Skip to main content

audio_engine_core/processor/
eq.rs

1//! IIR Biquad Equalizer - 10-band parametric EQ
2
3pub use super::lockfree_params::EQ_BANDS;
4
5/// IIR Biquad filter section (SOS - Second Order Section)
6#[derive(Clone)]
7pub struct BiquadSection {
8    b0: f64,
9    b1: f64,
10    b2: f64,
11    a1: f64,
12    a2: f64,
13    z1: f64,
14    z2: f64,
15}
16
17impl BiquadSection {
18    pub fn peaking_eq(freq: f64, gain_db: f64, q: f64, sample_rate: f64) -> Self {
19        let a = 10.0_f64.powf(gain_db / 40.0);
20        let w0 = 2.0 * std::f64::consts::PI * freq / sample_rate;
21        let cos_w0 = w0.cos();
22        let sin_w0 = w0.sin();
23        let alpha = sin_w0 / (2.0 * q);
24
25        let b0 = 1.0 + alpha * a;
26        let b1 = -2.0 * cos_w0;
27        let b2 = 1.0 - alpha * a;
28        let a0 = 1.0 + alpha / a;
29        let a1 = -2.0 * cos_w0;
30        let a2 = 1.0 - alpha / a;
31
32        Self {
33            b0: b0 / a0,
34            b1: b1 / a0,
35            b2: b2 / a0,
36            a1: a1 / a0,
37            a2: a2 / a0,
38            z1: 0.0,
39            z2: 0.0,
40        }
41    }
42
43    #[inline]
44    pub fn process(&mut self, x: f64) -> f64 {
45        let y = self.b0 * x + self.z1;
46        self.z1 = self.b1 * x - self.a1 * y + self.z2;
47        self.z2 = self.b2 * x - self.a2 * y;
48        y
49    }
50
51    pub fn reset(&mut self) {
52        self.z1 = 0.0;
53        self.z2 = 0.0;
54    }
55
56    /// Copy coefficients from another section without copying state (z1, z2).
57    /// This is used for smooth parameter transitions to avoid state discontinuities.
58    pub fn copy_coefficients_from(&mut self, other: &Self) {
59        self.b0 = other.b0;
60        self.b1 = other.b1;
61        self.b2 = other.b2;
62        self.a1 = other.a1;
63        self.a2 = other.a2;
64        // z1, z2 are intentionally NOT copied - keep current state
65    }
66}
67
68/// 10-band Parametric EQ
69pub struct Equalizer {
70    bands: Vec<[BiquadSection; EQ_BANDS]>, // current active filters [channel][band]
71    target_bands: Vec<[BiquadSection; EQ_BANDS]>, // target filters (new params) [channel][band]
72    target_gains: Vec<f64>,                // target gain per band (dB)
73    smooth_counter: Vec<u32>,              // samples remaining in crossfade per band
74    channels: usize,
75    enabled: bool,
76}
77
78const EQ_SMOOTH_SAMPLES: u32 = 1024; // ~23ms @ 44100Hz
79const INV_EQ_SMOOTH: f64 = 1.0 / EQ_SMOOTH_SAMPLES as f64;
80
81impl Equalizer {
82    const FREQUENCIES: [f64; EQ_BANDS] = [
83        31.0, 62.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0, 16000.0,
84    ];
85    const Q: f64 = 1.41;
86
87    pub fn new(channels: usize, sample_rate: f64) -> Self {
88        let bands: Vec<[BiquadSection; EQ_BANDS]> = (0..channels)
89            .map(|_| Self::build_channel_bank(sample_rate))
90            .collect();
91        let target_bands = bands.clone();
92
93        Self {
94            bands,
95            target_bands,
96            target_gains: vec![0.0; EQ_BANDS],
97            smooth_counter: vec![0u32; EQ_BANDS],
98            channels,
99            enabled: false,
100        }
101    }
102
103    fn build_channel_bank(sample_rate: f64) -> [BiquadSection; EQ_BANDS] {
104        std::array::from_fn(|idx| {
105            BiquadSection::peaking_eq(Self::FREQUENCIES[idx], 0.0, Self::Q, sample_rate)
106        })
107    }
108
109    pub fn set_band_gain(&mut self, band_idx: usize, gain_db: f64, sample_rate: f64) {
110        if band_idx >= EQ_BANDS {
111            return;
112        }
113        let gain_db = gain_db.clamp(-15.0, 15.0);
114        let freq = Self::FREQUENCIES[band_idx];
115        // Update target filters for all channels
116        for ch in 0..self.channels {
117            self.target_bands[ch][band_idx] =
118                BiquadSection::peaking_eq(freq, gain_db, Self::Q, sample_rate);
119        }
120        self.target_gains[band_idx] = gain_db;
121        // Start crossfade for this band
122        self.smooth_counter[band_idx] = EQ_SMOOTH_SAMPLES;
123    }
124
125    pub fn set_all_bands(&mut self, gains: &[f64; EQ_BANDS], sample_rate: f64) {
126        for (idx, &gain) in gains.iter().enumerate() {
127            self.set_band_gain(idx, gain, sample_rate);
128        }
129    }
130
131    pub fn set_enabled(&mut self, enabled: bool) {
132        self.enabled = enabled;
133    }
134
135    pub fn is_enabled(&self) -> bool {
136        self.enabled
137    }
138
139    pub fn process(&mut self, buffer: &mut [f64]) {
140        if !self.enabled {
141            return;
142        }
143        debug_assert!(self.bands.len() >= self.channels);
144        debug_assert!(self.target_bands.len() >= self.channels);
145        let frames = buffer.len() / self.channels;
146
147        if self.channels == 2 && self.smooth_counter.iter().all(|&counter| counter == 0) {
148            self.process_settled_stereo(buffer);
149            return;
150        }
151
152        for frame in 0..frames {
153            // Process all channels for this frame
154            for ch in 0..self.channels {
155                let idx = frame * self.channels + ch;
156                buffer[idx] = self.process_sample_no_counter_update(buffer[idx], ch);
157            }
158
159            // Update smooth counters once per frame (after all channels processed)
160            // This fixes the multi-channel sync issue (MINOR-04)
161            for b in 0..EQ_BANDS {
162                if self.smooth_counter[b] > 0 {
163                    self.smooth_counter[b] -= 1;
164                    // Crossfade done: snap current to target
165                    if self.smooth_counter[b] == 0 {
166                        for c in 0..self.channels {
167                            self.bands[c][b].copy_coefficients_from(&self.target_bands[c][b]);
168                        }
169                    }
170                }
171            }
172        }
173    }
174
175    fn process_settled_stereo(&mut self, buffer: &mut [f64]) {
176        debug_assert_eq!(self.channels, 2);
177        debug_assert!(self.smooth_counter.iter().all(|&counter| counter == 0));
178
179        let (left_banks, right_banks) = self.bands.split_at_mut(1);
180        let left_bands = &mut left_banks[0];
181        let right_bands = &mut right_banks[0];
182
183        for frame in buffer.chunks_exact_mut(2) {
184            let mut left = frame[0];
185            for band in left_bands.iter_mut() {
186                left = band.process(left);
187            }
188            frame[0] = left;
189
190            let mut right = frame[1];
191            for band in right_bands.iter_mut() {
192                right = band.process(right);
193            }
194            frame[1] = right;
195        }
196    }
197
198    /// Process a single sample without updating smooth_counter
199    /// Counter updates are handled in process() for proper multi-channel sync
200    #[inline]
201    fn process_sample_no_counter_update(&mut self, mut sample: f64, ch: usize) -> f64 {
202        debug_assert!(ch < self.channels);
203        for b in 0..EQ_BANDS {
204            if self.smooth_counter[b] > 0 {
205                // Blend: run both filters on the same input
206                let current_out = self.bands[ch][b].process(sample);
207                let target_out = self.target_bands[ch][b].process(sample);
208                let t = self.smooth_counter[b] as f64 * INV_EQ_SMOOTH;
209                sample = current_out * t + target_out * (1.0 - t);
210            } else {
211                sample = self.bands[ch][b].process(sample);
212            }
213        }
214        sample
215    }
216
217    // M-2 fix: Removed deprecated process_sample() method.
218    // It duplicated logic from process() + process_sample_no_counter_update()
219    // with subtle differences that could cause bugs. Use process() instead.
220
221    pub fn reset(&mut self) {
222        for ch in &mut self.bands {
223            for band in ch {
224                band.reset();
225            }
226        }
227        for ch in &mut self.target_bands {
228            for band in ch {
229                band.reset();
230            }
231        }
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn fixed_band_banks_are_allocated_per_channel() {
241        for channels in [1, 2, 6, 8] {
242            let eq = Equalizer::new(channels, 48_000.0);
243            assert_eq!(eq.bands.len(), channels);
244            assert_eq!(eq.target_bands.len(), channels);
245            assert!(eq.bands.iter().all(|bank| bank.len() == EQ_BANDS));
246            assert!(eq.target_bands.iter().all(|bank| bank.len() == EQ_BANDS));
247        }
248    }
249
250    #[test]
251    fn reset_clears_current_and_target_bank_state() {
252        let mut eq = Equalizer::new(2, 48_000.0);
253        eq.set_enabled(true);
254        eq.set_band_gain(0, 6.0, 48_000.0);
255
256        let mut buffer = vec![0.25; 256];
257        eq.process(&mut buffer);
258
259        assert!(eq
260            .bands
261            .iter()
262            .flatten()
263            .chain(eq.target_bands.iter().flatten())
264            .any(|band| band.z1 != 0.0 || band.z2 != 0.0));
265
266        eq.reset();
267
268        assert!(eq
269            .bands
270            .iter()
271            .flatten()
272            .chain(eq.target_bands.iter().flatten())
273            .all(|band| band.z1 == 0.0 && band.z2 == 0.0));
274    }
275
276    #[test]
277    fn settled_stereo_fast_path_matches_regular_path() {
278        let gains = [12.0, 9.0, 6.0, 3.0, -3.0, -6.0, -9.0, -12.0, 6.0, -6.0];
279        let mut regular = Equalizer::new(2, 48_000.0);
280        let mut fast = Equalizer::new(2, 48_000.0);
281        regular.set_enabled(true);
282        fast.set_enabled(true);
283        regular.set_all_bands(&gains, 48_000.0);
284        fast.set_all_bands(&gains, 48_000.0);
285
286        let mut silence = vec![0.0; 2 * (EQ_SMOOTH_SAMPLES as usize + 1)];
287        regular.process(&mut silence);
288        fast.process(&mut silence);
289        assert!(regular.smooth_counter.iter().all(|&counter| counter == 0));
290        assert!(fast.smooth_counter.iter().all(|&counter| counter == 0));
291
292        let mut regular_buffer = (0..2048)
293            .map(|sample| {
294                let t = sample as f64 / 48_000.0;
295                (2.0 * std::f64::consts::PI * 997.0 * t).sin() * 0.25
296            })
297            .collect::<Vec<_>>();
298        let mut fast_buffer = regular_buffer.clone();
299
300        regular.process_sample_by_sample_for_test(&mut regular_buffer);
301        fast.process(&mut fast_buffer);
302
303        let max_abs = regular_buffer
304            .iter()
305            .zip(&fast_buffer)
306            .map(|(a, b)| (a - b).abs())
307            .fold(0.0, f64::max);
308        assert!(max_abs <= 1.0e-12, "max_abs={max_abs:.3e}");
309    }
310
311    impl Equalizer {
312        fn process_sample_by_sample_for_test(&mut self, buffer: &mut [f64]) {
313            let frames = buffer.len() / self.channels;
314            for frame in 0..frames {
315                for ch in 0..self.channels {
316                    let idx = frame * self.channels + ch;
317                    buffer[idx] = self.process_sample_no_counter_update(buffer[idx], ch);
318                }
319            }
320        }
321    }
322}