use std::f64::consts::PI;
const BUTTERWORTH_Q: f64 = std::f64::consts::FRAC_1_SQRT_2;
#[derive(Debug, Clone)]
struct Biquad {
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
x1: f64,
x2: f64,
y1: f64,
y2: f64,
}
impl Biquad {
fn lowpass(freq: f64, sample_rate: u32) -> Self {
Self::lowpass_q(freq, sample_rate, BUTTERWORTH_Q)
}
fn lowpass_q(freq: f64, sample_rate: u32, q: f64) -> Self {
let w0 = 2.0 * PI * freq / sample_rate as f64;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let alpha = sin_w0 / (2.0 * q);
let a0 = 1.0 + alpha;
let b0 = (1.0 - cos_w0) / 2.0 / a0;
let b1 = (1.0 - cos_w0) / a0;
let b2 = (1.0 - cos_w0) / 2.0 / a0;
let a1 = -2.0 * cos_w0 / a0;
let a2 = (1.0 - alpha) / a0;
Self {
b0,
b1,
b2,
a1,
a2,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
fn highpass(freq: f64, sample_rate: u32) -> Self {
Self::highpass_q(freq, sample_rate, BUTTERWORTH_Q)
}
fn highpass_q(freq: f64, sample_rate: u32, q: f64) -> Self {
let w0 = 2.0 * PI * freq / sample_rate as f64;
let cos_w0 = w0.cos();
let sin_w0 = w0.sin();
let alpha = sin_w0 / (2.0 * q);
let a0 = 1.0 + alpha;
let b0 = (1.0 + cos_w0) / 2.0 / a0;
let b1 = -(1.0 + cos_w0) / a0;
let b2 = (1.0 + cos_w0) / 2.0 / a0;
let a1 = -2.0 * cos_w0 / a0;
let a2 = (1.0 - alpha) / a0;
Self {
b0,
b1,
b2,
a1,
a2,
x1: 0.0,
x2: 0.0,
y1: 0.0,
y2: 0.0,
}
}
#[inline]
fn process_sample(&mut self, input: f64) -> f64 {
let output = self.b0 * input + self.b1 * self.x1 + self.b2 * self.x2
- self.a1 * self.y1
- self.a2 * self.y2;
self.x2 = self.x1;
self.x1 = input;
self.y2 = self.y1;
self.y1 = output;
output
}
fn reset(&mut self) {
self.x1 = 0.0;
self.x2 = 0.0;
self.y1 = 0.0;
self.y2 = 0.0;
}
}
pub struct LR4Crossover {
low_pass: [Biquad; 2],
high_pass: [Biquad; 2],
}
impl LR4Crossover {
pub fn new(crossover_freq: f64, sample_rate: u32) -> Self {
Self {
low_pass: [
Biquad::lowpass(crossover_freq, sample_rate),
Biquad::lowpass(crossover_freq, sample_rate),
],
high_pass: [
Biquad::highpass(crossover_freq, sample_rate),
Biquad::highpass(crossover_freq, sample_rate),
],
}
}
#[inline]
pub fn process_sample(&mut self, input: f32) -> (f32, f32) {
let x = input as f64;
let lp_stage1 = self.low_pass[0].process_sample(x);
let low = self.low_pass[1].process_sample(lp_stage1);
let hp_stage1 = self.high_pass[0].process_sample(x);
let high = self.high_pass[1].process_sample(hp_stage1);
(low as f32, high as f32)
}
pub fn process(&mut self, input: &[f32], low: &mut [f32], high: &mut [f32]) {
let len = input.len().min(low.len()).min(high.len());
for (i, &sample) in input.iter().take(len).enumerate() {
let (l, h) = self.process_sample(sample);
low[i] = l;
high[i] = h;
}
}
pub fn reset(&mut self) {
for bq in &mut self.low_pass {
bq.reset();
}
for bq in &mut self.high_pass {
bq.reset();
}
}
}
#[derive(Debug)]
pub struct LR8Crossover {
low_pass: [Biquad; 4],
high_pass: [Biquad; 4],
}
impl LR8Crossover {
pub fn new(crossover_freq: f64, sample_rate: u32) -> Self {
Self {
low_pass: [
Biquad::lowpass(crossover_freq, sample_rate),
Biquad::lowpass(crossover_freq, sample_rate),
Biquad::lowpass(crossover_freq, sample_rate),
Biquad::lowpass(crossover_freq, sample_rate),
],
high_pass: [
Biquad::highpass(crossover_freq, sample_rate),
Biquad::highpass(crossover_freq, sample_rate),
Biquad::highpass(crossover_freq, sample_rate),
Biquad::highpass(crossover_freq, sample_rate),
],
}
}
#[inline]
pub fn process_sample(&mut self, input: f32) -> (f32, f32) {
let x = input as f64;
let mut low = x;
for stage in &mut self.low_pass {
low = stage.process_sample(low);
}
let mut high = x;
for stage in &mut self.high_pass {
high = stage.process_sample(high);
}
(low as f32, high as f32)
}
pub fn process(&mut self, input: &[f32], low: &mut [f32], high: &mut [f32]) {
let len = input.len().min(low.len()).min(high.len());
for (i, &sample) in input.iter().take(len).enumerate() {
let (l, h) = self.process_sample(sample);
low[i] = l;
high[i] = h;
}
}
pub fn reset(&mut self) {
for bq in &mut self.low_pass {
bq.reset();
}
for bq in &mut self.high_pass {
bq.reset();
}
}
}
const BW4_Q1: f64 = 0.541_196_100_146_197;
const BW4_Q2: f64 = 1.306_562_964_876_377;
#[derive(Debug)]
pub struct LinkwitzRiley8 {
low_pass: [Biquad; 4],
high_pass: [Biquad; 4],
}
impl LinkwitzRiley8 {
pub fn new(crossover_freq: f64, sample_rate: u32) -> Self {
Self {
low_pass: [
Biquad::lowpass_q(crossover_freq, sample_rate, BW4_Q1),
Biquad::lowpass_q(crossover_freq, sample_rate, BW4_Q2),
Biquad::lowpass_q(crossover_freq, sample_rate, BW4_Q1),
Biquad::lowpass_q(crossover_freq, sample_rate, BW4_Q2),
],
high_pass: [
Biquad::highpass_q(crossover_freq, sample_rate, BW4_Q1),
Biquad::highpass_q(crossover_freq, sample_rate, BW4_Q2),
Biquad::highpass_q(crossover_freq, sample_rate, BW4_Q1),
Biquad::highpass_q(crossover_freq, sample_rate, BW4_Q2),
],
}
}
#[inline]
pub fn process_sample(&mut self, input: f32) -> (f32, f32) {
let x = input as f64;
let mut low = x;
for stage in &mut self.low_pass {
low = stage.process_sample(low);
}
let mut high = x;
for stage in &mut self.high_pass {
high = stage.process_sample(high);
}
(low as f32, high as f32)
}
pub fn process(&mut self, input: &[f32], low: &mut [f32], high: &mut [f32]) {
let len = input.len().min(low.len()).min(high.len());
for (i, &sample) in input.iter().take(len).enumerate() {
let (l, h) = self.process_sample(sample);
low[i] = l;
high[i] = h;
}
}
pub fn reset(&mut self) {
for bq in &mut self.low_pass {
bq.reset();
}
for bq in &mut self.high_pass {
bq.reset();
}
}
}
pub struct ThreeBandSplitter {
low_mid: LR8Crossover,
mid_high: LR8Crossover,
}
impl ThreeBandSplitter {
pub fn new(low_freq: f64, high_freq: f64, sample_rate: u32) -> Self {
Self {
low_mid: LR8Crossover::new(low_freq, sample_rate),
mid_high: LR8Crossover::new(high_freq, sample_rate),
}
}
pub fn process(
&mut self,
input: &[f32],
sub_bass: &mut [f32],
mid: &mut [f32],
high: &mut [f32],
) {
let len = input
.len()
.min(sub_bass.len())
.min(mid.len())
.min(high.len());
for (i, &sample) in input.iter().take(len).enumerate() {
let (lo, upper) = self.low_mid.process_sample(sample);
let (m, h) = self.mid_high.process_sample(upper);
sub_bass[i] = lo;
mid[i] = m;
high[i] = h;
}
}
pub fn reset(&mut self) {
self.low_mid.reset();
self.mid_high.reset();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_lr4_crossover_energy_conservation() {
let sample_rate = 44100;
let crossover_freq = 1000.0;
let mut xover = LR4Crossover::new(crossover_freq, sample_rate);
let len = 16384;
let input: Vec<f32> = (0..len)
.map(|i| {
let t = i as f32 / sample_rate as f32;
(2.0 * std::f32::consts::PI * 200.0 * t).sin() * 0.33
+ (2.0 * std::f32::consts::PI * 1000.0 * t).sin() * 0.33
+ (2.0 * std::f32::consts::PI * 5000.0 * t).sin() * 0.33
})
.collect();
let mut low = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
xover.process(&input, &mut low, &mut high);
let settle = 1024;
let input_energy: f64 = input[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let low_energy: f64 = low[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let high_energy: f64 = high[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let combined_energy = low_energy + high_energy;
let energy_ratio = combined_energy / input_energy;
assert!(
(0.7..1.3).contains(&energy_ratio),
"LR4 energy ratio {energy_ratio:.4} too far from 1.0 (low={low_energy:.2}, high={high_energy:.2}, input={input_energy:.2})"
);
}
#[test]
fn test_three_band_energy_conservation() {
let sample_rate = 44100;
let mut splitter = ThreeBandSplitter::new(200.0, 4000.0, sample_rate);
let len = 16384;
let input: Vec<f32> = (0..len)
.map(|i| {
let t = i as f32 / sample_rate as f32;
(2.0 * std::f32::consts::PI * 80.0 * t).sin() * 0.33
+ (2.0 * std::f32::consts::PI * 1000.0 * t).sin() * 0.33
+ (2.0 * std::f32::consts::PI * 8000.0 * t).sin() * 0.33
})
.collect();
let mut sub_bass = vec![0.0f32; len];
let mut mid = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
splitter.process(&input, &mut sub_bass, &mut mid, &mut high);
let settle = 1024;
let input_energy: f64 = input[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let sub_bass_energy: f64 = sub_bass[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let mid_energy: f64 = mid[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let high_energy: f64 = high[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let combined_energy = sub_bass_energy + mid_energy + high_energy;
let energy_ratio = combined_energy / input_energy;
assert!(
(0.7..1.3).contains(&energy_ratio),
"Three-band energy ratio {energy_ratio:.4} too far from 1.0 \
(sub={sub_bass_energy:.2}, mid={mid_energy:.2}, high={high_energy:.2}, input={input_energy:.2})"
);
}
#[test]
fn test_three_band_low_freq_routing() {
let sample_rate = 44100;
let mut splitter = ThreeBandSplitter::new(200.0, 4000.0, sample_rate);
let freq = 50.0; let len = 8192;
let input: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate as f32).sin())
.collect();
let mut sub_bass = vec![0.0f32; len];
let mut mid = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
splitter.process(&input, &mut sub_bass, &mut mid, &mut high);
let settle = 1024;
let sub_bass_energy: f32 = sub_bass[settle..].iter().map(|s| s * s).sum();
let mid_energy: f32 = mid[settle..].iter().map(|s| s * s).sum();
let high_energy: f32 = high[settle..].iter().map(|s| s * s).sum();
assert!(
sub_bass_energy > mid_energy * 10.0,
"50 Hz should be mostly in sub-bass, but sub_bass={sub_bass_energy:.4}, mid={mid_energy:.4}"
);
assert!(
sub_bass_energy > high_energy * 100.0,
"50 Hz should have negligible high energy"
);
}
#[test]
fn test_three_band_high_freq_routing() {
let sample_rate = 44100;
let mut splitter = ThreeBandSplitter::new(200.0, 4000.0, sample_rate);
let freq = 10000.0; let len = 8192;
let input: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate as f32).sin())
.collect();
let mut sub_bass = vec![0.0f32; len];
let mut mid = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
splitter.process(&input, &mut sub_bass, &mut mid, &mut high);
let settle = 1024;
let sub_bass_energy: f32 = sub_bass[settle..].iter().map(|s| s * s).sum();
let mid_energy: f32 = mid[settle..].iter().map(|s| s * s).sum();
let high_energy: f32 = high[settle..].iter().map(|s| s * s).sum();
assert!(
high_energy > mid_energy * 10.0,
"10 kHz should be mostly in high band, but high={high_energy:.4}, mid={mid_energy:.4}"
);
assert!(
high_energy > sub_bass_energy * 100.0,
"10 kHz should have negligible sub-bass energy"
);
}
#[test]
fn test_lr4_reset() {
let mut xover = LR4Crossover::new(1000.0, 44100);
for i in 0..100 {
xover.process_sample((i as f32 * 0.1).sin());
}
xover.reset();
let (low, high) = xover.process_sample(0.0);
assert!(low.abs() < 1e-10, "low should be ~0 after reset, got {low}");
assert!(
high.abs() < 1e-10,
"high should be ~0 after reset, got {high}"
);
}
#[test]
fn test_three_band_mid_freq_routing() {
let sample_rate = 44100;
let mut splitter = ThreeBandSplitter::new(200.0, 4000.0, sample_rate);
let freq = 1000.0; let len = 8192;
let input: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate as f32).sin())
.collect();
let mut sub_bass = vec![0.0f32; len];
let mut mid = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
splitter.process(&input, &mut sub_bass, &mut mid, &mut high);
let settle = 1024;
let sub_bass_energy: f32 = sub_bass[settle..].iter().map(|s| s * s).sum();
let mid_energy: f32 = mid[settle..].iter().map(|s| s * s).sum();
let high_energy: f32 = high[settle..].iter().map(|s| s * s).sum();
assert!(
mid_energy > sub_bass_energy * 10.0,
"1 kHz should be mostly in mid band, but mid={mid_energy:.4}, sub_bass={sub_bass_energy:.4}"
);
assert!(
mid_energy > high_energy * 10.0,
"1 kHz should be mostly in mid band, but mid={mid_energy:.4}, high={high_energy:.4}"
);
}
#[test]
fn test_lr8_crossover_energy_conservation() {
let sample_rate = 44100;
let crossover_freq = 1000.0;
let mut xover = LR8Crossover::new(crossover_freq, sample_rate);
let len = 16384;
let input: Vec<f32> = (0..len)
.map(|i| {
let t = i as f32 / sample_rate as f32;
(2.0 * std::f32::consts::PI * 200.0 * t).sin() * 0.33
+ (2.0 * std::f32::consts::PI * 1000.0 * t).sin() * 0.33
+ (2.0 * std::f32::consts::PI * 5000.0 * t).sin() * 0.33
})
.collect();
let mut low = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
xover.process(&input, &mut low, &mut high);
let settle = 1024;
let input_energy: f64 = input[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let low_energy: f64 = low[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let high_energy: f64 = high[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let combined_energy = low_energy + high_energy;
let energy_ratio = combined_energy / input_energy;
assert!(
(0.7..1.3).contains(&energy_ratio),
"LR8 energy ratio {energy_ratio:.4} too far from 1.0 (low={low_energy:.2}, high={high_energy:.2}, input={input_energy:.2})"
);
}
#[test]
fn test_lr8_steeper_rolloff_than_lr4() {
let sample_rate = 44100;
let crossover_freq = 1000.0;
let mut lr4 = LR4Crossover::new(crossover_freq, sample_rate);
let mut lr8 = LR8Crossover::new(crossover_freq, sample_rate);
let freq = 2000.0;
let len = 16384;
let input: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * freq * i as f32 / sample_rate as f32).sin())
.collect();
let mut lr4_low = vec![0.0f32; len];
let mut lr4_high = vec![0.0f32; len];
lr4.process(&input, &mut lr4_low, &mut lr4_high);
let mut lr8_low = vec![0.0f32; len];
let mut lr8_high = vec![0.0f32; len];
lr8.process(&input, &mut lr8_low, &mut lr8_high);
let settle = 2048;
let lr4_low_energy: f64 = lr4_low[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let lr8_low_energy: f64 = lr8_low[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
assert!(
lr8_low_energy < lr4_low_energy * 0.1,
"LR8 low-pass at 2x crossover should be much lower than LR4: LR8={lr8_low_energy:.6}, LR4={lr4_low_energy:.6}"
);
}
#[test]
fn test_linkwitz_riley8_sums_to_allpass_at_crossover() {
let sample_rate = 44_100;
let fc = 150.0;
let mut xover = LinkwitzRiley8::new(fc, sample_rate);
let len = 32_768;
let input: Vec<f32> = (0..len)
.map(|i| (2.0 * std::f32::consts::PI * fc as f32 * i as f32 / sample_rate as f32).sin())
.collect();
let mut low = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
xover.process(&input, &mut low, &mut high);
let settle = 8_192;
let sum_energy: f64 = (settle..len)
.map(|i| ((low[i] + high[i]) as f64).powi(2))
.sum();
let in_energy: f64 = input[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let level_db = 10.0 * (sum_energy / in_energy).log10();
assert!(
level_db.abs() < 0.1,
"LR8 re-sum at crossover: {level_db:+.3} dB (must be allpass)"
);
let low_energy: f64 = low[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let band_db = 10.0 * (low_energy / in_energy).log10();
assert!(
(band_db + 6.0).abs() < 0.3,
"LR8 band level at crossover: {band_db:+.2} dB, expected −6"
);
}
#[test]
fn test_linkwitz_riley8_slope_matches_lr8() {
let sample_rate = 44_100;
let fc = 1_000.0;
let mut xover = LinkwitzRiley8::new(fc, sample_rate);
let freq = 4_000.0; let len = 32_768;
let input: Vec<f32> = (0..len)
.map(|i| {
(2.0 * std::f32::consts::PI * freq as f32 * i as f32 / sample_rate as f32).sin()
})
.collect();
let mut low = vec![0.0f32; len];
let mut high = vec![0.0f32; len];
xover.process(&input, &mut low, &mut high);
let settle = 8_192;
let low_energy: f64 = low[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let in_energy: f64 = input[settle..].iter().map(|s| (*s as f64).powi(2)).sum();
let db = 10.0 * (low_energy / in_energy).log10();
assert!(
db < -70.0,
"LR8 low band only {db:.1} dB down two octaves above fc"
);
}
#[test]
fn test_lr8_reset() {
let mut xover = LR8Crossover::new(1000.0, 44100);
for i in 0..100 {
xover.process_sample((i as f32 * 0.1).sin());
}
xover.reset();
let (low, high) = xover.process_sample(0.0);
assert!(low.abs() < 1e-10, "low should be ~0 after reset, got {low}");
assert!(
high.abs() < 1e-10,
"high should be ~0 after reset, got {high}"
);
}
}