#[derive(Clone, Copy, Debug)]
pub struct Biquad {
pub b0: f64,
pub b1: f64,
pub b2: f64,
pub a1: f64,
pub a2: f64,
}
impl Biquad {
pub fn lowpass(cutoff_hz: f64, sample_rate_hz: f64, q: f64) -> Self {
use std::f64::consts::PI;
let omega = 2.0 * PI * cutoff_hz / sample_rate_hz;
let (sin_w, cos_w) = (omega.sin(), omega.cos());
let alpha = sin_w / (2.0 * q);
let a0 = 1.0 + alpha;
Biquad {
b0: (1.0 - cos_w) * 0.5 / a0,
b1: (1.0 - cos_w) / a0,
b2: (1.0 - cos_w) * 0.5 / a0,
a1: -2.0 * cos_w / a0,
a2: (1.0 - alpha) / a0,
}
}
#[inline]
fn step(&self, x: f64, z: &mut (f64, f64)) -> 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
}
}
pub fn butterworth4_lowpass(cutoff_hz: f64, sample_rate_hz: f64) -> [Biquad; 2] {
use std::f64::consts::PI;
let q1 = 1.0 / (2.0 * (PI / 8.0).sin());
let q2 = 1.0 / (2.0 * (3.0 * PI / 8.0).sin());
[
Biquad::lowpass(cutoff_hz, sample_rate_hz, q1),
Biquad::lowpass(cutoff_hz, sample_rate_hz, q2),
]
}
pub fn filter_forward(coeffs: &[Biquad], x: &[f64]) -> Vec<f64> {
let x0 = x.first().copied().unwrap_or(0.0);
let mut state: Vec<(f64, f64)> = coeffs
.iter()
.map(|bq| ((1.0 - bq.b0) * x0, (bq.b2 - bq.a2) * x0))
.collect();
let mut out = Vec::with_capacity(x.len());
for &v in x {
let mut s = v;
for (bq, st) in coeffs.iter().zip(state.iter_mut()) {
s = bq.step(s, st);
}
out.push(s);
}
out
}
pub fn filter_zero_phase(coeffs: &[Biquad], x: &[f64]) -> Vec<f64> {
if x.is_empty() {
return Vec::new();
}
let forward = filter_forward(coeffs, x);
let reversed: Vec<f64> = forward.into_iter().rev().collect();
let twice = filter_forward(coeffs, &reversed);
twice.into_iter().rev().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn butterworth_passes_dc_unchanged() {
let coeffs = butterworth4_lowpass(100.0, 5000.0);
let x = vec![5.0; 500];
let y = filter_zero_phase(&coeffs, &x);
for v in &y[100..400] {
assert!((v - 5.0).abs() < 1e-6, "DC drift: {}", v - 5.0);
}
}
#[test]
fn butterworth_attenuates_above_cutoff() {
let fs = 5000.0;
let coeffs = butterworth4_lowpass(100.0, fs);
let n = 2000;
let x: Vec<f64> = (0..n)
.map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / fs).sin())
.collect();
let y = filter_zero_phase(&coeffs, &x);
let peak = y[500..1500].iter().map(|v| v.abs()).fold(0.0_f64, f64::max);
assert!(peak < 0.01, "1 kHz not attenuated: peak={}", peak);
}
#[test]
fn butterworth_preserves_well_below_cutoff() {
let fs = 5000.0;
let coeffs = butterworth4_lowpass(100.0, fs);
let n = 2000;
let x: Vec<f64> = (0..n)
.map(|i| (2.0 * std::f64::consts::PI * 10.0 * i as f64 / fs).sin())
.collect();
let y = filter_zero_phase(&coeffs, &x);
let max_err = x[500..1500]
.iter()
.zip(&y[500..1500])
.map(|(a, b)| (a - b).abs())
.fold(0.0_f64, f64::max);
assert!(max_err < 0.01, "10 Hz attenuated: max_err={}", max_err);
}
#[test]
fn butterworth_zero_phase_does_not_shift_peak() {
let fs = 5000.0;
let coeffs = butterworth4_lowpass(100.0, fs);
let n = 1000;
let centre = 500usize;
let sigma = 20.0;
let x: Vec<f64> = (0..n)
.map(|i| (-((i as f64 - centre as f64).powi(2)) / (2.0 * sigma * sigma)).exp())
.collect();
let y = filter_zero_phase(&coeffs, &x);
let (peak_idx, _) = y
.iter()
.enumerate()
.fold((0, f64::MIN), |acc, (i, &v)| if v > acc.1 { (i, v) } else { acc });
assert_eq!(peak_idx, centre);
}
#[test]
fn empty_input_returns_empty() {
let coeffs = butterworth4_lowpass(40.0, 5000.0);
assert!(filter_zero_phase(&coeffs, &[]).is_empty());
}
}