Skip to main content

proteus_lib/dsp/effects/
multiband_eq.rs

1//! Multiband parametric EQ effect.
2//!
3//! This effect applies a configurable chain of parametric EQ points plus
4//! optional edge-shaping filters for low and high frequency boundaries.
5
6use std::f32::consts::PI;
7
8use serde::{Deserialize, Serialize};
9
10use super::EffectContext;
11
12const DEFAULT_LOW_FREQ_HZ: u32 = 120;
13const DEFAULT_MID_FREQ_HZ: u32 = 1_000;
14const DEFAULT_HIGH_FREQ_HZ: u32 = 8_000;
15const DEFAULT_Q: f32 = 0.8;
16const DEFAULT_GAIN_DB: f32 = 0.0;
17const MIN_Q: f32 = 0.1;
18const MAX_Q: f32 = 10.0;
19const MIN_GAIN_DB: f32 = -24.0;
20const MAX_GAIN_DB: f32 = 24.0;
21
22/// Serialized configuration for a single parametric EQ point.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(default)]
25pub struct EqPointSettings {
26    pub freq_hz: u32,
27    pub q: f32,
28    pub gain_db: f32,
29}
30
31impl EqPointSettings {
32    /// Create an EQ point.
33    pub fn new(freq_hz: u32, q: f32, gain_db: f32) -> Self {
34        Self {
35            freq_hz,
36            q,
37            gain_db,
38        }
39    }
40}
41
42impl Default for EqPointSettings {
43    fn default() -> Self {
44        Self {
45            freq_hz: DEFAULT_MID_FREQ_HZ,
46            q: DEFAULT_Q,
47            gain_db: DEFAULT_GAIN_DB,
48        }
49    }
50}
51
52/// Optional low-edge shaping.
53///
54/// `HighPass` removes low-end energy below the cutoff.
55/// `LowShelf` boosts/cuts the low-end around the center frequency.
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "type", rename_all = "snake_case")]
58pub enum LowEdgeFilterSettings {
59    HighPass { freq_hz: u32, q: f32 },
60    LowShelf { freq_hz: u32, q: f32, gain_db: f32 },
61}
62
63/// Optional high-edge shaping.
64///
65/// `LowPass` removes high-end energy above the cutoff.
66/// `HighShelf` boosts/cuts the high-end around the center frequency.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(tag = "type", rename_all = "snake_case")]
69pub enum HighEdgeFilterSettings {
70    LowPass { freq_hz: u32, q: f32 },
71    HighShelf { freq_hz: u32, q: f32, gain_db: f32 },
72}
73
74/// Serialized configuration for multiband EQ.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[serde(default)]
77pub struct MultibandEqSettings {
78    #[serde(alias = "bands", alias = "eq_points")]
79    pub points: Vec<EqPointSettings>,
80    pub low_edge: Option<LowEdgeFilterSettings>,
81    pub high_edge: Option<HighEdgeFilterSettings>,
82}
83
84impl MultibandEqSettings {
85    /// Create multiband EQ settings.
86    pub fn new(
87        points: Vec<EqPointSettings>,
88        low_edge: Option<LowEdgeFilterSettings>,
89        high_edge: Option<HighEdgeFilterSettings>,
90    ) -> Self {
91        Self {
92            points,
93            low_edge,
94            high_edge,
95        }
96    }
97}
98
99impl Default for MultibandEqSettings {
100    fn default() -> Self {
101        Self {
102            points: vec![
103                EqPointSettings::new(DEFAULT_LOW_FREQ_HZ, DEFAULT_Q, DEFAULT_GAIN_DB),
104                EqPointSettings::new(DEFAULT_MID_FREQ_HZ, DEFAULT_Q, DEFAULT_GAIN_DB),
105                EqPointSettings::new(DEFAULT_HIGH_FREQ_HZ, DEFAULT_Q, DEFAULT_GAIN_DB),
106            ],
107            low_edge: None,
108            high_edge: None,
109        }
110    }
111}
112
113/// Configured multiband EQ effect with runtime state.
114#[derive(Clone, Serialize, Deserialize)]
115#[serde(default)]
116pub struct MultibandEqEffect {
117    pub enabled: bool,
118    #[serde(flatten)]
119    pub settings: MultibandEqSettings,
120    #[serde(skip)]
121    state: Option<MultibandEqState>,
122}
123
124impl std::fmt::Debug for MultibandEqEffect {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        f.debug_struct("MultibandEqEffect")
127            .field("enabled", &self.enabled)
128            .field("settings", &self.settings)
129            .finish()
130    }
131}
132
133impl Default for MultibandEqEffect {
134    fn default() -> Self {
135        Self {
136            enabled: false,
137            settings: MultibandEqSettings::default(),
138            state: None,
139        }
140    }
141}
142
143impl MultibandEqEffect {
144    /// Process interleaved samples through the multiband EQ.
145    ///
146    /// # Arguments
147    /// - `samples`: Interleaved input samples.
148    /// - `context`: Environment details (sample rate, channels, etc.).
149    /// - `drain`: Unused for this effect.
150    ///
151    /// # Returns
152    /// Processed interleaved samples.
153    pub fn process(&mut self, samples: &[f32], context: &EffectContext, _drain: bool) -> Vec<f32> {
154        if !self.enabled {
155            return samples.to_vec();
156        }
157
158        self.ensure_state(context);
159        let Some(state) = self.state.as_mut() else {
160            return samples.to_vec();
161        };
162
163        if samples.is_empty() {
164            return Vec::new();
165        }
166
167        let channels = state.channels;
168        let mut output = Vec::with_capacity(samples.len());
169
170        for (idx, &sample) in samples.iter().enumerate() {
171            let ch = idx % channels;
172            let mut y = sample;
173
174            if let Some(filter) = state.low_edge.as_mut() {
175                y = filter.process_sample(ch, y);
176            }
177
178            for point in &mut state.points {
179                y = point.process_sample(ch, y);
180            }
181
182            if let Some(filter) = state.high_edge.as_mut() {
183                y = filter.process_sample(ch, y);
184            }
185
186            output.push(y);
187        }
188
189        output
190    }
191
192    /// Reset any internal state held by the multiband EQ.
193    pub fn reset_state(&mut self) {
194        if let Some(state) = self.state.as_mut() {
195            state.reset();
196        }
197        self.state = None;
198    }
199
200    fn ensure_state(&mut self, context: &EffectContext) {
201        let channels = context.channels.max(1);
202        let points = self
203            .settings
204            .points
205            .iter()
206            .map(|point| EqPointParams {
207                freq_hz: sanitize_freq(point.freq_hz, context.sample_rate),
208                q: sanitize_q(point.q),
209                gain_db: sanitize_gain_db(point.gain_db),
210            })
211            .collect::<Vec<_>>();
212
213        let low_edge = self
214            .settings
215            .low_edge
216            .as_ref()
217            .map(|edge| sanitize_low_edge(edge, context.sample_rate));
218        let high_edge = self
219            .settings
220            .high_edge
221            .as_ref()
222            .map(|edge| sanitize_high_edge(edge, context.sample_rate));
223
224        let needs_reset = self
225            .state
226            .as_ref()
227            .map(|state| {
228                state.matches(
229                    context.sample_rate,
230                    channels,
231                    &points,
232                    &low_edge,
233                    &high_edge,
234                )
235            })
236            .map(|matches| !matches)
237            .unwrap_or(true);
238
239        if needs_reset {
240            self.state = Some(MultibandEqState::new(
241                context.sample_rate,
242                channels,
243                points,
244                low_edge,
245                high_edge,
246            ));
247        }
248    }
249}
250
251#[derive(Clone, Copy, Debug)]
252struct EqPointParams {
253    freq_hz: u32,
254    q: f32,
255    gain_db: f32,
256}
257
258#[derive(Clone, Copy, Debug)]
259enum LowEdgeParams {
260    HighPass { freq_hz: u32, q: f32 },
261    LowShelf { freq_hz: u32, q: f32, gain_db: f32 },
262}
263
264#[derive(Clone, Copy, Debug)]
265enum HighEdgeParams {
266    LowPass { freq_hz: u32, q: f32 },
267    HighShelf { freq_hz: u32, q: f32, gain_db: f32 },
268}
269
270#[derive(Clone, Debug)]
271struct MultibandEqState {
272    sample_rate: u32,
273    channels: usize,
274    points_params: Vec<EqPointParams>,
275    low_edge_params: Option<LowEdgeParams>,
276    high_edge_params: Option<HighEdgeParams>,
277    points: Vec<Biquad>,
278    low_edge: Option<Biquad>,
279    high_edge: Option<Biquad>,
280}
281
282impl MultibandEqState {
283    fn new(
284        sample_rate: u32,
285        channels: usize,
286        points_params: Vec<EqPointParams>,
287        low_edge_params: Option<LowEdgeParams>,
288        high_edge_params: Option<HighEdgeParams>,
289    ) -> Self {
290        let low_edge = low_edge_params.map(|params| match params {
291            LowEdgeParams::HighPass { freq_hz, q } => {
292                Biquad::new(sample_rate, channels, BiquadDesign::HighPass { freq_hz, q })
293            }
294            LowEdgeParams::LowShelf {
295                freq_hz,
296                q,
297                gain_db,
298            } => Biquad::new(
299                sample_rate,
300                channels,
301                BiquadDesign::LowShelf {
302                    freq_hz,
303                    q,
304                    gain_db,
305                },
306            ),
307        });
308
309        let points = points_params
310            .iter()
311            .map(|params| {
312                Biquad::new(
313                    sample_rate,
314                    channels,
315                    BiquadDesign::Peaking {
316                        freq_hz: params.freq_hz,
317                        q: params.q,
318                        gain_db: params.gain_db,
319                    },
320                )
321            })
322            .collect();
323
324        let high_edge = high_edge_params.map(|params| match params {
325            HighEdgeParams::LowPass { freq_hz, q } => {
326                Biquad::new(sample_rate, channels, BiquadDesign::LowPass { freq_hz, q })
327            }
328            HighEdgeParams::HighShelf {
329                freq_hz,
330                q,
331                gain_db,
332            } => Biquad::new(
333                sample_rate,
334                channels,
335                BiquadDesign::HighShelf {
336                    freq_hz,
337                    q,
338                    gain_db,
339                },
340            ),
341        });
342
343        Self {
344            sample_rate,
345            channels,
346            points_params,
347            low_edge_params,
348            high_edge_params,
349            points,
350            low_edge,
351            high_edge,
352        }
353    }
354
355    fn matches(
356        &self,
357        sample_rate: u32,
358        channels: usize,
359        points_params: &[EqPointParams],
360        low_edge_params: &Option<LowEdgeParams>,
361        high_edge_params: &Option<HighEdgeParams>,
362    ) -> bool {
363        self.sample_rate == sample_rate
364            && self.channels == channels
365            && eq_point_params_vec_equal(&self.points_params, points_params)
366            && low_edge_params_equal(self.low_edge_params, *low_edge_params)
367            && high_edge_params_equal(self.high_edge_params, *high_edge_params)
368    }
369
370    fn reset(&mut self) {
371        for point in &mut self.points {
372            point.reset();
373        }
374        if let Some(filter) = self.low_edge.as_mut() {
375            filter.reset();
376        }
377        if let Some(filter) = self.high_edge.as_mut() {
378            filter.reset();
379        }
380    }
381}
382
383#[derive(Clone, Copy, Debug)]
384struct BiquadCoefficients {
385    b0: f32,
386    b1: f32,
387    b2: f32,
388    a1: f32,
389    a2: f32,
390}
391
392#[derive(Clone, Copy, Debug)]
393enum BiquadDesign {
394    Peaking { freq_hz: u32, q: f32, gain_db: f32 },
395    LowPass { freq_hz: u32, q: f32 },
396    HighPass { freq_hz: u32, q: f32 },
397    LowShelf { freq_hz: u32, q: f32, gain_db: f32 },
398    HighShelf { freq_hz: u32, q: f32, gain_db: f32 },
399}
400
401#[derive(Clone, Debug)]
402struct Biquad {
403    coeffs: BiquadCoefficients,
404    x_n1: Vec<f32>,
405    x_n2: Vec<f32>,
406    y_n1: Vec<f32>,
407    y_n2: Vec<f32>,
408}
409
410impl Biquad {
411    fn new(sample_rate: u32, channels: usize, design: BiquadDesign) -> Self {
412        let channels = channels.max(1);
413        Self {
414            coeffs: coefficients(sample_rate, design),
415            x_n1: vec![0.0; channels],
416            x_n2: vec![0.0; channels],
417            y_n1: vec![0.0; channels],
418            y_n2: vec![0.0; channels],
419        }
420    }
421
422    fn process_sample(&mut self, channel: usize, sample: f32) -> f32 {
423        let y = self.coeffs.b0 * sample
424            + self.coeffs.b1 * self.x_n1[channel]
425            + self.coeffs.b2 * self.x_n2[channel]
426            - self.coeffs.a1 * self.y_n1[channel]
427            - self.coeffs.a2 * self.y_n2[channel];
428
429        self.x_n2[channel] = self.x_n1[channel];
430        self.x_n1[channel] = sample;
431        self.y_n2[channel] = self.y_n1[channel];
432        self.y_n1[channel] = y;
433
434        y
435    }
436
437    fn reset(&mut self) {
438        self.x_n1.fill(0.0);
439        self.x_n2.fill(0.0);
440        self.y_n1.fill(0.0);
441        self.y_n2.fill(0.0);
442    }
443}
444
445fn coefficients(sample_rate: u32, design: BiquadDesign) -> BiquadCoefficients {
446    match design {
447        BiquadDesign::Peaking {
448            freq_hz,
449            q,
450            gain_db,
451        } => peaking_coefficients(sample_rate, freq_hz, q, gain_db),
452        BiquadDesign::LowPass { freq_hz, q } => low_pass_coefficients(sample_rate, freq_hz, q),
453        BiquadDesign::HighPass { freq_hz, q } => high_pass_coefficients(sample_rate, freq_hz, q),
454        BiquadDesign::LowShelf {
455            freq_hz,
456            q,
457            gain_db,
458        } => low_shelf_coefficients(sample_rate, freq_hz, q, gain_db),
459        BiquadDesign::HighShelf {
460            freq_hz,
461            q,
462            gain_db,
463        } => high_shelf_coefficients(sample_rate, freq_hz, q, gain_db),
464    }
465}
466
467fn peaking_coefficients(
468    sample_rate: u32,
469    freq_hz: u32,
470    q: f32,
471    gain_db: f32,
472) -> BiquadCoefficients {
473    let w0 = 2.0 * PI * freq_hz as f32 / sample_rate as f32;
474    let cos_w0 = w0.cos();
475    let alpha = w0.sin() / (2.0 * q);
476    let amplitude = 10.0_f32.powf(gain_db / 40.0);
477
478    let b0 = 1.0 + alpha * amplitude;
479    let b1 = -2.0 * cos_w0;
480    let b2 = 1.0 - alpha * amplitude;
481    let a0 = 1.0 + alpha / amplitude;
482    let a1 = -2.0 * cos_w0;
483    let a2 = 1.0 - alpha / amplitude;
484
485    normalized_coefficients(b0, b1, b2, a0, a1, a2)
486}
487
488fn low_pass_coefficients(sample_rate: u32, freq_hz: u32, q: f32) -> BiquadCoefficients {
489    let w0 = 2.0 * PI * freq_hz as f32 / sample_rate as f32;
490    let cos_w0 = w0.cos();
491    let alpha = w0.sin() / (2.0 * q);
492
493    let b1 = 1.0 - cos_w0;
494    let b0 = b1 / 2.0;
495    let b2 = b0;
496    let a0 = 1.0 + alpha;
497    let a1 = -2.0 * cos_w0;
498    let a2 = 1.0 - alpha;
499
500    normalized_coefficients(b0, b1, b2, a0, a1, a2)
501}
502
503fn high_pass_coefficients(sample_rate: u32, freq_hz: u32, q: f32) -> BiquadCoefficients {
504    let w0 = 2.0 * PI * freq_hz as f32 / sample_rate as f32;
505    let cos_w0 = w0.cos();
506    let alpha = w0.sin() / (2.0 * q);
507
508    let b0 = (1.0 + cos_w0) / 2.0;
509    let b1 = -1.0 - cos_w0;
510    let b2 = b0;
511    let a0 = 1.0 + alpha;
512    let a1 = -2.0 * cos_w0;
513    let a2 = 1.0 - alpha;
514
515    normalized_coefficients(b0, b1, b2, a0, a1, a2)
516}
517
518fn low_shelf_coefficients(
519    sample_rate: u32,
520    freq_hz: u32,
521    q: f32,
522    gain_db: f32,
523) -> BiquadCoefficients {
524    let w0 = 2.0 * PI * freq_hz as f32 / sample_rate as f32;
525    let cos_w0 = w0.cos();
526    let alpha = w0.sin() / (2.0 * q);
527    let amplitude = 10.0_f32.powf(gain_db / 40.0);
528    let sqrt_amplitude = amplitude.sqrt();
529
530    let b0 =
531        amplitude * ((amplitude + 1.0) - (amplitude - 1.0) * cos_w0 + 2.0 * sqrt_amplitude * alpha);
532    let b1 = 2.0 * amplitude * ((amplitude - 1.0) - (amplitude + 1.0) * cos_w0);
533    let b2 =
534        amplitude * ((amplitude + 1.0) - (amplitude - 1.0) * cos_w0 - 2.0 * sqrt_amplitude * alpha);
535    let a0 = (amplitude + 1.0) + (amplitude - 1.0) * cos_w0 + 2.0 * sqrt_amplitude * alpha;
536    let a1 = -2.0 * ((amplitude - 1.0) + (amplitude + 1.0) * cos_w0);
537    let a2 = (amplitude + 1.0) + (amplitude - 1.0) * cos_w0 - 2.0 * sqrt_amplitude * alpha;
538
539    normalized_coefficients(b0, b1, b2, a0, a1, a2)
540}
541
542fn high_shelf_coefficients(
543    sample_rate: u32,
544    freq_hz: u32,
545    q: f32,
546    gain_db: f32,
547) -> BiquadCoefficients {
548    let w0 = 2.0 * PI * freq_hz as f32 / sample_rate as f32;
549    let cos_w0 = w0.cos();
550    let alpha = w0.sin() / (2.0 * q);
551    let amplitude = 10.0_f32.powf(gain_db / 40.0);
552    let sqrt_amplitude = amplitude.sqrt();
553
554    let b0 =
555        amplitude * ((amplitude + 1.0) + (amplitude - 1.0) * cos_w0 + 2.0 * sqrt_amplitude * alpha);
556    let b1 = -2.0 * amplitude * ((amplitude - 1.0) + (amplitude + 1.0) * cos_w0);
557    let b2 =
558        amplitude * ((amplitude + 1.0) + (amplitude - 1.0) * cos_w0 - 2.0 * sqrt_amplitude * alpha);
559    let a0 = (amplitude + 1.0) - (amplitude - 1.0) * cos_w0 + 2.0 * sqrt_amplitude * alpha;
560    let a1 = 2.0 * ((amplitude - 1.0) - (amplitude + 1.0) * cos_w0);
561    let a2 = (amplitude + 1.0) - (amplitude - 1.0) * cos_w0 - 2.0 * sqrt_amplitude * alpha;
562
563    normalized_coefficients(b0, b1, b2, a0, a1, a2)
564}
565
566fn normalized_coefficients(
567    b0: f32,
568    b1: f32,
569    b2: f32,
570    a0: f32,
571    a1: f32,
572    a2: f32,
573) -> BiquadCoefficients {
574    BiquadCoefficients {
575        b0: b0 / a0,
576        b1: b1 / a0,
577        b2: b2 / a0,
578        a1: a1 / a0,
579        a2: a2 / a0,
580    }
581}
582
583fn sanitize_low_edge(edge: &LowEdgeFilterSettings, sample_rate: u32) -> LowEdgeParams {
584    match edge {
585        LowEdgeFilterSettings::HighPass { freq_hz, q } => LowEdgeParams::HighPass {
586            freq_hz: sanitize_freq(*freq_hz, sample_rate),
587            q: sanitize_q(*q),
588        },
589        LowEdgeFilterSettings::LowShelf {
590            freq_hz,
591            q,
592            gain_db,
593        } => LowEdgeParams::LowShelf {
594            freq_hz: sanitize_freq(*freq_hz, sample_rate),
595            q: sanitize_q(*q),
596            gain_db: sanitize_gain_db(*gain_db),
597        },
598    }
599}
600
601fn sanitize_high_edge(edge: &HighEdgeFilterSettings, sample_rate: u32) -> HighEdgeParams {
602    match edge {
603        HighEdgeFilterSettings::LowPass { freq_hz, q } => HighEdgeParams::LowPass {
604            freq_hz: sanitize_freq(*freq_hz, sample_rate),
605            q: sanitize_q(*q),
606        },
607        HighEdgeFilterSettings::HighShelf {
608            freq_hz,
609            q,
610            gain_db,
611        } => HighEdgeParams::HighShelf {
612            freq_hz: sanitize_freq(*freq_hz, sample_rate),
613            q: sanitize_q(*q),
614            gain_db: sanitize_gain_db(*gain_db),
615        },
616    }
617}
618
619fn sanitize_freq(freq_hz: u32, sample_rate: u32) -> u32 {
620    let nyquist = sample_rate / 2;
621    if nyquist <= 1 {
622        return 1;
623    }
624    freq_hz.clamp(1, nyquist.saturating_sub(1).max(1))
625}
626
627fn sanitize_q(q: f32) -> f32 {
628    if !q.is_finite() {
629        return DEFAULT_Q;
630    }
631    q.clamp(MIN_Q, MAX_Q)
632}
633
634fn sanitize_gain_db(gain_db: f32) -> f32 {
635    if !gain_db.is_finite() {
636        return DEFAULT_GAIN_DB;
637    }
638    gain_db.clamp(MIN_GAIN_DB, MAX_GAIN_DB)
639}
640
641fn eq_point_params_vec_equal(left: &[EqPointParams], right: &[EqPointParams]) -> bool {
642    left.len() == right.len()
643        && left
644            .iter()
645            .zip(right.iter())
646            .all(|(l, r)| eq_point_params_equal(*l, *r))
647}
648
649fn eq_point_params_equal(left: EqPointParams, right: EqPointParams) -> bool {
650    left.freq_hz == right.freq_hz
651        && (left.q - right.q).abs() < f32::EPSILON
652        && (left.gain_db - right.gain_db).abs() < f32::EPSILON
653}
654
655fn low_edge_params_equal(left: Option<LowEdgeParams>, right: Option<LowEdgeParams>) -> bool {
656    match (left, right) {
657        (None, None) => true,
658        (
659            Some(LowEdgeParams::HighPass { freq_hz: lf, q: lq }),
660            Some(LowEdgeParams::HighPass { freq_hz: rf, q: rq }),
661        ) => lf == rf && (lq - rq).abs() < f32::EPSILON,
662        (
663            Some(LowEdgeParams::LowShelf {
664                freq_hz: lf,
665                q: lq,
666                gain_db: lg,
667            }),
668            Some(LowEdgeParams::LowShelf {
669                freq_hz: rf,
670                q: rq,
671                gain_db: rg,
672            }),
673        ) => lf == rf && (lq - rq).abs() < f32::EPSILON && (lg - rg).abs() < f32::EPSILON,
674        _ => false,
675    }
676}
677
678fn high_edge_params_equal(left: Option<HighEdgeParams>, right: Option<HighEdgeParams>) -> bool {
679    match (left, right) {
680        (None, None) => true,
681        (
682            Some(HighEdgeParams::LowPass { freq_hz: lf, q: lq }),
683            Some(HighEdgeParams::LowPass { freq_hz: rf, q: rq }),
684        ) => lf == rf && (lq - rq).abs() < f32::EPSILON,
685        (
686            Some(HighEdgeParams::HighShelf {
687                freq_hz: lf,
688                q: lq,
689                gain_db: lg,
690            }),
691            Some(HighEdgeParams::HighShelf {
692                freq_hz: rf,
693                q: rq,
694                gain_db: rg,
695            }),
696        ) => lf == rf && (lq - rq).abs() < f32::EPSILON && (lg - rg).abs() < f32::EPSILON,
697        _ => false,
698    }
699}
700
701#[cfg(test)]
702mod tests {
703    use super::*;
704
705    fn context() -> EffectContext {
706        EffectContext {
707            sample_rate: 48_000,
708            channels: 2,
709            container_path: None,
710            impulse_response_spec: None,
711            impulse_response_tail_db: -60.0,
712        }
713    }
714
715    #[test]
716    fn multiband_eq_disabled_passthrough() {
717        let mut effect = MultibandEqEffect::default();
718        let samples = vec![0.25_f32, -0.25, 0.5, -0.5];
719        let output = effect.process(&samples, &context(), false);
720        assert_eq!(output, samples);
721    }
722
723    #[test]
724    fn multiband_eq_points_and_edges_change_signal() {
725        let mut effect = MultibandEqEffect::default();
726        effect.enabled = true;
727        effect.settings.points = vec![
728            EqPointSettings::new(120, 0.8, 6.0),
729            EqPointSettings::new(1_000, 1.2, -4.0),
730            EqPointSettings::new(8_000, 0.9, 3.0),
731            EqPointSettings::new(12_000, 0.7, -2.0),
732        ];
733        effect.settings.low_edge = Some(LowEdgeFilterSettings::HighPass {
734            freq_hz: 40,
735            q: 0.7,
736        });
737        effect.settings.high_edge = Some(HighEdgeFilterSettings::HighShelf {
738            freq_hz: 10_000,
739            q: 0.8,
740            gain_db: 2.0,
741        });
742
743        let samples = vec![0.1_f32, -0.1, 0.2, -0.2, 0.15, -0.15, 0.3, -0.3];
744        let output = effect.process(&samples, &context(), false);
745
746        assert_eq!(output.len(), samples.len());
747        assert!(output.iter().all(|value| value.is_finite()));
748        assert!(output
749            .iter()
750            .zip(samples.iter())
751            .any(|(out, input)| (*out - *input).abs() > 1e-6));
752    }
753
754    #[test]
755    fn multiband_eq_deserializes_vec_points_and_edge_variants() {
756        let json = r#"{
757            "enabled": true,
758            "points": [
759                {"freq_hz": 120, "q": 0.8, "gain_db": 4.5},
760                {"freq_hz": 800, "q": 1.1, "gain_db": -3.0}
761            ],
762            "low_edge": {"type": "low_shelf", "freq_hz": 100, "q": 0.8, "gain_db": 2.0},
763            "high_edge": {"type": "low_pass", "freq_hz": 14000, "q": 0.7}
764        }"#;
765
766        let effect: MultibandEqEffect =
767            serde_json::from_str(json).expect("deserialize multiband eq");
768        assert_eq!(effect.settings.points.len(), 2);
769        assert!(matches!(
770            effect.settings.low_edge,
771            Some(LowEdgeFilterSettings::LowShelf { .. })
772        ));
773        assert!(matches!(
774            effect.settings.high_edge,
775            Some(HighEdgeFilterSettings::LowPass { .. })
776        ));
777    }
778}