Skip to main content

pedalkernel/compiler/
stage_test_helpers.rs

1//! Test helpers for IIR-based tone stages.
2//!
3//! Provides [`ToneFeedback`]: a first-order IIR filter derived from the
4//! component values of an inverting op-amp with a reactive feedback network
5//! (the "active high-shelf" topology used in the Klon Centaur treble control
6//! and similar circuits).
7//!
8//! # Circuit topology
9//!
10//! ```text
11//!         Zf(s)
12//!     ┌──/\/\/──┐
13//!     │         │
14//!  in ──Ri──(-)─┤        Zf = Rf + Rs ‖ (Rp·pos + 1/(Cs))
15//!           (+)─┤
16//!     gnd───────┘──out
17//! ```
18//!
19//! where:
20//! - `Rf`  = series feedback resistor
21//! - `Ri`  = input resistor
22//! - `Rs`  = shelf resistor (parallel path in feedback)
23//! - `C`   = tone capacitor
24//! - `Rp`  = pot maximum resistance (variable via `pos ∈ [0,1]`)
25//!
26//! # Transfer function derivation
27//!
28//! The s-domain transfer function is:
29//!
30//! ```text
31//! H(s) = -Zf(s) / Ri
32//!
33//! Zf(s) = Rf + Rs·(Rp·pos·C·s + 1) / ((Rs + Rp·pos)·C·s + 1)
34//!
35//!       = [C·(Rf·(Rs + Rp·pos) + Rs·Rp·pos)·s + (Rf + Rs)]
36//!         / [(Rs + Rp·pos)·C·s + 1]
37//! ```
38//!
39//! This is a first-order shelf filter. Applying the bilinear transform
40//! `s ← (2·fs)·(1 - z⁻¹)/(1 + z⁻¹)` yields a 1st-order IIR:
41//!
42//! ```text
43//! y[n] = b0·x[n] + b1·x[n-1] - a1·y[n-1]
44//! ```
45//!
46//! Reference: Chowdhury, "A Comparison of Virtual Analog Modelling
47//! Techniques" (arXiv:2009.02833), Section 2.1.
48
49/// First-order IIR filter derived from an inverting op-amp active high-shelf
50/// circuit (e.g. Klon Centaur treble control).
51///
52/// Coefficients are computed analytically from component values and updated
53/// when the pot position changes.
54pub struct ToneFeedback {
55    // ── Component values (fixed at construction) ──
56    rf: f64,
57    ri: f64,
58    c_tone: f64,
59    r_shelf: f64,
60    max_pot_r: f64,
61    sample_rate: f64,
62
63    // ── IIR coefficients (recomputed on pot change) ──
64    pub b0: f64,
65    pub b1: f64,
66    pub a1: f64,
67    pub dc_gain: f64,
68
69    // ── Filter state ──
70    x1: f64,
71    y1: f64,
72}
73
74impl ToneFeedback {
75    /// Create a new `ToneFeedback` IIR from circuit component values.
76    ///
77    /// # Arguments
78    /// - `rf` — feedback resistor (Ω)
79    /// - `ri` — input resistor (Ω)
80    /// - `c_tone` — tone capacitor (F)
81    /// - `r_shelf` — shelf resistor in feedback path (Ω)
82    /// - `max_pot_r` — maximum pot resistance (Ω)
83    /// - `_pot_id` — pot label (for future binding, currently unused)
84    /// - `sample_rate` — sample rate (Hz)
85    /// - `pot_pos` — initial pot position in `[0.0, 1.0]`
86    #[allow(clippy::too_many_arguments)]
87    pub fn new(
88        rf: f64,
89        ri: f64,
90        c_tone: f64,
91        r_shelf: f64,
92        max_pot_r: f64,
93        _pot_id: String,
94        sample_rate: f64,
95        pot_pos: f64,
96    ) -> Self {
97        let mut tf = ToneFeedback {
98            rf,
99            ri,
100            c_tone,
101            r_shelf,
102            max_pot_r,
103            sample_rate,
104            b0: 0.0,
105            b1: 0.0,
106            a1: 0.0,
107            dc_gain: 0.0,
108            x1: 0.0,
109            y1: 0.0,
110        };
111        tf.recompute(pot_pos);
112        tf
113    }
114
115    /// Recompute IIR coefficients for a new pot position.
116    ///
117    /// This applies the bilinear transform to the s-domain transfer function
118    /// with the updated pot resistance. Cheap arithmetic — no matrix ops.
119    pub fn recompute(&mut self, pot_pos: f64) {
120        let rf = self.rf;
121        let ri = self.ri;
122        let c = self.c_tone;
123        let rs = self.r_shelf;
124        let rp = self.max_pot_r * pot_pos.clamp(0.0, 1.0);
125
126        // ── s-domain transfer function coefficients ──
127        //
128        // H(s) = -(1/Ri) · (num1·s + num0) / (den1·s + 1)
129        //
130        // num1 = C · (Rf·(Rs + Rp) + Rs·Rp)
131        // num0 = Rf + Rs
132        // den1 = (Rs + Rp) · C
133
134        let num1 = c * (rf * (rs + rp) + rs * rp);
135        let num0 = rf + rs;
136        let den1 = (rs + rp) * c;
137
138        // ── Bilinear transform: s ← k·(1 - z⁻¹)/(1 + z⁻¹) ──
139        //
140        // k = 2·fs (no pre-warping — shelf corner is typically well below
141        // Nyquist for guitar pedal circuits at 44.1/48 kHz)
142        let k = 2.0 * self.sample_rate;
143
144        // Substituting into H(s) and multiplying through by (1 + z⁻¹):
145        //
146        // Numerator:  (num1·k + num0) + (-num1·k + num0)·z⁻¹
147        // Denominator: (den1·k + 1)  + (-den1·k + 1)·z⁻¹
148        //
149        // Then scale by -1/Ri.
150
151        let n0 = num1 * k + num0;
152        let n1 = -num1 * k + num0;
153        let d0 = den1 * k + 1.0;
154        let d1 = -den1 * k + 1.0;
155
156        // Normalize so a0 = 1.0, and include the -1/Ri gain.
157        let gain = -1.0 / ri;
158        self.b0 = gain * n0 / d0;
159        self.b1 = gain * n1 / d0;
160        self.a1 = d1 / d0;
161
162        // DC gain: H(z=1) = H(s=0) = -(Rf + Rs) / Ri
163        self.dc_gain = -(rf + rs) / ri;
164    }
165
166    /// Process one audio sample through the first-order IIR.
167    #[inline]
168    pub fn process(&mut self, x: f64) -> f64 {
169        let y = self.b0 * x + self.b1 * self.x1 - self.a1 * self.y1;
170        self.x1 = x;
171        self.y1 = y;
172        y
173    }
174
175    /// Reset filter state (clear delay line).
176    pub fn reset(&mut self) {
177        self.x1 = 0.0;
178        self.y1 = 0.0;
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use std::f64::consts::PI;
186
187    /// Verify DC gain matches analytical prediction.
188    #[test]
189    fn dc_gain_matches_analytical() {
190        let tf = ToneFeedback::new(
191            1800.0,
192            1800.0,
193            3.9e-9,
194            4700.0,
195            10000.0,
196            "Treble".into(),
197            48000.0,
198            0.5,
199        );
200        let expected_dc = -(1800.0 + 4700.0) / 1800.0;
201        assert!(
202            (tf.dc_gain - expected_dc).abs() < 1e-10,
203            "DC gain mismatch: got {}, expected {}",
204            tf.dc_gain,
205            expected_dc
206        );
207    }
208
209    /// Verify coefficients change with pot position.
210    #[test]
211    fn coefficients_vary_with_pot() {
212        let tf0 = ToneFeedback::new(
213            1800.0,
214            1800.0,
215            3.9e-9,
216            4700.0,
217            10000.0,
218            "Treble".into(),
219            48000.0,
220            0.0,
221        );
222        let tf1 = ToneFeedback::new(
223            1800.0,
224            1800.0,
225            3.9e-9,
226            4700.0,
227            10000.0,
228            "Treble".into(),
229            48000.0,
230            1.0,
231        );
232
233        // DC gain is pot-independent (shelf resistor dominates at DC)
234        assert!((tf0.dc_gain - tf1.dc_gain).abs() < 1e-10);
235
236        // But HF behavior (b0, b1) must differ
237        assert!(
238            (tf0.b0 - tf1.b0).abs() > 1e-6,
239            "b0 should differ: {} vs {}",
240            tf0.b0,
241            tf1.b0
242        );
243    }
244
245    /// Verify frequency response: treble boost/cut at 10 kHz.
246    #[test]
247    fn frequency_response_shelf() {
248        let sr = 48000.0;
249        let freq = 10000.0;
250        let n = 4096usize;
251
252        let mut rms_by_pos = Vec::new();
253
254        for &pos in &[0.0, 0.5, 1.0] {
255            let mut tf = ToneFeedback::new(
256                1800.0,
257                1800.0,
258                3.9e-9,
259                4700.0,
260                10000.0,
261                "Treble".into(),
262                sr,
263                pos,
264            );
265
266            // Warm up to reach steady state
267            for i in 0..2048 {
268                let x = 0.1 * (2.0 * PI * freq * i as f64 / sr).sin();
269                tf.process(x);
270            }
271
272            // Measure RMS
273            let mut sum_sq = 0.0;
274            for i in 0..n {
275                let x = 0.1 * (2.0 * PI * freq * (i + 2048) as f64 / sr).sin();
276                let y = tf.process(x);
277                sum_sq += y * y;
278            }
279            rms_by_pos.push((sum_sq / n as f64).sqrt());
280        }
281
282        // At 10 kHz, pot=0 should give less gain than pot=1
283        // (pot=0 shorts the cap path → Zf approaches Rf at HF → lower gain)
284        assert!(
285            rms_by_pos[0] < rms_by_pos[2],
286            "Treble pot should boost HF: pos=0 rms={:.6}, pos=1 rms={:.6}",
287            rms_by_pos[0],
288            rms_by_pos[2]
289        );
290    }
291
292    /// Verify filter is stable (no NaN/Inf).
293    #[test]
294    fn stability() {
295        let mut tf = ToneFeedback::new(
296            1800.0,
297            1800.0,
298            3.9e-9,
299            4700.0,
300            10000.0,
301            "Treble".into(),
302            48000.0,
303            0.5,
304        );
305
306        for i in 0..10000 {
307            let x = 0.5 * (2.0 * PI * 1000.0 * i as f64 / 48000.0).sin();
308            let y = tf.process(x);
309            assert!(y.is_finite(), "Output became non-finite at sample {}", i);
310        }
311    }
312
313    // ══════════════════════════════════════════════════════════════════════
314    // OpAmpWdfAdaptor tests
315    // ══════════════════════════════════════════════════════════════════════
316
317    use crate::compiler::dyn_node::DynNode;
318    use crate::compiler::stage::OpAmpWdfAdaptor;
319    use crate::dsl::PotTaper;
320    use crate::elements::nonlinear::{OpAmpModel, OpAmpRoot};
321
322    /// Build the Klon treble Zf tree:
323    /// Parallel(R_tone_fb=1800, Series(Parallel(R_shelf=4700, Treble__wb), Series(C_tone, Treble__aw)))
324    fn build_klon_zf(sr: f64, pot_pos: f64) -> DynNode {
325        let r_tone_fb = DynNode::Resistor(Some("R_tone_fb".into()), 1800.0);
326        let r_shelf = DynNode::Resistor(Some("R_tone_shelf".into()), 4700.0);
327
328        let max_pot_r = 10000.0;
329        let treble_wb = DynNode::Pot("Treble__wb".into(), max_pot_r, 1.0 - pot_pos, PotTaper::B);
330        let c_tone_rp = 1.0 / (2.0 * sr * 3.9e-9);
331        let c_tone = DynNode::Capacitor(Some("C_tone".into()), 3.9e-9, c_tone_rp);
332        let treble_aw = DynNode::Pot("Treble__aw".into(), max_pot_r, pot_pos, PotTaper::B);
333
334        // Inner parallel: R_shelf ‖ Treble__wb
335        let inner_par = DynNode::Parallel(Box::new(r_shelf), Box::new(treble_wb));
336        // Inner series: C_tone + Treble__aw
337        let inner_ser = DynNode::Series(Box::new(c_tone), Box::new(treble_aw));
338        // Outer series: (R_shelf ‖ Treble__wb) + (C_tone + Treble__aw)
339        let outer_ser = DynNode::Series(Box::new(inner_par), Box::new(inner_ser));
340        // Root parallel: R_tone_fb ‖ outer_ser
341        DynNode::Parallel(Box::new(r_tone_fb), Box::new(outer_ser))
342    }
343
344    /// Build a Klon-style inverting OpAmpWdfAdaptor for testing.
345    fn build_klon_adaptor(sr: f64, pot_pos: f64) -> OpAmpWdfAdaptor {
346        let ri = 1800.0;
347        let zi = DynNode::VoltageSource(0.0, ri);
348        let zf = build_klon_zf(sr, pot_pos);
349
350        let model = OpAmpModel::tl072();
351        let gain = 1800.0 / ri; // DC gain = Rf/Ri (approximate)
352        let mut opamp = OpAmpRoot::new_inverting(model, 1.0);
353        opamp.set_sample_rate(sr);
354        opamp.set_gbw_gain(gain);
355        opamp.set_v_max(3.0); // 9V supply / 2 - 1.5
356
357        OpAmpWdfAdaptor::new(zi, zf, true, opamp, Some("Treble".into()))
358    }
359
360    /// Measure RMS output at a given frequency through the adaptor.
361    fn measure_rms(adaptor: &mut OpAmpWdfAdaptor, freq: f64, sr: f64, amplitude: f64) -> f64 {
362        let warmup = 4096;
363        let n = 4096;
364
365        // Warm up
366        for i in 0..warmup {
367            let x = amplitude * (2.0 * PI * freq * i as f64 / sr).sin();
368            adaptor.process(0.0, x); // inverting: V+ = 0, V_in = signal
369        }
370        // Measure
371        let mut sum_sq = 0.0;
372        for i in 0..n {
373            let x = amplitude * (2.0 * PI * freq * (i + warmup) as f64 / sr).sin();
374            let y = adaptor.process(0.0, x);
375            sum_sq += y * y;
376        }
377        (sum_sq / n as f64).sqrt()
378    }
379
380    #[test]
381    fn wdf_adaptor_produces_signal() {
382        let sr = 48000.0;
383        let mut adaptor = build_klon_adaptor(sr, 0.5);
384        let rms = measure_rms(&mut adaptor, 1000.0, sr, 0.1);
385        eprintln!("WDF adaptor 1kHz RMS = {:.6}", rms);
386        assert!(
387            rms > 0.01,
388            "Adaptor should produce audible output, got {:.6}",
389            rms
390        );
391    }
392
393    #[test]
394    fn wdf_adaptor_stable() {
395        let sr = 48000.0;
396        let mut adaptor = build_klon_adaptor(sr, 0.5);
397        for i in 0..20000 {
398            let x = 0.5 * (2.0 * PI * 1000.0 * i as f64 / sr).sin();
399            let y = adaptor.process(0.0, x);
400            assert!(y.is_finite(), "Output NaN/Inf at sample {}", i);
401            assert!(
402                y.abs() < 100.0,
403                "Output diverging at sample {}: {:.2}",
404                i,
405                y
406            );
407        }
408    }
409
410    #[test]
411    fn wdf_adaptor_dc_gain_inverting() {
412        // At DC with Zi=Ri=1800 and Zf=Rf=1800 (ignoring reactive elements),
413        // gain should be approximately -Rf/Ri = -1.0
414        let sr = 48000.0;
415        let mut adaptor = build_klon_adaptor(sr, 0.5);
416
417        // Drive with DC step, let settle
418        let dc_in = 0.1;
419        let mut last = 0.0;
420        for _ in 0..10000 {
421            last = adaptor.process(0.0, dc_in);
422        }
423        let dc_gain = last / dc_in;
424        eprintln!("WDF adaptor DC gain = {:.4}", dc_gain);
425        // DC gain magnitude: with Zf network at DC, caps open → Zf_dc ≈ R_tone_fb ‖ (R_shelf + R_pot/2)
426        // Expected |gain| = Zf_dc / Ri ≈ 1800‖(4700+5000) / 1800 ≈ 1500/1800 ≈ 0.83 to ~3.6
427        // Sign may be inverted in WDF convention (port polarity dependent)
428        let gain_mag = dc_gain.abs();
429        assert!(
430            gain_mag > 0.3 && gain_mag < 15.0,
431            "DC gain magnitude should be reasonable, got {:.4} (mag={:.4})",
432            dc_gain,
433            gain_mag
434        );
435    }
436
437    #[test]
438    fn wdf_adaptor_treble_changes_spectrum() {
439        let sr = 48000.0;
440        let freq = 10000.0; // 10kHz — treble range
441
442        let mut adaptor_dark = build_klon_adaptor(sr, 0.0);
443        let mut adaptor_bright = build_klon_adaptor(sr, 1.0);
444
445        let rms_dark = measure_rms(&mut adaptor_dark, freq, sr, 0.1);
446        let rms_bright = measure_rms(&mut adaptor_bright, freq, sr, 0.1);
447
448        let ratio_db = 20.0 * (rms_bright / rms_dark).log10();
449        eprintln!(
450            "WDF adaptor treble at {}Hz: dark={:.6}, bright={:.6}, ratio={:.3}x, dB={:.2}",
451            freq,
452            rms_dark,
453            rms_bright,
454            rms_bright / rms_dark,
455            ratio_db
456        );
457
458        assert!(
459            ratio_db.abs() > 0.5,
460            "Treble pot should affect 10kHz output by ≥0.5dB, got {:.2}dB",
461            ratio_db
462        );
463    }
464
465    #[test]
466    fn wdf_adaptor_frequency_response_shape() {
467        // Verify the adaptor produces different responses at different frequencies.
468        // A shelving EQ should have more effect at HF than LF.
469        let sr = 48000.0;
470
471        let mut adaptor_dark = build_klon_adaptor(sr, 0.0);
472        let mut adaptor_bright = build_klon_adaptor(sr, 1.0);
473
474        let freqs = [100.0, 1000.0, 5000.0, 10000.0];
475        let mut ratios = Vec::new();
476
477        for &freq in &freqs {
478            // Need fresh adaptors for each frequency to avoid state contamination
479            let mut ad = build_klon_adaptor(sr, 0.0);
480            let mut ab = build_klon_adaptor(sr, 1.0);
481            let rms_d = measure_rms(&mut ad, freq, sr, 0.1);
482            let rms_b = measure_rms(&mut ab, freq, sr, 0.1);
483            let ratio = if rms_d > 1e-10 { rms_b / rms_d } else { 1.0 };
484            let db = 20.0 * ratio.log10();
485            eprintln!(
486                "  {}Hz: dark={:.6} bright={:.6} ratio={:.4} ({:.2}dB)",
487                freq, rms_d, rms_b, ratio, db
488            );
489            ratios.push(db);
490        }
491
492        // The shelf should have more effect at HF than LF
493        // ratios[0] is at 100Hz, ratios[3] is at 10kHz
494        // The difference in dB between HF and LF should be > 0
495        let hf_lf_diff = (ratios[3] - ratios[0]).abs();
496        eprintln!("HF-LF pot effect difference: {:.2}dB", hf_lf_diff);
497        assert!(
498            hf_lf_diff > 0.1,
499            "Shelving EQ should affect HF more than LF, diff={:.2}dB",
500            hf_lf_diff
501        );
502    }
503}