2026-08-31 22:01:51 +09:00
|
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
2026-06-17 20:04:24 +09:00
|
|
|
//! 3-band Linkwitz-Riley (LR4, 24 dB/oct) crossover filterbank.
|
|
|
|
|
//!
|
|
|
|
|
//! Each crossover splits into a low-passed band output and a high-passed remainder that feeds
|
|
|
|
|
//! the next crossover. Because higher bands pass through more filters, lower bands are phase-
|
|
|
|
|
//! compensated with an all-pass at every *later* crossover frequency so the three bands sum back
|
|
|
|
|
//! to flat **magnitude** (the sum is an all-pass of the input — phase-shifted, not bit-identical,
|
|
|
|
|
//! which is inherent to IIR Linkwitz-Riley). Approach mirrors NIH-plug's `crossover` plugin.
|
|
|
|
|
//!
|
|
|
|
|
//! For 3 bands there are two crossovers (low/mid at `f_lo`, mid/high at `f_hi`); only the low
|
|
|
|
|
//! band needs compensation (one all-pass at `f_hi`).
|
|
|
|
|
|
|
|
|
|
use super::biquad::{Biquad, BiquadCoefficients, NEUTRAL_Q};
|
|
|
|
|
|
|
|
|
|
/// Mono/stereo only, matching the plugin's audio layouts.
|
|
|
|
|
const MAX_CHANNELS: usize = 2;
|
|
|
|
|
|
|
|
|
|
/// One channel's worth of filter state for the 3-band split.
|
|
|
|
|
#[derive(Clone, Copy, Default)]
|
|
|
|
|
struct BandSplitter {
|
|
|
|
|
lp_lo: [Biquad; 2], // LR4 low-pass at f_lo (two cascaded Butterworth)
|
|
|
|
|
hp_lo: [Biquad; 2], // LR4 high-pass at f_lo
|
|
|
|
|
lp_hi: [Biquad; 2], // LR4 low-pass at f_hi
|
|
|
|
|
hp_hi: [Biquad; 2], // LR4 high-pass at f_hi
|
|
|
|
|
ap_low: Biquad, // all-pass at f_hi, phase-compensates the low band
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl BandSplitter {
|
|
|
|
|
/// Split one sample into `[low, mid, high]`.
|
|
|
|
|
fn split(&mut self, x: f32) -> [f32; 3] {
|
|
|
|
|
// Crossover at f_lo: low-passed band + high-passed remainder.
|
|
|
|
|
let mut lp = x;
|
|
|
|
|
for f in &mut self.lp_lo {
|
|
|
|
|
lp = f.process(lp);
|
|
|
|
|
}
|
|
|
|
|
let mut hp = x;
|
|
|
|
|
for f in &mut self.hp_lo {
|
|
|
|
|
hp = f.process(hp);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Low band is phase-compensated for the f_hi crossover the upper bands pass through.
|
|
|
|
|
let low = self.ap_low.process(lp);
|
|
|
|
|
|
|
|
|
|
// Crossover at f_hi splits the remainder into mid + high.
|
|
|
|
|
let mut mid = hp;
|
|
|
|
|
for f in &mut self.lp_hi {
|
|
|
|
|
mid = f.process(mid);
|
|
|
|
|
}
|
|
|
|
|
let mut high = hp;
|
|
|
|
|
for f in &mut self.hp_hi {
|
|
|
|
|
high = f.process(high);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
[low, mid, high]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn set_coefficients(
|
|
|
|
|
&mut self,
|
|
|
|
|
lp_lo: BiquadCoefficients,
|
|
|
|
|
hp_lo: BiquadCoefficients,
|
|
|
|
|
lp_hi: BiquadCoefficients,
|
|
|
|
|
hp_hi: BiquadCoefficients,
|
|
|
|
|
ap_low: BiquadCoefficients,
|
|
|
|
|
) {
|
|
|
|
|
for f in &mut self.lp_lo {
|
|
|
|
|
f.set_coefficients(lp_lo);
|
|
|
|
|
}
|
|
|
|
|
for f in &mut self.hp_lo {
|
|
|
|
|
f.set_coefficients(hp_lo);
|
|
|
|
|
}
|
|
|
|
|
for f in &mut self.lp_hi {
|
|
|
|
|
f.set_coefficients(lp_hi);
|
|
|
|
|
}
|
|
|
|
|
for f in &mut self.hp_hi {
|
|
|
|
|
f.set_coefficients(hp_hi);
|
|
|
|
|
}
|
|
|
|
|
self.ap_low.set_coefficients(ap_low);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn reset(&mut self) {
|
|
|
|
|
for f in self
|
|
|
|
|
.lp_lo
|
|
|
|
|
.iter_mut()
|
|
|
|
|
.chain(&mut self.hp_lo)
|
|
|
|
|
.chain(&mut self.lp_hi)
|
|
|
|
|
.chain(&mut self.hp_hi)
|
|
|
|
|
{
|
|
|
|
|
f.reset();
|
|
|
|
|
}
|
|
|
|
|
self.ap_low.reset();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct Crossover {
|
|
|
|
|
channels: usize,
|
|
|
|
|
splitters: [BandSplitter; MAX_CHANNELS],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for Crossover {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
channels: 2,
|
|
|
|
|
splitters: [BandSplitter::default(); MAX_CHANNELS],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Crossover {
|
|
|
|
|
pub fn new() -> Self {
|
|
|
|
|
Self::default()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Set the active channel count and clear state. Call from `initialize()`.
|
|
|
|
|
pub fn prepare(&mut self, channels: usize) {
|
|
|
|
|
self.channels = channels.clamp(1, MAX_CHANNELS);
|
|
|
|
|
self.reset();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Recompute and apply crossover coefficients. Cheap enough to call once per block.
|
|
|
|
|
/// Frequencies are clamped to a valid range and forced monotonic (`f_lo <= f_hi`).
|
|
|
|
|
pub fn update(&mut self, sample_rate: f32, low_hz: f32, high_hz: f32) {
|
|
|
|
|
let max_hz = sample_rate * 0.49;
|
|
|
|
|
let f_lo = low_hz.clamp(20.0, max_hz);
|
|
|
|
|
let f_hi = high_hz.clamp(f_lo, max_hz);
|
|
|
|
|
|
|
|
|
|
let lp_lo = BiquadCoefficients::lowpass(sample_rate, f_lo, NEUTRAL_Q);
|
|
|
|
|
let hp_lo = BiquadCoefficients::highpass(sample_rate, f_lo, NEUTRAL_Q);
|
|
|
|
|
let lp_hi = BiquadCoefficients::lowpass(sample_rate, f_hi, NEUTRAL_Q);
|
|
|
|
|
let hp_hi = BiquadCoefficients::highpass(sample_rate, f_hi, NEUTRAL_Q);
|
|
|
|
|
let ap_low = BiquadCoefficients::allpass(sample_rate, f_hi, NEUTRAL_Q);
|
|
|
|
|
|
|
|
|
|
for s in &mut self.splitters {
|
|
|
|
|
s.set_coefficients(lp_lo, hp_lo, lp_hi, hp_hi, ap_low);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn reset(&mut self) {
|
|
|
|
|
for s in &mut self.splitters {
|
|
|
|
|
s.reset();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Split one sample of `channel` into `[low, mid, high]`.
|
|
|
|
|
#[inline]
|
|
|
|
|
pub fn split(&mut self, channel: usize, x: f32) -> [f32; 3] {
|
|
|
|
|
self.splitters[channel].split(x)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
use std::f32::consts::TAU;
|
|
|
|
|
|
|
|
|
|
const SR: f32 = 48_000.0;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bands_sum_to_flat_magnitude() {
|
|
|
|
|
// LR4 bands sum to an all-pass: the magnitude is flat at every frequency (including the
|
|
|
|
|
// crossovers), even though the time-domain signal is phase-shifted (so it is NOT a
|
|
|
|
|
// bit-exact null — that only holds for linear-phase FIR crossovers).
|
|
|
|
|
let mut xo = Crossover::new();
|
|
|
|
|
xo.prepare(1);
|
|
|
|
|
xo.update(SR, 200.0, 2_500.0);
|
|
|
|
|
|
|
|
|
|
for &f in &[50.0, 200.0, 1_000.0, 2_500.0, 9_000.0] {
|
|
|
|
|
xo.reset();
|
|
|
|
|
let n = 24_000usize;
|
|
|
|
|
let (mut in_acc, mut out_acc) = (0.0f64, 0.0f64);
|
|
|
|
|
for i in 0..n {
|
|
|
|
|
let x = (TAU * f * i as f32 / SR).sin();
|
|
|
|
|
let [lo, mid, hi] = xo.split(0, x);
|
|
|
|
|
let y = lo + mid + hi;
|
|
|
|
|
if i >= n - 8_000 {
|
|
|
|
|
in_acc += (x * x) as f64;
|
|
|
|
|
out_acc += (y * y) as f64;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let ratio = (out_acc / in_acc).sqrt() as f32;
|
|
|
|
|
assert!(
|
|
|
|
|
(ratio - 1.0).abs() < 0.06,
|
|
|
|
|
"reconstruction not flat at {f} Hz: {ratio}"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn bands_are_actually_split() {
|
|
|
|
|
// Sanity: the low band should keep lows and reject highs; the high band vice versa.
|
|
|
|
|
fn band_energy(band: usize, freq: f32) -> f64 {
|
|
|
|
|
let mut xo = Crossover::new();
|
|
|
|
|
xo.prepare(1);
|
|
|
|
|
xo.update(SR, 200.0, 2_500.0);
|
|
|
|
|
let n = 24_000usize;
|
|
|
|
|
let mut acc = 0.0f64;
|
|
|
|
|
for i in 0..n {
|
|
|
|
|
let x = (TAU * freq * i as f32 / SR).sin();
|
|
|
|
|
let bands = xo.split(0, x);
|
|
|
|
|
if i >= n - 8_000 {
|
|
|
|
|
acc += (bands[band] * bands[band]) as f64;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
acc
|
|
|
|
|
}
|
|
|
|
|
assert!(band_energy(0, 50.0) > band_energy(0, 9_000.0) * 100.0); // low band: lows >> highs
|
|
|
|
|
assert!(band_energy(2, 9_000.0) > band_energy(2, 50.0) * 100.0); // high band: highs >> lows
|
|
|
|
|
}
|
|
|
|
|
}
|