Skip to main content

quiver/modules/
filters.rs

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