Skip to main content

quiver/modules/
filters.rs

1//! Filter modules.
2
3use crate::modules::common::{flush_denorm, sanitize_audio, Memo};
4use crate::port::{GraphModule, PortDef, PortSpec, PortValues, SignalKind};
5use alloc::vec;
6use core::f64::consts::{PI, TAU};
7use libm::Libm;
8
9/// State Variable Filter (SVF)
10///
11/// A versatile 12dB/oct filter with simultaneous lowpass, bandpass,
12/// highpass, and notch outputs. Features cutoff, resonance, FM, and
13/// keyboard tracking inputs.
14///
15/// Implemented as a Zavalishin topology-preserving-transform (TPT / zero-delay
16/// feedback) SVF. The prewarped coefficient `g = tan(π·fc/fs)` keeps the cutoff
17/// correctly tuned all the way toward Nyquist (unlike the older Chamberlin core
18/// whose `2·sin(π·fc/fs)` coefficient froze above ~fs/6), and the trapezoidal
19/// integrator states are bounded by a soft nonlinearity so high resonance
20/// self-oscillates stably instead of diverging.
21///
22/// Phase 3 features:
23/// - Self-oscillation at high resonance values
24/// - Keyboard tracking for filter-follows-pitch
25pub struct Svf {
26    /// First trapezoidal integrator state (TPT `ic1eq`).
27    ic1eq: f64,
28    /// Second trapezoidal integrator state (TPT `ic2eq`).
29    ic2eq: f64,
30    sample_rate: f64,
31    /// Memoized prewarped cutoff coefficient `g = tan(π·fc/fs)`: the pow/pow/tan
32    /// derivation only changes when the driving CVs (or sample rate) change.
33    g_memo: Memo<4, f64>,
34    spec: PortSpec,
35}
36
37/// Minimum damping factor `k` (`= 1/Q`). Floored strictly positive so the
38/// linear TPT core keeps its poles inside the unit circle (never diverges);
39/// at `res = 1` this leaves a near-lossless resonator that sustains a long,
40/// bounded self-oscillation.
41const SVF_K_MIN: f64 = 1e-5;
42
43/// Soft-clip limit (volts) applied to the SVF integrator states. Chosen well
44/// above the nominal ±5 V audio range so ordinary signals pass through
45/// linearly, while still bounding runaway energy under heavy drive at extreme
46/// resonance.
47const SVF_STATE_LIMIT: f64 = 8.0;
48
49/// Bounded nonlinearity for the SVF integrator states: identity within
50/// `±SVF_STATE_LIMIT`, tanh-limited beyond. Keeps self-oscillation and hard
51/// drive finite without distorting normal-level audio.
52#[inline]
53fn svf_soft_clip(x: f64) -> f64 {
54    if Libm::<f64>::fabs(x) <= SVF_STATE_LIMIT {
55        x
56    } else {
57        SVF_STATE_LIMIT * Libm::<f64>::tanh(x / SVF_STATE_LIMIT)
58    }
59}
60
61impl Svf {
62    pub fn new(sample_rate: f64) -> Self {
63        Self {
64            ic1eq: 0.0,
65            ic2eq: 0.0,
66            sample_rate,
67            g_memo: Memo::new(0.0),
68            spec: PortSpec {
69                inputs: vec![
70                    PortDef::new(0, "in", SignalKind::Audio),
71                    PortDef::new(1, "cutoff", SignalKind::CvUnipolar)
72                        .with_default(0.5)
73                        .with_attenuverter(),
74                    PortDef::new(2, "res", SignalKind::CvUnipolar)
75                        .with_default(0.0)
76                        .with_attenuverter(),
77                    PortDef::new(3, "fm", SignalKind::CvBipolar).with_attenuverter(),
78                    // Phase 3: Keyboard tracking input
79                    PortDef::new(4, "keytrack", SignalKind::VoltPerOctave),
80                    // Phase 3: Keyboard tracking amount (0-1)
81                    PortDef::new(5, "keytrack_amt", SignalKind::CvUnipolar).with_default(0.0),
82                ],
83                outputs: vec![
84                    PortDef::new(10, "lp", SignalKind::Audio),
85                    PortDef::new(11, "bp", SignalKind::Audio),
86                    PortDef::new(12, "hp", SignalKind::Audio),
87                    PortDef::new(13, "notch", SignalKind::Audio),
88                ],
89            },
90        }
91    }
92}
93
94impl Default for Svf {
95    fn default() -> Self {
96        Self::new(44100.0)
97    }
98}
99
100impl GraphModule for Svf {
101    fn port_spec(&self) -> &PortSpec {
102        &self.spec
103    }
104
105    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
106        // Q160: sanitize so a non-finite input can never poison the resonant
107        // TPT integrator state (which would otherwise latch NaN forever).
108        let input = sanitize_audio(inputs.get_or(0, 0.0));
109        let cutoff_cv = inputs.get_or(1, 0.5) + inputs.get_or(3, 0.0);
110        let res = inputs.get_or(2, 0.0).clamp(0.0, 1.0);
111
112        // Phase 3: Keyboard tracking
113        let keytrack_voct = inputs.get_or(4, 0.0);
114        let keytrack_amt = inputs.get_or(5, 0.0).clamp(0.0, 1.0);
115
116        // Coefficient derivation memoized on its driving inputs (bit-exact: the
117        // miss path below is the original computation, unchanged and in order).
118        let sample_rate = self.sample_rate;
119        let g = self.g_memo.get_or_compute(
120            [cutoff_cv, keytrack_voct, keytrack_amt, sample_rate],
121            || {
122                // Calculate base cutoff frequency
123                let base_cutoff_hz = 20.0 * Libm::<f64>::pow(1000.0, cutoff_cv.clamp(0.0, 1.0));
124
125                // Apply keyboard tracking: each octave of V/Oct doubles the cutoff
126                let keytrack_multiplier = Libm::<f64>::pow(2.0, keytrack_voct * keytrack_amt);
127                let cutoff_hz = (base_cutoff_hz * keytrack_multiplier).clamp(20.0, 20000.0);
128
129                // TPT prewarp: g = tan(π·fc/fs). Valid all the way toward Nyquist, so the
130                // cutoff stays correctly tuned across the whole advertised range. Clamp fc
131                // just below Nyquist (0.49·fs) so tan() never blows up near π/2.
132                let max_fc = 0.49 * sample_rate;
133                let fc = Libm::<f64>::fmin(cutoff_hz, max_fc);
134                Libm::<f64>::tan(PI * fc / sample_rate)
135            },
136        );
137
138        // Damping k = 1/Q, parameterized as k = 2 - 2·res (res 0 → k=2 / Q=0.5,
139        // res 1 → k≈0 / near-infinite Q). Floored strictly positive so the linear
140        // core's poles stay inside the unit circle and can never diverge.
141        let k = Libm::<f64>::fmax(2.0 - 2.0 * res, SVF_K_MIN);
142
143        // Zero-delay-feedback (Cytomic) coefficients.
144        let a1 = 1.0 / (1.0 + g * (g + k));
145        let a2 = g * a1;
146        let a3 = g * a2;
147
148        // Resolve the loop for this sample (no unit delay in the feedback path).
149        let v0 = input;
150        let v3 = v0 - self.ic2eq;
151        let v1 = a1 * self.ic1eq + a2 * v3;
152        let v2 = self.ic2eq + a2 * self.ic1eq + a3 * v3;
153
154        // Trapezoidal integrator update s = 2·v - s_old, with the bounded
155        // nonlinearity keeping self-oscillation and hard drive finite, and
156        // denormal flushing to dodge CPU denormal penalties.
157        self.ic1eq = flush_denorm(svf_soft_clip(2.0 * v1 - self.ic1eq));
158        self.ic2eq = flush_denorm(svf_soft_clip(2.0 * v2 - self.ic2eq));
159
160        let low = v2;
161        let band = v1;
162        let high = v0 - k * v1 - v2;
163        let notch = low + high; // = v0 - k·v1
164
165        outputs.set(10, low); // LP
166        outputs.set(11, band); // BP
167        outputs.set(12, high); // HP
168        outputs.set(13, notch); // Notch
169    }
170
171    fn reset(&mut self) {
172        self.ic1eq = 0.0;
173        self.ic2eq = 0.0;
174    }
175
176    fn set_sample_rate(&mut self, sample_rate: f64) {
177        self.sample_rate = sample_rate;
178    }
179
180    fn type_id(&self) -> &'static str {
181        "svf"
182    }
183}
184
185/// Diode Ladder Filter
186///
187/// A 24dB/oct (4-pole) lowpass filter modeled after the classic TB-303 / Moog
188/// diode ladder topology. Features:
189/// - Characteristic "squelchy" resonance
190/// - Keyboard tracking
191/// - Self-oscillation at high resonance
192/// - Non-linear diode saturation at each stage
193///
194/// This is a Phase 3 addition.
195pub struct DiodeLadderFilter {
196    /// Filter stages (4 poles)
197    stages: [f64; 4],
198    /// Feedback path
199    feedback: f64,
200    /// Sample rate
201    sample_rate: f64,
202    /// Memoized ZDF one-pole gain `big_g = g/(1+g)`: the pow/pow/tan derivation
203    /// only changes when the driving CVs (or sample rate) change.
204    big_g_memo: Memo<4, f64>,
205    /// Port specification
206    spec: PortSpec,
207}
208
209impl DiodeLadderFilter {
210    pub fn new(sample_rate: f64) -> Self {
211        Self {
212            stages: [0.0; 4],
213            feedback: 0.0,
214            sample_rate,
215            big_g_memo: Memo::new(0.0),
216            spec: PortSpec {
217                inputs: vec![
218                    PortDef::new(0, "in", SignalKind::Audio),
219                    PortDef::new(1, "cutoff", SignalKind::CvUnipolar)
220                        .with_default(0.5)
221                        .with_attenuverter(),
222                    PortDef::new(2, "res", SignalKind::CvUnipolar)
223                        .with_default(0.0)
224                        .with_attenuverter(),
225                    PortDef::new(3, "fm", SignalKind::CvBipolar).with_attenuverter(),
226                    PortDef::new(4, "keytrack", SignalKind::VoltPerOctave),
227                    PortDef::new(5, "keytrack_amt", SignalKind::CvUnipolar).with_default(0.0),
228                    PortDef::new(6, "drive", SignalKind::CvUnipolar)
229                        .with_default(0.0)
230                        .with_attenuverter(),
231                ],
232                outputs: vec![
233                    PortDef::new(10, "out", SignalKind::Audio),
234                    PortDef::new(11, "pole1", SignalKind::Audio), // 6dB/oct
235                    PortDef::new(12, "pole2", SignalKind::Audio), // 12dB/oct
236                    PortDef::new(13, "pole3", SignalKind::Audio), // 18dB/oct
237                ],
238            },
239        }
240    }
241
242    /// Diode saturation curve - asymmetric soft clipping
243    #[inline]
244    fn diode_sat(x: f64) -> f64 {
245        // Asymmetric tanh-like saturation mimicking diode behavior
246        if x >= 0.0 {
247            Libm::<f64>::tanh(x * 1.2)
248        } else {
249            Libm::<f64>::tanh(x * 0.8)
250        }
251    }
252
253    /// Run the 4-stage saturated one-pole cascade once for input `u` (volts)
254    /// against the current stage states, using the true TPT one-pole update.
255    ///
256    /// `big_g = g/(1+g)` is the ZDF integrator gain. Returns the four stage
257    /// outputs `y` and the updated states `new_s` (`= 2·y - s_old`, giving the
258    /// bilinear pole `(1-g)/(1+g)`), without mutating `self`. Keeping this pure
259    /// lets the resonance feedback be resolved within the sample by evaluating
260    /// the cascade a few times before committing the state.
261    #[inline]
262    fn run_cascade(u: f64, s: &[f64; 4], big_g: f64) -> ([f64; 4], [f64; 4]) {
263        let mut y = [0.0f64; 4];
264        let mut new_s = [0.0f64; 4];
265        // Drive into the first stage through the diode nonlinearity (±5 V scale).
266        let mut x = Self::diode_sat(u / 5.0) * 5.0;
267        for i in 0..4 {
268            let v = (x - s[i]) * big_g; // v = (x - s)·g/(1+g)
269            let yi = v + s[i]; // TPT output
270            y[i] = yi;
271            new_s[i] = yi + v; // = 2·y - s_old  (bilinear pole)
272                               // Inter-stage diode saturation feeds the next pole.
273            x = Self::diode_sat(yi / 5.0) * 5.0;
274        }
275        (y, new_s)
276    }
277}
278
279impl Default for DiodeLadderFilter {
280    fn default() -> Self {
281        Self::new(44100.0)
282    }
283}
284
285impl GraphModule for DiodeLadderFilter {
286    fn port_spec(&self) -> &PortSpec {
287        &self.spec
288    }
289
290    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
291        // Q160: sanitize so a non-finite input can never poison the ladder
292        // feedback stages (which would otherwise latch NaN forever).
293        let input = sanitize_audio(inputs.get_or(0, 0.0));
294        let cutoff_cv = inputs.get_or(1, 0.5) + inputs.get_or(3, 0.0);
295        let res = inputs.get_or(2, 0.0).clamp(0.0, 1.0);
296        let keytrack_voct = inputs.get_or(4, 0.0);
297        let keytrack_amt = inputs.get_or(5, 0.0).clamp(0.0, 1.0);
298        let drive = inputs.get_or(6, 0.0).clamp(0.0, 1.0);
299
300        // Coefficient derivation memoized on its driving inputs (bit-exact: the
301        // miss path below is the original computation, unchanged and in order).
302        let sample_rate = self.sample_rate;
303        let big_g = self.big_g_memo.get_or_compute(
304            [cutoff_cv, keytrack_voct, keytrack_amt, sample_rate],
305            || {
306                // Calculate base cutoff frequency (20 Hz - 20 kHz)
307                let base_cutoff_hz = 20.0 * Libm::<f64>::pow(1000.0, cutoff_cv.clamp(0.0, 1.0));
308
309                // Apply keyboard tracking
310                let keytrack_multiplier = Libm::<f64>::pow(2.0, keytrack_voct * keytrack_amt);
311                let cutoff_hz = (base_cutoff_hz * keytrack_multiplier).clamp(20.0, 20000.0);
312
313                // TPT prewarp: g = tan(π·fc/fs); big_g = g/(1+g) is the ZDF one-pole gain.
314                // Clamp fc just below Nyquist so tan() stays well-conditioned.
315                let max_fc = 0.49 * sample_rate;
316                let fc = Libm::<f64>::fmin(cutoff_hz, max_fc);
317                let wc = PI * fc / sample_rate;
318                let g = Libm::<f64>::tan(wc);
319                g / (1.0 + g)
320            },
321        );
322
323        // Resonance with self-oscillation capability
324        // k = 4 for self-oscillation in 4-pole ladder
325        let k = res * 4.0;
326
327        // Drive amount for input saturation
328        let drive_gain = 1.0 + drive * 3.0;
329
330        // Apply input drive
331        let input_driven = Self::diode_sat(input / 5.0 * drive_gain) * 5.0;
332
333        // Resolve the resonance feedback *within* this sample (Q012). The global
334        // k·output term makes the cascade an implicit system; rather than reading
335        // the previous sample's output (a full unit delay that detunes resonance
336        // and self-oscillation pitch), we approximate the zero-delay solution with
337        // a short fixed-point iteration. Two passes over the (nonlinear) cascade
338        // with the stage states held fixed get the feedback estimate close to the
339        // converged value; the diode saturation on the feedback keeps it bounded,
340        // so it is stable even at maximum resonance. Documented as a 2-iteration
341        // fixed-point approximation of the true ZDF ladder solve.
342        let mut fb_norm = self.feedback; // start from last sample's stage-4 output
343        for _ in 0..2 {
344            let fb = Self::diode_sat(fb_norm * k);
345            let u = input_driven - fb * 5.0;
346            let (y, _) = Self::run_cascade(u, &self.stages, big_g);
347            fb_norm = y[3] / 5.0;
348        }
349
350        // Final pass with the converged feedback; this one commits the state.
351        let fb = Self::diode_sat(fb_norm * k);
352        let u = input_driven - fb * 5.0;
353        let (y, new_s) = Self::run_cascade(u, &self.stages, big_g);
354
355        // Commit state with denormal flushing (Q011/Q012 stability).
356        self.stages[0] = flush_denorm(new_s[0]);
357        self.stages[1] = flush_denorm(new_s[1]);
358        self.stages[2] = flush_denorm(new_s[2]);
359        self.stages[3] = flush_denorm(new_s[3]);
360        self.feedback = flush_denorm(y[3] / 5.0);
361
362        // Outputs (all normalized to ±5V range)
363        outputs.set(10, y[3]); // 24dB/oct (main output)
364        outputs.set(11, y[0]); // 6dB/oct
365        outputs.set(12, y[1]); // 12dB/oct
366        outputs.set(13, y[2]); // 18dB/oct
367    }
368
369    fn reset(&mut self) {
370        self.stages = [0.0; 4];
371        self.feedback = 0.0;
372    }
373
374    fn set_sample_rate(&mut self, sample_rate: f64) {
375        self.sample_rate = sample_rate;
376    }
377
378    fn type_id(&self) -> &'static str {
379        "diode_ladder"
380    }
381}
382
383/// 3-Band Parametric Equalizer
384///
385/// A flexible tone-shaping EQ with:
386/// - Low shelf (50-500 Hz)
387/// - Parametric mid with adjustable Q (200 Hz - 8 kHz)
388/// - High shelf (2-12 kHz)
389///
390/// Each band has ±12dB gain range. Uses biquad filters in
391/// Transposed Direct Form II for numerical stability.
392pub struct ParametricEq {
393    // Biquad state for each band (z1, z2)
394    low_state: [f64; 2],
395    mid_state: [f64; 2],
396    high_state: [f64; 2],
397    // Cached biquad coefficients [b0, b1, b2, a1, a2] per band (Q109). The three
398    // coefficient sets are pow/cos/sin/sqrt-heavy; caching lets the tick path
399    // reuse them and recompute only the band whose parameters actually changed.
400    low_coefs: [f64; 5],
401    mid_coefs: [f64; 5],
402    high_coefs: [f64; 5],
403    // Last-seen resolved parameters that determine each band's coefficients.
404    // Seeded with NaN so the first tick always recomputes (NaN != anything).
405    cached_low: [f64; 2],  // [low_freq, low_gain_db]
406    cached_mid: [f64; 3],  // [mid_freq, mid_gain_db, mid_q]
407    cached_high: [f64; 2], // [high_freq, high_gain_db]
408    /// Number of per-band coefficient recomputes performed (diagnostics/tests).
409    recompute_count: u64,
410    sample_rate: f64,
411    spec: PortSpec,
412}
413
414impl ParametricEq {
415    pub fn new(sample_rate: f64) -> Self {
416        Self {
417            low_state: [0.0; 2],
418            mid_state: [0.0; 2],
419            high_state: [0.0; 2],
420            low_coefs: [0.0; 5],
421            mid_coefs: [0.0; 5],
422            high_coefs: [0.0; 5],
423            cached_low: [f64::NAN; 2],
424            cached_mid: [f64::NAN; 3],
425            cached_high: [f64::NAN; 2],
426            recompute_count: 0,
427            sample_rate,
428            spec: PortSpec {
429                inputs: vec![
430                    PortDef::new(0, "in", SignalKind::Audio),
431                    PortDef::new(1, "low_gain", SignalKind::CvBipolar)
432                        .with_default(0.0)
433                        .with_attenuverter(),
434                    PortDef::new(2, "low_freq", SignalKind::CvUnipolar)
435                        .with_default(0.2)
436                        .with_attenuverter(),
437                    PortDef::new(3, "mid_gain", SignalKind::CvBipolar)
438                        .with_default(0.0)
439                        .with_attenuverter(),
440                    PortDef::new(4, "mid_freq", SignalKind::CvUnipolar)
441                        .with_default(0.5)
442                        .with_attenuverter(),
443                    PortDef::new(5, "mid_q", SignalKind::CvUnipolar)
444                        .with_default(0.5)
445                        .with_attenuverter(),
446                    PortDef::new(6, "high_gain", SignalKind::CvBipolar)
447                        .with_default(0.0)
448                        .with_attenuverter(),
449                    PortDef::new(7, "high_freq", SignalKind::CvUnipolar)
450                        .with_default(0.7)
451                        .with_attenuverter(),
452                ],
453                outputs: vec![PortDef::new(10, "out", SignalKind::Audio)],
454            },
455        }
456    }
457
458    /// Calculate low shelf biquad coefficients
459    /// Returns [b0, b1, b2, a1, a2] normalized
460    fn calc_low_shelf(freq: f64, gain_db: f64, sample_rate: f64) -> [f64; 5] {
461        let a = Libm::<f64>::pow(10.0, gain_db / 40.0);
462        let w0 = TAU * freq / sample_rate;
463        let cos_w0 = Libm::<f64>::cos(w0);
464        let sin_w0 = Libm::<f64>::sin(w0);
465        let alpha = sin_w0 / 2.0 * Libm::<f64>::sqrt(2.0);
466        let sqrt_a = Libm::<f64>::sqrt(a);
467
468        let a0 = (a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
469        let b0 = a * ((a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
470        let b1 = 2.0 * a * ((a - 1.0) - (a + 1.0) * cos_w0);
471        let b2 = a * ((a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
472        let a1 = -2.0 * ((a - 1.0) + (a + 1.0) * cos_w0);
473        let a2 = (a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
474
475        [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0]
476    }
477
478    /// Calculate high shelf biquad coefficients
479    fn calc_high_shelf(freq: f64, gain_db: f64, sample_rate: f64) -> [f64; 5] {
480        let a = Libm::<f64>::pow(10.0, gain_db / 40.0);
481        let w0 = TAU * freq / sample_rate;
482        let cos_w0 = Libm::<f64>::cos(w0);
483        let sin_w0 = Libm::<f64>::sin(w0);
484        let alpha = sin_w0 / 2.0 * Libm::<f64>::sqrt(2.0);
485        let sqrt_a = Libm::<f64>::sqrt(a);
486
487        let a0 = (a + 1.0) - (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha;
488        let b0 = a * ((a + 1.0) + (a - 1.0) * cos_w0 + 2.0 * sqrt_a * alpha);
489        let b1 = -2.0 * a * ((a - 1.0) + (a + 1.0) * cos_w0);
490        let b2 = a * ((a + 1.0) + (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha);
491        let a1 = 2.0 * ((a - 1.0) - (a + 1.0) * cos_w0);
492        let a2 = (a + 1.0) - (a - 1.0) * cos_w0 - 2.0 * sqrt_a * alpha;
493
494        [b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0]
495    }
496
497    /// Calculate peaking EQ biquad coefficients
498    fn calc_peaking(freq: f64, gain_db: f64, q: f64, sample_rate: f64) -> [f64; 5] {
499        let a = Libm::<f64>::pow(10.0, gain_db / 40.0);
500        let w0 = TAU * freq / sample_rate;
501        let cos_w0 = Libm::<f64>::cos(w0);
502        let sin_w0 = Libm::<f64>::sin(w0);
503        let alpha = sin_w0 / (2.0 * q);
504
505        let a0 = 1.0 + alpha / a;
506        let b0 = (1.0 + alpha * a) / a0;
507        let b1 = (-2.0 * cos_w0) / a0;
508        let b2 = (1.0 - alpha * a) / a0;
509        let a1 = (-2.0 * cos_w0) / a0;
510        let a2 = (1.0 - alpha / a) / a0;
511
512        [b0, b1, b2, a1, a2]
513    }
514
515    /// Process a sample through a biquad filter (Transposed Direct Form II)
516    #[inline]
517    fn process_biquad(input: f64, coefs: &[f64; 5], state: &mut [f64; 2]) -> f64 {
518        let output = coefs[0] * input + state[0];
519        state[0] = coefs[1] * input - coefs[3] * output + state[1];
520        state[1] = coefs[2] * input - coefs[4] * output;
521        output
522    }
523}
524
525impl Default for ParametricEq {
526    fn default() -> Self {
527        Self::new(44100.0)
528    }
529}
530
531impl GraphModule for ParametricEq {
532    fn port_spec(&self) -> &PortSpec {
533        &self.spec
534    }
535
536    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
537        // Q160: sanitize the audio input so a non-finite sample can never latch
538        // the recursive biquad state to NaN/Inf permanently (matching Svf and
539        // DiodeLadderFilter).
540        let input = sanitize_audio(inputs.get_or(0, 0.0));
541
542        // Map CV to parameters
543        // Gain: bipolar CV ±5V maps to ±12dB
544        let low_gain_db = (inputs.get_or(1, 0.0) / 5.0) * 12.0;
545        let mid_gain_db = (inputs.get_or(3, 0.0) / 5.0) * 12.0;
546        let high_gain_db = (inputs.get_or(6, 0.0) / 5.0) * 12.0;
547
548        // Frequencies (exponential mapping)
549        let low_freq_cv = inputs.get_or(2, 0.2).clamp(0.0, 1.0);
550        let low_freq = 50.0 * Libm::<f64>::pow(10.0, low_freq_cv); // 50-500 Hz
551
552        let mid_freq_cv = inputs.get_or(4, 0.5).clamp(0.0, 1.0);
553        let mid_freq = 200.0 * Libm::<f64>::pow(40.0, mid_freq_cv); // 200 Hz - 8 kHz
554
555        let high_freq_cv = inputs.get_or(7, 0.7).clamp(0.0, 1.0);
556        let high_freq = 2000.0 + high_freq_cv * 10000.0; // 2-12 kHz
557
558        // Mid Q: 0.5 to 10
559        let mid_q_cv = inputs.get_or(5, 0.5).clamp(0.0, 1.0);
560        let mid_q = 0.5 + mid_q_cv * 9.5;
561
562        // Clamp frequencies to Nyquist
563        let nyquist = self.sample_rate * 0.45;
564        let low_freq = low_freq.clamp(20.0, nyquist);
565        let mid_freq = mid_freq.clamp(20.0, nyquist);
566        let high_freq = high_freq.clamp(20.0, nyquist);
567
568        // Recompute biquad coefficients only when a band's parameters actually
569        // change (Q109). Each calc_* is pow/cos/sin/sqrt-heavy; with static params
570        // this skips all of it and just runs the three process_biquad calls. The
571        // reused coefficients are bit-identical to recomputing them, so the output
572        // is unchanged.
573        let low_params = [low_freq, low_gain_db];
574        if self.cached_low != low_params {
575            self.low_coefs = Self::calc_low_shelf(low_freq, low_gain_db, self.sample_rate);
576            self.cached_low = low_params;
577            self.recompute_count += 1;
578        }
579        let mid_params = [mid_freq, mid_gain_db, mid_q];
580        if self.cached_mid != mid_params {
581            self.mid_coefs = Self::calc_peaking(mid_freq, mid_gain_db, mid_q, self.sample_rate);
582            self.cached_mid = mid_params;
583            self.recompute_count += 1;
584        }
585        let high_params = [high_freq, high_gain_db];
586        if self.cached_high != high_params {
587            self.high_coefs = Self::calc_high_shelf(high_freq, high_gain_db, self.sample_rate);
588            self.cached_high = high_params;
589            self.recompute_count += 1;
590        }
591
592        // Process through the cascade
593        let mut signal = input;
594        signal = Self::process_biquad(signal, &self.low_coefs, &mut self.low_state);
595        signal = Self::process_biquad(signal, &self.mid_coefs, &mut self.mid_state);
596        signal = Self::process_biquad(signal, &self.high_coefs, &mut self.high_state);
597
598        outputs.set(10, signal);
599    }
600
601    fn reset(&mut self) {
602        self.low_state = [0.0; 2];
603        self.mid_state = [0.0; 2];
604        self.high_state = [0.0; 2];
605    }
606
607    fn set_sample_rate(&mut self, sample_rate: f64) {
608        self.sample_rate = sample_rate;
609        // Coefficients depend on sample_rate; invalidate the cache so the next
610        // tick recomputes them for the new rate even if freq/gain/Q are unchanged.
611        self.cached_low = [f64::NAN; 2];
612        self.cached_mid = [f64::NAN; 3];
613        self.cached_high = [f64::NAN; 2];
614        self.reset();
615    }
616
617    fn type_id(&self) -> &'static str {
618        "parametric_eq"
619    }
620}
621
622#[cfg(test)]
623mod tests {
624    use super::*;
625    use crate::modules::common::{measure_max_output, SAFE_AUDIO_LIMIT};
626
627    #[test]
628    fn test_svf_filter() {
629        let mut svf = Svf::new(44100.0);
630        let mut inputs = PortValues::new();
631        let mut outputs = PortValues::new();
632
633        // Low cutoff should attenuate high frequencies
634        inputs.set(0, 5.0); // Input signal
635        inputs.set(1, 0.1); // Low cutoff
636
637        svf.tick(&inputs, &mut outputs);
638
639        // LP output should exist
640        assert!(outputs.get(10).is_some());
641    }
642    #[test]
643    fn test_svf_default_reset_sample_rate() {
644        let mut svf = Svf::default();
645        assert!(svf.sample_rate == 44100.0);
646
647        svf.set_sample_rate(48000.0);
648        assert!(svf.sample_rate == 48000.0);
649
650        let mut inputs = PortValues::new();
651        let mut outputs = PortValues::new();
652        inputs.set(0, 1.0);
653        for _ in 0..100 {
654            svf.tick(&inputs, &mut outputs);
655        }
656
657        svf.reset();
658        // Field renamed from `low` to the TPT integrator state `ic1eq` in the
659        // Zavalishin SVF rewrite; reset must still clear it to zero.
660        assert!(svf.ic1eq == 0.0);
661
662        assert_eq!(svf.type_id(), "svf");
663    }
664    #[test]
665    fn test_diode_ladder_filter_coverage() {
666        use crate::{Crosstalk, DiodeLadderFilter, GroundLoop};
667
668        // DiodeLadderFilter
669        let mut dlf = DiodeLadderFilter::default();
670        assert!(dlf.sample_rate == 44100.0);
671
672        dlf.set_sample_rate(48000.0);
673        assert!(dlf.sample_rate == 48000.0);
674
675        let mut inputs = PortValues::new();
676        let mut outputs = PortValues::new();
677        inputs.set(0, 1.0);
678        for _ in 0..100 {
679            dlf.tick(&inputs, &mut outputs);
680        }
681
682        dlf.reset();
683        assert!(dlf.stages[0] == 0.0);
684
685        assert_eq!(dlf.type_id(), "diode_ladder");
686
687        // Crosstalk
688        let mut crosstalk = Crosstalk::default();
689        crosstalk.set_sample_rate(48000.0);
690        inputs.set(0, 1.0);
691        inputs.set(1, 2.0);
692        crosstalk.tick(&inputs, &mut outputs);
693        crosstalk.reset();
694        assert_eq!(crosstalk.type_id(), "crosstalk");
695
696        // GroundLoop
697        let mut gl = GroundLoop::default();
698        gl.set_sample_rate(48000.0);
699        gl.tick(&inputs, &mut outputs);
700        gl.reset();
701        assert_eq!(gl.type_id(), "ground_loop");
702    }
703    #[test]
704    fn test_parametric_eq_passthrough() {
705        let mut eq = ParametricEq::new(44100.0);
706        let mut inputs = PortValues::new();
707        let mut outputs = PortValues::new();
708
709        // With 0 gain on all bands, signal should pass through unchanged
710        inputs.set(0, 1.0); // Input signal
711        inputs.set(1, 0.0); // Low gain = 0dB
712        inputs.set(3, 0.0); // Mid gain = 0dB
713        inputs.set(6, 0.0); // High gain = 0dB
714
715        // Process several samples to reach steady state
716        for _ in 0..1000 {
717            eq.tick(&inputs, &mut outputs);
718        }
719
720        let out = outputs.get(10).unwrap();
721        // Should be approximately 1.0 (input) after settling
722        assert!((out - 1.0).abs() < 0.01);
723    }
724
725    #[test]
726    fn test_parametric_eq_nan_recovery() {
727        // Q160: a non-finite input must not permanently latch the recursive
728        // biquad state to NaN. After poisoning, a clean signal must recover.
729        let mut eq = ParametricEq::new(44100.0);
730        let mut inputs = PortValues::new();
731        let mut outputs = PortValues::new();
732        inputs.set(1, 0.0);
733        inputs.set(3, 0.0);
734        inputs.set(6, 0.0);
735
736        for &bad in &[f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
737            inputs.set(0, bad);
738            eq.tick(&inputs, &mut outputs);
739        }
740
741        // Feed a clean signal; the cascade must return to finite output.
742        inputs.set(0, 0.5);
743        let mut last = 0.0;
744        for _ in 0..2000 {
745            eq.tick(&inputs, &mut outputs);
746            last = outputs.get(10).unwrap();
747        }
748        assert!(
749            last.is_finite(),
750            "ParametricEq output stayed non-finite after a NaN input: {last}"
751        );
752    }
753
754    #[test]
755    fn test_parametric_eq_low_boost() {
756        let mut eq = ParametricEq::new(44100.0);
757        let mut inputs = PortValues::new();
758        let mut outputs = PortValues::new();
759
760        // Boost low frequencies by 12dB (+5V)
761        inputs.set(0, 1.0);
762        inputs.set(1, 5.0); // +12dB low gain
763        inputs.set(2, 0.0); // Low frequency at minimum (50 Hz)
764
765        for _ in 0..1000 {
766            eq.tick(&inputs, &mut outputs);
767        }
768
769        let out = outputs.get(10).unwrap();
770        // With boosted lows, DC-like signal should be amplified
771        assert!(out > 1.0);
772        assert!(out.is_finite());
773    }
774    #[test]
775    fn test_parametric_eq_mid_cut() {
776        let mut eq = ParametricEq::new(44100.0);
777        let mut inputs = PortValues::new();
778        let mut outputs = PortValues::new();
779
780        // Cut mid frequencies
781        inputs.set(0, 1.0);
782        inputs.set(3, -5.0); // -12dB mid gain
783        inputs.set(5, 1.0); // High Q for narrow cut
784
785        for _ in 0..1000 {
786            eq.tick(&inputs, &mut outputs);
787        }
788
789        let out = outputs.get(10).unwrap();
790        assert!(out.is_finite());
791    }
792    #[test]
793    fn test_parametric_eq_high_boost() {
794        let mut eq = ParametricEq::new(44100.0);
795        let mut inputs = PortValues::new();
796        let mut outputs = PortValues::new();
797
798        inputs.set(0, 1.0);
799        inputs.set(6, 5.0); // +12dB high gain
800
801        for _ in 0..1000 {
802            eq.tick(&inputs, &mut outputs);
803        }
804
805        let out = outputs.get(10).unwrap();
806        assert!(out.is_finite());
807    }
808    #[test]
809    fn test_parametric_eq_default_reset_sample_rate() {
810        let mut eq = ParametricEq::default();
811        assert!(eq.sample_rate == 44100.0);
812
813        // Process some samples with non-zero gain (0dB passthrough keeps state at zero)
814        let mut inputs = PortValues::new();
815        let mut outputs = PortValues::new();
816        inputs.set(0, 1.0);
817        inputs.set(1, 2.5); // +6dB low gain (bipolar CV)
818        for _ in 0..100 {
819            eq.tick(&inputs, &mut outputs);
820        }
821
822        // Verify state is non-zero (filter is active with non-zero gain)
823        assert!(eq.low_state[0] != 0.0 || eq.low_state[1] != 0.0);
824
825        // Reset should clear state
826        eq.reset();
827        assert_eq!(eq.low_state, [0.0; 2]);
828        assert_eq!(eq.mid_state, [0.0; 2]);
829        assert_eq!(eq.high_state, [0.0; 2]);
830
831        // Set sample rate
832        eq.set_sample_rate(48000.0);
833        assert_eq!(eq.sample_rate, 48000.0);
834
835        assert_eq!(eq.type_id(), "parametric_eq");
836        assert_eq!(eq.port_spec().inputs.len(), 8);
837        assert_eq!(eq.port_spec().outputs.len(), 1);
838    }
839    #[test]
840    fn test_parametric_eq_frequency_ranges() {
841        let mut eq = ParametricEq::new(44100.0);
842        let mut inputs = PortValues::new();
843        let mut outputs = PortValues::new();
844
845        // Test with extreme frequency settings
846        inputs.set(0, 1.0);
847        inputs.set(2, 0.0); // Min low freq (50 Hz)
848        inputs.set(4, 0.0); // Min mid freq (200 Hz)
849        inputs.set(7, 0.0); // Min high freq (2 kHz)
850
851        for _ in 0..100 {
852            eq.tick(&inputs, &mut outputs);
853        }
854        assert!(outputs.get(10).unwrap().is_finite());
855
856        eq.reset();
857        inputs.set(2, 1.0); // Max low freq (500 Hz)
858        inputs.set(4, 1.0); // Max mid freq (8 kHz)
859        inputs.set(7, 1.0); // Max high freq (12 kHz)
860
861        for _ in 0..100 {
862            eq.tick(&inputs, &mut outputs);
863        }
864        assert!(outputs.get(10).unwrap().is_finite());
865    }
866    #[test]
867    fn test_parametric_eq_stability() {
868        let mut eq = ParametricEq::new(44100.0);
869        let mut inputs = PortValues::new();
870        let mut outputs = PortValues::new();
871
872        // Test with impulse input
873        inputs.set(0, 5.0); // Strong impulse
874        inputs.set(1, 5.0); // Extreme gain settings
875        inputs.set(3, 5.0);
876        inputs.set(6, 5.0);
877        inputs.set(5, 1.0); // High Q
878
879        eq.tick(&inputs, &mut outputs);
880
881        // Continue with zero input
882        inputs.set(0, 0.0);
883        for _ in 0..10000 {
884            eq.tick(&inputs, &mut outputs);
885        }
886
887        // Should decay to near zero, not blow up
888        let out = outputs.get(10).unwrap();
889        assert!(out.is_finite());
890        assert!(out.abs() < 0.01);
891    }
892    #[test]
893    fn test_svf_high_resonance_bounded() {
894        // Test that SVF outputs stay bounded at various high resonance values
895        // This catches the gap between 0.8-0.95 where no clipping was applied
896        let test_resonances = [0.8, 0.85, 0.9, 0.92, 0.94, 0.96, 0.98, 1.0];
897
898        for &res in &test_resonances {
899            let mut svf = Svf::new(44100.0);
900            let mut inputs = PortValues::new();
901            let mut outputs = PortValues::new();
902
903            inputs.set(0, 5.0); // Full scale input
904            inputs.set(1, 0.5); // Mid cutoff
905            inputs.set(2, res); // Resonance
906
907            let max = measure_max_output(10000, || {
908                svf.tick(&inputs, &mut outputs);
909                // Check all outputs: LP, BP, HP, Notch
910                let lp = outputs.get(10).unwrap_or(0.0).abs();
911                let bp = outputs.get(11).unwrap_or(0.0).abs();
912                let hp = outputs.get(12).unwrap_or(0.0).abs();
913                let notch = outputs.get(13).unwrap_or(0.0).abs();
914                lp.max(bp).max(hp).max(notch)
915            });
916
917            assert!(
918                max <= SAFE_AUDIO_LIMIT,
919                "SVF output {} exceeds safe limit {} at resonance {}",
920                max,
921                SAFE_AUDIO_LIMIT,
922                res
923            );
924        }
925    }
926    #[test]
927    fn test_svf_low_cutoff_transient_bounded() {
928        // Low cutoff + high resonance + step input = potential for ringing
929        let mut svf = Svf::new(44100.0);
930        let mut inputs = PortValues::new();
931        let mut outputs = PortValues::new();
932
933        // Very low cutoff (20Hz range)
934        inputs.set(1, 0.0); // Minimum cutoff CV
935        inputs.set(2, 0.9); // High resonance
936
937        // Step input from 0 to 5V
938        inputs.set(0, 0.0);
939        for _ in 0..100 {
940            svf.tick(&inputs, &mut outputs);
941        }
942
943        inputs.set(0, 5.0); // Step!
944        let max = measure_max_output(5000, || {
945            svf.tick(&inputs, &mut outputs);
946            outputs.get(10).unwrap_or(0.0).abs()
947        });
948
949        assert!(
950            max <= SAFE_AUDIO_LIMIT,
951            "SVF transient response {} exceeds safe limit {} at low cutoff",
952            max,
953            SAFE_AUDIO_LIMIT
954        );
955    }
956    #[test]
957    fn test_svf_self_oscillation_bounded() {
958        // Self-oscillation mode should produce bounded output
959        let mut svf = Svf::new(44100.0);
960        let mut inputs = PortValues::new();
961        let mut outputs = PortValues::new();
962
963        inputs.set(0, 0.0); // No input - pure self-oscillation
964        inputs.set(1, 0.5); // Mid cutoff
965        inputs.set(2, 1.0); // Maximum resonance
966
967        // Kick-start oscillation with a brief impulse
968        inputs.set(0, 1.0);
969        svf.tick(&inputs, &mut outputs);
970        inputs.set(0, 0.0);
971
972        // Let it oscillate for a while
973        let max = measure_max_output(20000, || {
974            svf.tick(&inputs, &mut outputs);
975            outputs.get(10).unwrap_or(0.0).abs()
976        });
977
978        assert!(
979            max <= SAFE_AUDIO_LIMIT,
980            "SVF self-oscillation {} exceeds safe limit {}",
981            max,
982            SAFE_AUDIO_LIMIT
983        );
984    }
985    #[test]
986    fn test_svf_extreme_input_bounded() {
987        // Even with garbage input (20V), output should be bounded
988        let mut svf = Svf::new(44100.0);
989        let mut inputs = PortValues::new();
990        let mut outputs = PortValues::new();
991
992        inputs.set(0, 20.0); // Way over nominal!
993        inputs.set(1, 0.5);
994        inputs.set(2, 0.9);
995
996        let max = measure_max_output(1000, || {
997            svf.tick(&inputs, &mut outputs);
998            outputs.get(10).unwrap_or(0.0).abs()
999        });
1000
1001        assert!(
1002            max <= SAFE_AUDIO_LIMIT * 2.0, // Allow 2x for extreme input
1003            "SVF with extreme input {} exceeds limit {}",
1004            max,
1005            SAFE_AUDIO_LIMIT * 2.0
1006        );
1007    }
1008    #[test]
1009    fn test_diode_ladder_high_resonance_bounded() {
1010        // Diode ladder filter should also be bounded
1011        let mut filter = DiodeLadderFilter::new(44100.0);
1012        let mut inputs = PortValues::new();
1013        let mut outputs = PortValues::new();
1014
1015        inputs.set(0, 5.0); // Input
1016        inputs.set(1, 0.5); // Cutoff
1017        inputs.set(2, 1.0); // Max resonance
1018
1019        let max = measure_max_output(10000, || {
1020            filter.tick(&inputs, &mut outputs);
1021            outputs.get(10).unwrap_or(0.0).abs()
1022        });
1023
1024        assert!(
1025            max <= SAFE_AUDIO_LIMIT,
1026            "Diode ladder output {} exceeds safe limit {}",
1027            max,
1028            SAFE_AUDIO_LIMIT
1029        );
1030    }
1031
1032    // ----- Wave B remediation tests -----------------------------------------
1033
1034    /// CV that maps to a target cutoff frequency through `20 * 1000^cv`.
1035    fn cutoff_cv_for(freq_hz: f64) -> f64 {
1036        (freq_hz / 20.0).ln() / 1000.0_f64.ln()
1037    }
1038
1039    fn rms(samples: &[f64]) -> f64 {
1040        let sum_sq: f64 = samples.iter().map(|x| x * x).sum();
1041        (sum_sq / samples.len() as f64).sqrt()
1042    }
1043
1044    /// Q009: at maximum resonance with the cutoff CV maxed and a continuous
1045    /// drive, the SVF must stay finite and bounded indefinitely. The old
1046    /// Chamberlin core drove `self.low`/`self.band` to inf→NaN under exactly
1047    /// these conditions (negative damping, clip only on the output copies), so
1048    /// this test would fail on the pre-remediation code.
1049    #[test]
1050    fn test_svf_max_resonance_finite_200k() {
1051        let mut svf = Svf::new(44100.0);
1052        let mut inputs = PortValues::new();
1053        let mut outputs = PortValues::new();
1054
1055        inputs.set(0, 1.0); // continuous DC-plus-transient drive seeds the loop
1056        inputs.set(1, 1.0); // cutoff CV maxed (would freeze the old Chamberlin f)
1057        inputs.set(2, 1.0); // maximum resonance (old code: negative damping)
1058
1059        let mut max_abs = 0.0f64;
1060        for n in 0..200_000 {
1061            svf.tick(&inputs, &mut outputs);
1062            for &id in &[10u32, 11, 12, 13] {
1063                let v = outputs.get(id).unwrap();
1064                assert!(
1065                    v.is_finite(),
1066                    "SVF output {id} became non-finite at sample {n}"
1067                );
1068                max_abs = max_abs.max(v.abs());
1069            }
1070        }
1071        assert!(
1072            max_abs < 50.0,
1073            "SVF max resonance output unbounded: {max_abs}"
1074        );
1075    }
1076
1077    /// Q010: the LP -3 dB corner must land near the requested cutoff across the
1078    /// advertised range, including well above the old ~fs/6 (~7.3 kHz) freeze.
1079    /// At Butterworth damping (Q = 1/sqrt(2)) the LP magnitude at fc is exactly
1080    /// -3 dB (0.707), while the passband gain is unity, so the RMS ratio between
1081    /// a tone at fc and a tone deep in the passband should be ~0.707.
1082    #[test]
1083    fn test_svf_cutoff_accuracy() {
1084        let sample_rate = 44100.0;
1085        // res giving k = 2 - 2*res = sqrt(2)  => Butterworth Q = 1/sqrt(2).
1086        let res = (2.0 - core::f64::consts::SQRT_2) / 2.0;
1087
1088        for &target_fc in &[1000.0_f64, 10_000.0_f64] {
1089            let cv = cutoff_cv_for(target_fc);
1090
1091            let measure = |freq: f64| -> f64 {
1092                let mut svf = Svf::new(sample_rate);
1093                let mut inputs = PortValues::new();
1094                let mut outputs = PortValues::new();
1095                inputs.set(1, cv);
1096                inputs.set(2, res);
1097                let mut out = alloc::vec::Vec::new();
1098                let dt = freq / sample_rate;
1099                let mut phase = 0.0f64;
1100                for n in 0..40_000 {
1101                    let s = Libm::<f64>::sin(TAU * phase);
1102                    phase += dt;
1103                    if phase >= 1.0 {
1104                        phase -= 1.0;
1105                    }
1106                    inputs.set(0, s);
1107                    svf.tick(&inputs, &mut outputs);
1108                    if n >= 20_000 {
1109                        out.push(outputs.get(10).unwrap());
1110                    }
1111                }
1112                rms(&out)
1113            };
1114
1115            let passband = measure(target_fc / 8.0);
1116            let at_fc = measure(target_fc);
1117            let ratio = at_fc / passband;
1118            assert!(
1119                (ratio - core::f64::consts::FRAC_1_SQRT_2).abs() < 0.10,
1120                "SVF -3dB point off at fc={target_fc}: ratio {ratio} (expected ~0.707)"
1121            );
1122        }
1123    }
1124
1125    /// Q009/Q010: at res=1 the SVF must self-oscillate as a *sustained* bounded
1126    /// tone rather than diverging or dying out. Kick it with a single impulse
1127    /// and confirm the ring is still present (and bounded) more than one second
1128    /// later.
1129    #[test]
1130    fn test_svf_self_oscillation_sustained() {
1131        let sample_rate = 44100.0;
1132        let mut svf = Svf::new(sample_rate);
1133        let mut inputs = PortValues::new();
1134        let mut outputs = PortValues::new();
1135
1136        inputs.set(1, cutoff_cv_for(2000.0)); // ~2 kHz
1137        inputs.set(2, 1.0); // maximum resonance
1138
1139        // Single-sample impulse kick.
1140        inputs.set(0, 5.0);
1141        svf.tick(&inputs, &mut outputs);
1142        inputs.set(0, 0.0);
1143
1144        let mut window = alloc::vec::Vec::new();
1145        for n in 0..66_150 {
1146            // 1.5 s
1147            svf.tick(&inputs, &mut outputs);
1148            let v = outputs.get(11).unwrap(); // bandpass shows the oscillation
1149            assert!(v.is_finite());
1150            if n >= 44_100 {
1151                window.push(v); // measure the 1.0 s .. 1.5 s window
1152            }
1153        }
1154        let r = rms(&window);
1155        assert!(
1156            (0.01..20.0).contains(&r),
1157            "SVF self-oscillation not sustained/bounded after 1s: rms {r}"
1158        );
1159    }
1160
1161    /// Q011/Q012: the resonant peak of the ladder must sit at the set cutoff.
1162    /// The corrected TPT one-pole (Q011) tunes each stage's pole correctly, and
1163    /// resolving the resonance feedback within the sample (Q012) removes the
1164    /// unit-delay detuning, so a small-signal frequency sweep at high resonance
1165    /// peaks within ~5% of the requested 2 kHz.
1166    #[test]
1167    fn test_diode_ladder_resonance_peak_frequency() {
1168        let sample_rate = 44100.0;
1169        let target_fc = 2000.0;
1170        let cv = cutoff_cv_for(target_fc);
1171
1172        // Steady-state RMS of the main output for a small sine at `freq`.
1173        let gain_at = |freq: f64| -> f64 {
1174            let mut filter = DiodeLadderFilter::new(sample_rate);
1175            let mut inputs = PortValues::new();
1176            let mut outputs = PortValues::new();
1177            inputs.set(1, cv);
1178            inputs.set(2, 1.0); // maximum resonance -> sharp peak at cutoff
1179            let dt = freq / sample_rate;
1180            let mut phase = 0.0f64;
1181            let mut buf = alloc::vec::Vec::new();
1182            for n in 0..40_000 {
1183                let s = 0.1 * Libm::<f64>::sin(TAU * phase);
1184                phase += dt;
1185                if phase >= 1.0 {
1186                    phase -= 1.0;
1187                }
1188                inputs.set(0, s);
1189                filter.tick(&inputs, &mut outputs);
1190                if n >= 20_000 {
1191                    buf.push(outputs.get(10).unwrap());
1192                }
1193            }
1194            rms(&buf)
1195        };
1196
1197        // Uniform sweep around the target; locate the peak bin, then refine to a
1198        // sub-grid estimate with parabolic interpolation of the three points
1199        // around it (standard peak-picking; removes grid quantization bias).
1200        let spacing = 100.0;
1201        let sweep: alloc::vec::Vec<f64> = (0..13).map(|i| 1400.0 + spacing * i as f64).collect();
1202        let gains: alloc::vec::Vec<f64> = sweep.iter().map(|&f| gain_at(f)).collect();
1203        let mut peak = 1;
1204        for i in 1..gains.len() - 1 {
1205            if gains[i] > gains[peak] {
1206                peak = i;
1207            }
1208        }
1209        assert!(
1210            peak > 0 && peak < gains.len() - 1,
1211            "peak fell on sweep edge"
1212        );
1213        let (a, b, c) = (gains[peak - 1], gains[peak], gains[peak + 1]);
1214        let denom = a - 2.0 * b + c;
1215        let delta = if denom != 0.0 {
1216            0.5 * (a - c) / denom
1217        } else {
1218            0.0
1219        };
1220        let peak_f = sweep[peak] + delta * spacing;
1221        let err = (peak_f - target_fc).abs() / target_fc;
1222        assert!(
1223            err < 0.05,
1224            "Diode ladder resonant peak at {peak_f:.0} Hz, off from {target_fc} Hz by {:.1}%",
1225            err * 100.0
1226        );
1227    }
1228
1229    /// Q011/Q012: maximum resonance must remain finite and bounded over a long
1230    /// run at several cutoffs (denormal-flushed TPT states + saturated feedback).
1231    #[test]
1232    fn test_diode_ladder_max_resonance_stable_100k() {
1233        for &cv in &[0.1_f64, 0.5, 0.9] {
1234            let mut filter = DiodeLadderFilter::new(44100.0);
1235            let mut inputs = PortValues::new();
1236            let mut outputs = PortValues::new();
1237            inputs.set(0, 5.0);
1238            inputs.set(1, cv);
1239            inputs.set(2, 1.0);
1240
1241            let mut max_abs = 0.0f64;
1242            for n in 0..100_000 {
1243                filter.tick(&inputs, &mut outputs);
1244                for &id in &[10u32, 11, 12, 13] {
1245                    let v = outputs.get(id).unwrap();
1246                    assert!(v.is_finite(), "diode out {id} non-finite at {n} (cv={cv})");
1247                    max_abs = max_abs.max(v.abs());
1248                }
1249            }
1250            assert!(
1251                max_abs <= SAFE_AUDIO_LIMIT,
1252                "diode unbounded {max_abs} at cv={cv}"
1253            );
1254        }
1255    }
1256
1257    /// Q109: caching the biquad coefficients must not change the output. Compare
1258    /// the module (cached) against a reference that recomputes the coefficients
1259    /// every sample; with static params the two must be bit-identical.
1260    #[test]
1261    fn test_parametric_eq_caching_bit_identical() {
1262        let sample_rate = 44100.0;
1263        let mut eq = ParametricEq::new(sample_rate);
1264        let mut inputs = PortValues::new();
1265        let mut outputs = PortValues::new();
1266
1267        // Static (non-default) params for all three bands.
1268        inputs.set(1, 3.0); // low gain
1269        inputs.set(2, 0.4); // low freq
1270        inputs.set(3, -2.0); // mid gain
1271        inputs.set(4, 0.6); // mid freq
1272        inputs.set(5, 0.7); // mid q
1273        inputs.set(6, 4.0); // high gain
1274        inputs.set(7, 0.5); // high freq
1275
1276        // Reference: recompute coefficients every sample (pre-Q109 behaviour).
1277        let low_gain_db = (3.0 / 5.0) * 12.0;
1278        let mid_gain_db = (-2.0 / 5.0) * 12.0;
1279        let high_gain_db = (4.0 / 5.0) * 12.0;
1280        let low_freq = (50.0 * Libm::<f64>::pow(10.0, 0.4)).clamp(20.0, sample_rate * 0.45);
1281        let mid_freq = (200.0 * Libm::<f64>::pow(40.0, 0.6)).clamp(20.0, sample_rate * 0.45);
1282        let high_freq: f64 = (2000.0 + 0.5 * 10000.0_f64).clamp(20.0, sample_rate * 0.45);
1283        let mid_q = 0.5 + 0.7 * 9.5;
1284        let low_c = ParametricEq::calc_low_shelf(low_freq, low_gain_db, sample_rate);
1285        let mid_c = ParametricEq::calc_peaking(mid_freq, mid_gain_db, mid_q, sample_rate);
1286        let high_c = ParametricEq::calc_high_shelf(high_freq, high_gain_db, sample_rate);
1287        let mut ref_low = [0.0; 2];
1288        let mut ref_mid = [0.0; 2];
1289        let mut ref_high = [0.0; 2];
1290
1291        let mut phase = 0.0f64;
1292        for _ in 0..2000 {
1293            let s = Libm::<f64>::sin(TAU * phase);
1294            phase += 500.0 / sample_rate;
1295            if phase >= 1.0 {
1296                phase -= 1.0;
1297            }
1298            inputs.set(0, s);
1299            eq.tick(&inputs, &mut outputs);
1300            let got = outputs.get(10).unwrap();
1301
1302            let mut r = ParametricEq::process_biquad(s, &low_c, &mut ref_low);
1303            r = ParametricEq::process_biquad(r, &mid_c, &mut ref_mid);
1304            r = ParametricEq::process_biquad(r, &high_c, &mut ref_high);
1305
1306            assert_eq!(
1307                got.to_bits(),
1308                r.to_bits(),
1309                "cached EQ output differs from recompute"
1310            );
1311        }
1312    }
1313
1314    /// Q109: with static params the coefficients are computed once per band and
1315    /// then reused; only a genuine parameter change triggers a recompute.
1316    #[test]
1317    fn test_parametric_eq_recompute_count() {
1318        let mut eq = ParametricEq::new(44100.0);
1319        let mut inputs = PortValues::new();
1320        let mut outputs = PortValues::new();
1321        inputs.set(0, 1.0);
1322        inputs.set(1, 2.0);
1323        inputs.set(3, 1.0);
1324        inputs.set(6, -1.0);
1325
1326        for _ in 0..100 {
1327            eq.tick(&inputs, &mut outputs);
1328        }
1329        // Three bands, computed once on the first tick, reused thereafter.
1330        assert_eq!(
1331            eq.recompute_count, 3,
1332            "static params should not recompute per sample"
1333        );
1334
1335        // Change only the mid band -> exactly one additional recompute.
1336        inputs.set(3, 2.0);
1337        eq.tick(&inputs, &mut outputs);
1338        assert_eq!(
1339            eq.recompute_count, 4,
1340            "changing one band should recompute only that band"
1341        );
1342
1343        // Static again -> no further recomputes.
1344        for _ in 0..50 {
1345            eq.tick(&inputs, &mut outputs);
1346        }
1347        assert_eq!(
1348            eq.recompute_count, 4,
1349            "returning to static must not recompute"
1350        );
1351    }
1352
1353    // ---- Q158: ParametricEq real frequency response (in-band vs out-of-band) ----
1354
1355    #[test]
1356    fn test_parametric_eq_mid_band_response() {
1357        let sample_rate = 44100.0;
1358        // Mid band default CV 0.5 -> 200 * 40^0.5 Hz; drive a tone right at that
1359        // peaking-filter center and one far below it (out of band).
1360        let mid_freq = 200.0 * Libm::<f64>::pow(40.0, 0.5);
1361        let out_of_band = mid_freq / 8.0;
1362
1363        // Steady-state RMS at `tone_hz` for a given mid-gain CV (input 3).
1364        let measure = |tone_hz: f64, mid_gain_cv: f64| -> f64 {
1365            let mut eq = ParametricEq::new(sample_rate);
1366            let mut inputs = PortValues::new();
1367            let mut outputs = PortValues::new();
1368            inputs.set(3, mid_gain_cv); // mid gain (bipolar CV, ±5V -> ±12dB)
1369            inputs.set(5, 1.0); // high mid-Q (narrow) so the band is well isolated
1370            let dt = tone_hz / sample_rate;
1371            let mut phase = 0.0f64;
1372            let mut out = alloc::vec::Vec::new();
1373            for n in 0..40_000 {
1374                let s = Libm::<f64>::sin(TAU * phase);
1375                phase += dt;
1376                if phase >= 1.0 {
1377                    phase -= 1.0;
1378                }
1379                inputs.set(0, s);
1380                eq.tick(&inputs, &mut outputs);
1381                if n >= 20_000 {
1382                    out.push(outputs.get(10).unwrap());
1383                }
1384            }
1385            rms(&out)
1386        };
1387
1388        // +12 dB boost at the center: the in-band tone is amplified ~+12 dB
1389        // relative to the out-of-band tone (which sees the flat parts of the EQ).
1390        let boost_in = measure(mid_freq, 5.0);
1391        let boost_out = measure(out_of_band, 5.0);
1392        let boost_db = 20.0 * Libm::<f64>::log10(boost_in / boost_out);
1393        assert!(
1394            (9.0..=13.0).contains(&boost_db),
1395            "mid +12dB boost: expected ~12dB in-band, got {boost_db:.2}dB"
1396        );
1397
1398        // -12 dB cut at the center: the in-band tone is attenuated well below
1399        // the out-of-band tone.
1400        let cut_in = measure(mid_freq, -5.0);
1401        let cut_out = measure(out_of_band, -5.0);
1402        let cut_db = 20.0 * Libm::<f64>::log10(cut_in / cut_out);
1403        assert!(
1404            (-13.0..=-9.0).contains(&cut_db),
1405            "mid -12dB cut: expected ~-12dB in-band, got {cut_db:.2}dB"
1406        );
1407    }
1408
1409    // ---- Coefficient memoization (perf) ------------------------------------
1410
1411    /// Memoization must be observationally invisible: a filter whose memo is
1412    /// invalidated before every tick executes the pre-memoization computation
1413    /// every sample, and must agree bit-for-bit with the memoized filter over a
1414    /// long render covering both constant and per-sample-modulated parameters.
1415    #[test]
1416    fn test_svf_memo_bit_identical() {
1417        let mut memoized = Svf::new(44100.0);
1418        let mut forced = Svf::new(44100.0);
1419        let mut inputs = PortValues::new();
1420        let mut out_m = PortValues::new();
1421        let mut out_f = PortValues::new();
1422
1423        for n in 0..20_000u32 {
1424            let t = n as f64;
1425            inputs.set(0, Libm::<f64>::sin(t * 0.037) * 4.0);
1426            if n < 10_000 {
1427                // Constant parameters: the memo hits every sample.
1428                inputs.set(1, 0.6);
1429            } else {
1430                // Sweep the cutoff CV every sample: the memo misses every sample.
1431                inputs.set(1, 0.3 + 0.3 * Libm::<f64>::sin(t * 0.001));
1432            }
1433            inputs.set(2, 0.4);
1434            inputs.set(4, 0.25);
1435            inputs.set(5, 0.5);
1436
1437            memoized.tick(&inputs, &mut out_m);
1438            forced.g_memo.invalidate();
1439            forced.tick(&inputs, &mut out_f);
1440
1441            for &id in &[10u32, 11, 12, 13] {
1442                assert_eq!(
1443                    out_m.get(id).unwrap().to_bits(),
1444                    out_f.get(id).unwrap().to_bits(),
1445                    "SVF output {id} diverged at sample {n}"
1446                );
1447            }
1448        }
1449        // Sanity: the constant half must actually have been served from cache.
1450        assert!(memoized.g_memo.recompute_count() <= 10_001);
1451        assert_eq!(forced.g_memo.recompute_count(), 20_000);
1452    }
1453
1454    /// Same equivalence for the diode ladder's memoized `big_g` derivation.
1455    #[test]
1456    fn test_diode_ladder_memo_bit_identical() {
1457        let mut memoized = DiodeLadderFilter::new(44100.0);
1458        let mut forced = DiodeLadderFilter::new(44100.0);
1459        let mut inputs = PortValues::new();
1460        let mut out_m = PortValues::new();
1461        let mut out_f = PortValues::new();
1462
1463        for n in 0..10_000u32 {
1464            let t = n as f64;
1465            inputs.set(0, Libm::<f64>::sin(t * 0.041) * 4.0);
1466            if n < 5_000 {
1467                inputs.set(1, 0.5);
1468            } else {
1469                inputs.set(1, 0.4 + 0.2 * Libm::<f64>::sin(t * 0.002));
1470            }
1471            inputs.set(2, 0.8);
1472            inputs.set(6, 0.5);
1473
1474            memoized.tick(&inputs, &mut out_m);
1475            forced.big_g_memo.invalidate();
1476            forced.tick(&inputs, &mut out_f);
1477
1478            for &id in &[10u32, 11, 12, 13] {
1479                assert_eq!(
1480                    out_m.get(id).unwrap().to_bits(),
1481                    out_f.get(id).unwrap().to_bits(),
1482                    "diode ladder output {id} diverged at sample {n}"
1483                );
1484            }
1485        }
1486        assert!(memoized.big_g_memo.recompute_count() <= 5_001);
1487    }
1488
1489    /// With constant parameters the SVF coefficient block is computed exactly
1490    /// once, and `set_sample_rate` (part of the key) forces a recompute.
1491    #[test]
1492    fn test_svf_memo_recompute_count() {
1493        let mut svf = Svf::new(44100.0);
1494        let mut inputs = PortValues::new();
1495        let mut outputs = PortValues::new();
1496        inputs.set(0, 1.0);
1497        inputs.set(1, 0.5);
1498        for _ in 0..1000 {
1499            svf.tick(&inputs, &mut outputs);
1500        }
1501        assert_eq!(svf.g_memo.recompute_count(), 1);
1502
1503        svf.set_sample_rate(48000.0);
1504        svf.tick(&inputs, &mut outputs);
1505        assert_eq!(svf.g_memo.recompute_count(), 2);
1506    }
1507}