use std::f64::consts::PI;
#[derive(Clone, Debug)]
pub struct BiquadState {
pub z: Vec<[f64; 2]>, }
impl BiquadState {
pub fn new(num_sections: usize) -> Self {
Self {
z: vec![[0.0; 2]; num_sections],
}
}
pub fn reset(&mut self) {
self.z.fill([0.0; 2]);
}
}
#[derive(Clone, Debug)]
pub struct Sos {
pub b0: f64,
pub b1: f64,
pub b2: f64,
pub a1: f64,
pub a2: f64,
}
impl Sos {
#[inline]
pub fn process(&self, x: f64, z: &mut [f64; 2]) -> f64 {
let y = self.b0 * x + z[0];
z[0] = self.b1 * x - self.a1 * y + z[1];
z[1] = self.b2 * x - self.a2 * y;
y
}
}
#[derive(Clone, Debug)]
pub struct SosChain {
pub sections: Vec<Sos>,
}
impl SosChain {
pub fn num_sections(&self) -> usize {
self.sections.len()
}
#[inline]
pub fn process(&self, mut x: f64, state: &mut BiquadState) -> f64 {
for (i, sec) in self.sections.iter().enumerate() {
x = sec.process(x, &mut state.z[i]);
}
x
}
}
#[allow(dead_code)]
pub fn sweep_filter(val: f64, sr: f64) -> Option<SosChain> {
let nyquist = sr / 2.0;
if (val - 0.5).abs() < 0.01 {
return None; }
let (freq, is_highpass) = if val < 0.5 {
let t = 1.0 - val / 0.5; let f = (20000.0 * t * t * t).max(20.0);
(f, true)
} else {
let t = (val - 0.5) * 2.0; let f = (20000.0 * (1.0 - t).powi(3)).max(20.0);
(f, false)
};
#[allow(clippy::manual_clamp)]
let wn = (freq / nyquist).min(0.99).max(0.001);
Some(butterworth_4th(wn, is_highpass))
}
#[inline]
fn pole_pair_section(warped: f64, angle: f64, highpass: bool) -> Sos {
let fs = 2.0;
let re = angle.cos() * warped;
let im = angle.sin() * warped;
let a0s = 4.0 * fs * fs - 4.0 * fs * re + re * re + im * im;
let (b0, b1, b2) = if highpass {
(4.0 * fs * fs / a0s, -8.0 * fs * fs / a0s, 4.0 * fs * fs / a0s)
} else {
let ww = re * re + im * im;
(ww / a0s, 2.0 * ww / a0s, ww / a0s)
};
let a1 = (2.0 * (re * re + im * im) - 8.0 * fs * fs) / a0s;
let a2 = (4.0 * fs * fs + 4.0 * fs * re + re * re + im * im) / a0s;
Sos { b0, b1, b2, a1, a2 }
}
pub(super) fn butterworth_4th(wn: f64, highpass: bool) -> SosChain {
let fs = 2.0;
let warped = 2.0 * fs * (PI * wn / fs).tan();
let angles = [5.0 * PI / 8.0, 7.0 * PI / 8.0];
let sections = angles
.iter()
.map(|&angle| pole_pair_section(warped, angle, highpass))
.collect();
SosChain { sections }
}
pub(super) fn linkwitz_riley_4th(wn: f64, highpass: bool) -> SosChain {
let fs = 2.0;
let warped = 2.0 * fs * (PI * wn / fs).tan();
let angle = 3.0 * PI / 4.0;
let section = pole_pair_section(warped, angle, highpass);
SosChain { sections: vec![section.clone(), section] }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sweep_filter_bypass_at_center() {
assert!(sweep_filter(0.5, 44100.0).is_none());
assert!(sweep_filter(0.505, 44100.0).is_none());
assert!(sweep_filter(0.495, 44100.0).is_none());
assert!(sweep_filter(0.49, 44100.0).is_some());
assert!(sweep_filter(0.51, 44100.0).is_some());
}
#[test]
fn sweep_filter_hp_at_zero() {
let chain = sweep_filter(0.0, 44100.0).unwrap();
assert_eq!(chain.num_sections(), 2); }
#[test]
fn sweep_filter_lp_at_one() {
let chain = sweep_filter(1.0, 44100.0).unwrap();
assert_eq!(chain.num_sections(), 2);
}
#[test]
fn biquad_state_process() {
let chain = butterworth_4th(0.1, false); let mut state = BiquadState::new(chain.num_sections());
let y0 = chain.process(1.0, &mut state);
let y1 = chain.process(0.0, &mut state);
assert!(y0.abs() > 0.0);
assert!(y1.abs() > 0.0);
}
fn gain_db(chain: &SosChain, freq: f64, sr: f64) -> f64 {
let mut st = BiquadState::new(chain.num_sections());
let (warmup, measure) = (16384, 16384);
let (mut sig, mut out) = (0.0_f64, 0.0_f64);
for i in 0..(warmup + measure) {
let x = (2.0 * PI * freq * (i as f64 / sr)).sin();
let y = chain.process(x, &mut st);
if i >= warmup {
sig += x * x;
out += y * y;
}
}
10.0 * (out / sig).log10()
}
fn sum_gain_db(lp: &SosChain, hp: &SosChain, freq: f64, sr: f64) -> f64 {
let mut s_lp = BiquadState::new(lp.num_sections());
let mut s_hp = BiquadState::new(hp.num_sections());
let (warmup, measure) = (16384, 16384);
let (mut sig, mut out) = (0.0_f64, 0.0_f64);
for i in 0..(warmup + measure) {
let x = (2.0 * PI * freq * (i as f64 / sr)).sin();
let y = lp.process(x, &mut s_lp) + hp.process(x, &mut s_hp);
if i >= warmup {
sig += x * x;
out += y * y;
}
}
10.0 * (out / sig).log10()
}
fn sos_eq(a: &Sos, b: &Sos) -> bool {
let close = |x: f64, y: f64| (x - y).abs() < 1e-12;
close(a.b0, b.b0) && close(a.b1, b.b1) && close(a.b2, b.b2)
&& close(a.a1, b.a1) && close(a.a2, b.a2)
}
#[test]
fn butterworth_4th_has_two_distinct_sections() {
let lp = butterworth_4th(0.1, false);
assert_eq!(lp.num_sections(), 2, "4th order = 2 biquads");
assert!(
!sos_eq(&lp.sections[0], &lp.sections[1]),
"Butterworth-4 sections should differ (different pole pairs)"
);
}
#[test]
fn butterworth_4th_lp_passband_stopband() {
let sr = 44100.0;
let wn = 0.1;
let fc = wn * sr / 2.0; let lp = butterworth_4th(wn, false);
assert!(gain_db(&lp, fc / 10.0, sr).abs() < 0.5);
let at_cut = gain_db(&lp, fc, sr);
assert!(
(at_cut + 3.0).abs() < 0.7,
"Butterworth-4 LP should be ~-3 dB at cutoff, got {at_cut:.2} dB"
);
assert!(gain_db(&lp, fc * 4.0, sr) < -30.0);
}
#[test]
fn butterworth_4th_hp_is_mirror() {
let sr = 44100.0;
let wn = 0.1;
let fc = wn * sr / 2.0;
let hp = butterworth_4th(wn, true);
assert!(gain_db(&hp, fc * 8.0, sr).abs() < 0.5);
assert!((gain_db(&hp, fc, sr) + 3.0).abs() < 0.7);
assert!(gain_db(&hp, fc / 4.0, sr) < -30.0);
}
#[test]
fn butterworth_4th_sum_peaks_3db_at_crossover() {
let sr = 44100.0;
let wn = 0.1;
let fc = wn * sr / 2.0;
let lp = butterworth_4th(wn, false);
let hp = butterworth_4th(wn, true);
let sum = sum_gain_db(&lp, &hp, fc, sr);
assert!(
(sum - 3.0).abs() < 0.7,
"Butterworth-4 LP+HP should peak ~+3 dB at crossover, got {sum:.2} dB"
);
}
#[test]
fn lr4_has_two_identical_sections() {
let lp = linkwitz_riley_4th(0.1, false);
assert_eq!(lp.num_sections(), 2, "LR4 = 2 cascaded biquads");
assert!(
sos_eq(&lp.sections[0], &lp.sections[1]),
"LR4 sections must be identical (doubled 2nd-order Butterworth)"
);
}
#[test]
fn lr4_is_minus_6db_at_crossover() {
let sr = 44100.0;
let wn = 0.1;
let fc = wn * sr / 2.0;
let lp = linkwitz_riley_4th(wn, false);
let hp = linkwitz_riley_4th(wn, true);
let lp_db = gain_db(&lp, fc, sr);
let hp_db = gain_db(&hp, fc, sr);
assert!(
(lp_db + 6.0).abs() < 0.7,
"LR4 LP should be ~-6 dB at crossover, got {lp_db:.2} dB"
);
assert!(
(hp_db + 6.0).abs() < 0.7,
"LR4 HP should be ~-6 dB at crossover, got {hp_db:.2} dB"
);
}
#[test]
fn lr4_sum_is_flat_at_crossover() {
let sr = 44100.0;
let wn = 0.1;
let fc = wn * sr / 2.0;
let lp = linkwitz_riley_4th(wn, false);
let hp = linkwitz_riley_4th(wn, true);
for &f in &[fc / 4.0, fc, fc * 4.0] {
let sum = sum_gain_db(&lp, &hp, f, sr);
assert!(
sum.abs() < 0.5,
"LR4 LP+HP should sum ~0 dB at {f:.0} Hz, got {sum:.2} dB"
);
}
}
#[test]
fn lr4_lp_passband_stopband() {
let sr = 44100.0;
let wn = 0.1;
let fc = wn * sr / 2.0;
let lp = linkwitz_riley_4th(wn, false);
assert!(gain_db(&lp, fc / 10.0, sr).abs() < 0.5); assert!(gain_db(&lp, fc * 4.0, sr) < -30.0); }
}