diff --git a/plugins/common/dsp/src/svf.rs b/plugins/common/dsp/src/svf.rs index abe88d7..fcacf17 100644 --- a/plugins/common/dsp/src/svf.rs +++ b/plugins/common/dsp/src/svf.rs @@ -70,6 +70,21 @@ impl SvfCoef { m2: -1., } } + /// Second-order allpass. With `q` = 1/√2 it has the phase of a Linkwitz–Riley crossover + /// built from two lowpass and two highpass stages at the same frequency, so it keeps + /// the bands of a crossover tree in phase. + pub fn allpass(rate: f32, hz: f32, q: f32) -> Self { + let k = 1. / q.max(1e-3); + let (a1, a2, a3) = Self::base(rate, hz, k, 1.); + Self { + a1, + a2, + a3, + m0: 1., + m1: -2. * k, + m2: 0., + } + } pub fn high_shelf(rate: f32, hz: f32, q: f32, gain_db: f32) -> Self { let a = 10f32.powf(gain_db / 40.); let k = 1. / q.max(1e-3); diff --git a/plugins/common/dsp/tests/blocks.rs b/plugins/common/dsp/tests/blocks.rs index 56b27c9..f687a29 100644 --- a/plugins/common/dsp/tests/blocks.rs +++ b/plugins/common/dsp/tests/blocks.rs @@ -46,6 +46,31 @@ fn svf_bell_at_zero_db_is_an_exact_identity() { } } +#[test] +fn svf_allpass_matches_a_linkwitz_riley_split() { + let q = std::f32::consts::FRAC_1_SQRT_2; + let (low, high, all) = ( + SvfCoef::lowpass(RATE, 300., q), + SvfCoef::highpass(RATE, 300., q), + SvfCoef::allpass(RATE, 300., q), + ); + let mut s = [Svf::default(); 5]; + let mut saw = Saw::new(RATE, 55.); + for _ in 0..9600 { + let x = saw.sample(); + let l = s[0].process(&low, x); + let l = s[1].process(&low, l); + let h = s[2].process(&high, x); + let h = s[3].process(&high, h); + let a = s[4].process(&all, x); + assert!((l + h - a).abs() < 1e-4, "{} vs {a}", l + h); + } + let mut s = Svf::default(); + for hz in [30., 300., 3000.] { + assert!(sine_gain(|x| s.process(&all, x), hz).abs() < 0.01); + } +} + #[test] fn svf_survives_retuning_every_sample() { let mut s = Svf::default();