use biquad::{Biquad, Coefficients, DirectForm1, Type};
use bon::Builder;
use kithara_decode::PcmChunk;
use crate::AudioEffect;
struct Consts;
impl Consts {
const BAND_MAX_FREQ: f32 = 18000.0;
const BAND_MIN_FREQ: f32 = 60.0;
const BUTTERWORTH_Q: f32 = std::f32::consts::FRAC_1_SQRT_2;
const DB_DIVISOR: f32 = 20.0;
const HIGH_SHELF_DISCRIMINANT: u8 = 2;
const LOG_FREQ_BASE: f32 = 10.0;
const MIN_CROSSOVER_BANDS: usize = 2;
const MS_PER_SEC: f32 = 1000.0;
const NYQUIST_FACTOR: f32 = 2.0;
const PASSTHROUGH: Coefficients<f32> = Coefficients {
a1: 0.0,
a2: 0.0,
b0: 1.0,
b1: 0.0,
b2: 0.0,
};
const Q_REFERENCE_BANDS: f32 = 10.0;
const Q_SCALE_FACTOR: f32 = 1.4;
const SMOOTH_BLOCK_SIZE: usize = 32;
const SMOOTH_CONVERGENCE_THRESHOLD: f32 = 0.0001;
const SMOOTH_TIME_MS: f32 = 10.0;
const STEREO_CHANNELS: usize = 2;
}
pub const MAX_GAIN_DB: f32 = 6.0;
pub const MIN_GAIN_DB: f32 = -24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[repr(u8)]
pub enum FilterKind {
LowShelf,
#[default]
Peaking,
HighShelf,
}
impl From<u8> for FilterKind {
fn from(v: u8) -> Self {
match v {
0 => Self::LowShelf,
Consts::HIGH_SHELF_DISCRIMINANT => Self::HighShelf,
_ => Self::Peaking,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Builder)]
#[builder(state_mod(vis = "pub"))]
#[non_exhaustive]
pub struct EqBandConfig {
#[builder(default)]
pub kind: FilterKind,
#[builder(default = 1000.0)]
pub frequency: f32,
#[builder(default)]
pub gain_db: f32,
#[builder(default = std::f32::consts::FRAC_1_SQRT_2)]
pub q_factor: f32,
}
impl Default for EqBandConfig {
fn default() -> Self {
Self::builder().build()
}
}
#[must_use]
pub fn generate_log_spaced_bands(count: usize) -> Vec<EqBandConfig> {
use num_traits::cast::AsPrimitive;
if count == 0 {
return Vec::new();
}
let count_f32: f32 = count.as_();
let q_factor = Consts::Q_SCALE_FACTOR * (count_f32 / Consts::Q_REFERENCE_BANDS).sqrt();
if count == 1 {
return vec![EqBandConfig {
frequency: (Consts::BAND_MIN_FREQ * Consts::BAND_MAX_FREQ).sqrt(),
q_factor,
gain_db: 0.0,
kind: FilterKind::Peaking,
}];
}
let log_min = Consts::BAND_MIN_FREQ.log10();
let log_max = Consts::BAND_MAX_FREQ.log10();
let last_count_f32: f32 = (count - 1).as_();
let log_step = (log_max - log_min) / last_count_f32;
let last = count - 1;
(0..count)
.map(|i| {
let kind = if i == 0 {
FilterKind::LowShelf
} else if i == last {
FilterKind::HighShelf
} else {
FilterKind::Peaking
};
let i_f32: f32 = i.as_();
EqBandConfig {
q_factor,
kind,
frequency: Consts::LOG_FREQ_BASE.powf(log_min + i_f32 * log_step),
gain_db: 0.0,
}
})
.collect()
}
#[inline]
fn clamp_sample(sample: f32) -> f32 {
if sample.is_finite() { sample } else { 0.0 }
}
fn compute_bypass_active(gains: &[GainState]) -> bool {
!gains.is_empty()
&& gains.iter().all(|g| {
(g.target_linear - 1.0).abs() < f32::EPSILON
&& (g.current_linear - 1.0).abs() < f32::EPSILON
})
}
fn compute_silence_active(gains: &[GainState]) -> bool {
!gains.is_empty()
&& gains
.iter()
.all(|g| g.target_linear.abs() < f32::EPSILON && g.current_linear.abs() < f32::EPSILON)
}
#[inline]
fn db_to_linear(db: f32) -> f32 {
if db <= MIN_GAIN_DB {
0.0
} else {
Consts::LOG_FREQ_BASE.powf(db / Consts::DB_DIVISOR)
}
}
struct LR4 {
bq1: DirectForm1<f32>,
bq2: DirectForm1<f32>,
}
impl LR4 {
fn new(coeffs: Coefficients<f32>) -> Self {
Self {
bq1: DirectForm1::new(coeffs),
bq2: DirectForm1::new(coeffs),
}
}
#[inline]
fn process(&mut self, input: f32) -> f32 {
self.bq2.run(self.bq1.run(input))
}
}
fn biquad_coeffs(filter: Type<f32>, freq: f32, sample_rate: f32) -> Coefficients<f32> {
let normalized = Consts::NYQUIST_FACTOR * freq / sample_rate;
Coefficients::<f32>::from_normalized_params(filter, normalized, Consts::BUTTERWORTH_Q)
.unwrap_or(Consts::PASSTHROUGH)
}
struct GainState {
current_linear: f32,
target_db: f32,
target_linear: f32,
}
impl GainState {
fn new(gain_db: f32) -> Self {
let linear = db_to_linear(gain_db);
Self {
target_db: gain_db,
target_linear: linear,
current_linear: linear,
}
}
fn set_target(&mut self, gain_db: f32) {
let clamped = gain_db.clamp(MIN_GAIN_DB, MAX_GAIN_DB);
if (clamped - self.target_db).abs() < f32::EPSILON {
return;
}
self.target_db = clamped;
self.target_linear = db_to_linear(clamped);
}
#[inline]
fn smooth(&mut self, coeff: f32) {
let diff = self.target_linear - self.current_linear;
if diff.abs() < Consts::SMOOTH_CONVERGENCE_THRESHOLD {
self.current_linear = self.target_linear;
} else {
self.current_linear += coeff * diff;
}
}
}
fn compute_smooth_coeff(sample_rate: f32) -> f32 {
use num_traits::cast::AsPrimitive;
let tau = Consts::SMOOTH_TIME_MS / Consts::MS_PER_SEC;
let block_size_f32: f32 = Consts::SMOOTH_BLOCK_SIZE.as_();
let effective_rate = sample_rate / block_size_f32;
1.0 - (-1.0 / (tau * effective_rate)).exp()
}
pub struct IsolatorEq {
ap_filters: Vec<DirectForm1<f32>>,
ap_offsets: Vec<usize>,
bypass_history: Vec<f32>,
crossover_freqs: Vec<f32>,
gains: Vec<GainState>,
hps: Vec<LR4>,
lp_scratch: Vec<f32>,
lps: Vec<LR4>,
cached_bypass_active: bool,
cached_silence_active: bool,
was_in_fastpath: bool,
sample_rate: f32,
smooth_coeff: f32,
block_counter: usize,
bypass_history_pos: usize,
}
impl IsolatorEq {
const BYPASS_HISTORY_LEN: usize = 128;
#[must_use]
pub fn new(bands: &[EqBandConfig], sample_rate: u32) -> Self {
use num_traits::cast::AsPrimitive;
let sr: f32 = sample_rate.as_();
let n = bands.len();
let xover_count = n.saturating_sub(1);
let crossover_freqs: Vec<f32> = if n >= Consts::MIN_CROSSOVER_BANDS {
(0..xover_count)
.map(|i| (bands[i].frequency * bands[i + 1].frequency).sqrt())
.collect()
} else {
Vec::new()
};
let lps: Vec<LR4> = crossover_freqs
.iter()
.map(|&f| LR4::new(biquad_coeffs(Type::LowPass, f, sr)))
.collect();
let hps: Vec<LR4> = crossover_freqs
.iter()
.map(|&f| LR4::new(biquad_coeffs(Type::HighPass, f, sr)))
.collect();
let mut ap_filters = Vec::new();
let mut ap_offsets = Vec::with_capacity(n + 1);
for band in 0..n {
ap_offsets.push(ap_filters.len());
if band + 1 < xover_count {
for &f in &crossover_freqs[band + 1..] {
ap_filters.push(DirectForm1::new(biquad_coeffs(Type::AllPass, f, sr)));
}
}
}
ap_offsets.push(ap_filters.len());
let gains: Vec<GainState> = bands.iter().map(|b| GainState::new(b.gain_db)).collect();
let cached_silence_active = compute_silence_active(&gains);
let cached_bypass_active = compute_bypass_active(&gains);
Self {
lps,
hps,
ap_filters,
ap_offsets,
gains,
crossover_freqs,
cached_silence_active,
cached_bypass_active,
lp_scratch: vec![0.0; xover_count],
sample_rate: sr,
smooth_coeff: compute_smooth_coeff(sr),
block_counter: 0,
bypass_history: vec![0.0; Self::BYPASS_HISTORY_LEN],
bypass_history_pos: 0,
was_in_fastpath: false,
}
}
#[must_use]
pub fn band_count(&self) -> usize {
self.gains.len()
}
#[inline]
pub fn process_sample(&mut self, input: f32) -> f32 {
self.block_counter += 1;
if self.block_counter >= Consts::SMOOTH_BLOCK_SIZE {
self.block_counter = 0;
for gain in &mut self.gains {
gain.smooth(self.smooth_coeff);
}
self.refresh_fastpath_cache();
}
if self.cached_silence_active {
self.record_bypass_input(input);
self.was_in_fastpath = true;
return 0.0;
}
if self.cached_bypass_active {
self.record_bypass_input(input);
self.was_in_fastpath = true;
return input;
}
if self.was_in_fastpath {
self.was_in_fastpath = false;
self.rehydrate_filter_state();
}
let n = self.gains.len();
if n == 0 {
return input;
}
if n == 1 {
return clamp_sample(input * self.gains[0].current_linear);
}
let mut hp = input;
for i in 0..n - 1 {
self.lp_scratch[i] = self.lps[i].process(hp);
hp = self.hps[i].process(hp);
}
let mut output = 0.0;
for i in 0..n - 1 {
let mut band = self.lp_scratch[i];
let ap_start = self.ap_offsets[i];
let ap_end = self.ap_offsets[i + 1];
for ap in &mut self.ap_filters[ap_start..ap_end] {
band = ap.run(band);
}
output += band * self.gains[i].current_linear;
}
output += hp * self.gains[n - 1].current_linear;
clamp_sample(output)
}
fn rebuild_filters(&mut self) {
for (i, &freq) in self.crossover_freqs.iter().enumerate() {
self.lps[i] = LR4::new(biquad_coeffs(Type::LowPass, freq, self.sample_rate));
self.hps[i] = LR4::new(biquad_coeffs(Type::HighPass, freq, self.sample_rate));
}
for band in 0..self.gains.len() {
let start = self.ap_offsets[band];
let end = self.ap_offsets[band + 1];
for (j, ap) in self.ap_filters[start..end].iter_mut().enumerate() {
let freq = self.crossover_freqs[band + 1 + j];
*ap = DirectForm1::new(biquad_coeffs(Type::AllPass, freq, self.sample_rate));
}
}
}
fn record_bypass_input(&mut self, input: f32) {
let len = self.bypass_history.len();
self.bypass_history[self.bypass_history_pos] = input;
self.bypass_history_pos = (self.bypass_history_pos + 1) % len;
}
fn refresh_fastpath_cache(&mut self) {
self.cached_silence_active = compute_silence_active(&self.gains);
self.cached_bypass_active = compute_bypass_active(&self.gains);
}
fn rehydrate_filter_state(&mut self) {
let n = self.gains.len();
if n < Consts::MIN_CROSSOVER_BANDS {
return;
}
let len = self.bypass_history.len();
let start = self.bypass_history_pos;
for offset in 0..len {
let idx = (start + offset) % len;
let s = self.bypass_history[idx];
let mut hp = s;
for i in 0..n - 1 {
self.lp_scratch[i] = self.lps[i].process(hp);
hp = self.hps[i].process(hp);
}
for i in 0..n - 1 {
let mut band = self.lp_scratch[i];
let ap_start = self.ap_offsets[i];
let ap_end = self.ap_offsets[i + 1];
for ap in &mut self.ap_filters[ap_start..ap_end] {
band = ap.run(band);
}
}
}
}
pub fn reset(&mut self) {
for gain in &mut self.gains {
gain.target_db = 0.0;
gain.target_linear = 1.0;
gain.current_linear = 1.0;
}
self.rebuild_filters();
self.block_counter = 0;
self.bypass_history.fill(0.0);
self.bypass_history_pos = 0;
self.was_in_fastpath = false;
self.refresh_fastpath_cache();
}
pub fn set_gain(&mut self, band: usize, gain_db: f32) {
if let Some(state) = self.gains.get_mut(band) {
state.set_target(gain_db);
}
self.refresh_fastpath_cache();
}
#[must_use]
pub fn target_gain(&self, band: usize) -> Option<f32> {
self.gains.get(band).map(|s| s.target_db)
}
pub fn update_sample_rate(&mut self, sample_rate: u32) {
use num_traits::cast::AsPrimitive;
self.sample_rate = sample_rate.as_();
self.smooth_coeff = compute_smooth_coeff(self.sample_rate);
self.rebuild_filters();
}
}
pub struct EqEffect {
eq_l: IsolatorEq,
eq_r: IsolatorEq,
bands: Vec<EqBandConfig>,
channels: u16,
}
impl EqEffect {
#[must_use]
pub fn new(bands: Vec<EqBandConfig>, sample_rate: u32, channels: u16) -> Self {
let eq_l = IsolatorEq::new(&bands, sample_rate);
let eq_r = IsolatorEq::new(&bands, sample_rate);
Self {
eq_l,
eq_r,
bands,
channels,
}
}
#[must_use]
pub fn bands(&self) -> Vec<EqBandConfig> {
self.bands
.iter()
.enumerate()
.map(|(i, band)| EqBandConfig {
gain_db: self.eq_l.target_gain(i).unwrap_or(0.0),
..*band
})
.collect()
}
#[cfg(test)]
fn is_smoothing(&self) -> bool {
self.eq_l.gains.iter().any(|g| {
(g.target_linear - g.current_linear).abs() > Consts::SMOOTH_CONVERGENCE_THRESHOLD
})
}
pub fn set_gain(&mut self, band_index: usize, gain_db: f32) {
self.eq_l.set_gain(band_index, gain_db);
self.eq_r.set_gain(band_index, gain_db);
}
#[must_use]
pub fn target_gain(&self, band_index: usize) -> Option<f32> {
self.eq_l.target_gain(band_index)
}
}
impl AudioEffect for EqEffect {
fn flush(&mut self) -> Option<PcmChunk> {
None
}
fn process(&mut self, mut chunk: PcmChunk) -> Option<PcmChunk> {
let channels = self.channels as usize;
if channels == 0 {
return Some(chunk);
}
let samples = chunk.pcm.as_mut_slice();
for frame in samples.chunks_exact_mut(channels) {
frame[0] = self.eq_l.process_sample(frame[0]);
if channels >= Consts::STEREO_CHANNELS {
frame[1] = self.eq_r.process_sample(frame[1]);
}
}
Some(chunk)
}
fn reset(&mut self) {
self.eq_l.reset();
self.eq_r.reset();
}
}
#[cfg(test)]
mod tests {
use std::f32::consts::PI;
use kithara_bufpool::PcmPool;
use kithara_decode::{PcmMeta, PcmSpec};
use kithara_test_utils::kithara;
use super::*;
fn test_chunk(spec: PcmSpec, pcm: Vec<f32>) -> PcmChunk {
PcmChunk::new(
PcmMeta {
spec,
..Default::default()
},
PcmPool::default().attach(pcm),
)
}
#[kithara::test]
#[case(0, 0)]
#[case(1, 1)]
#[case(10, 10)]
fn generates_log_spaced_bands(#[case] count: usize, #[case] expected_len: usize) {
let bands = generate_log_spaced_bands(count);
assert_eq!(bands.len(), expected_len);
match count {
0 => assert!(bands.is_empty()),
1 => {
let expected = (Consts::BAND_MIN_FREQ * Consts::BAND_MAX_FREQ).sqrt();
assert!((bands[0].frequency - expected).abs() < 1.0);
}
10 => {
assert!((bands[0].frequency - Consts::BAND_MIN_FREQ).abs() < 1.0);
assert!((bands[9].frequency - 18000.0).abs() < 1.0);
for pair in bands.windows(2) {
assert!(pair[1].frequency > pair[0].frequency);
}
for band in &bands {
assert!((band.gain_db).abs() < f32::EPSILON);
}
}
_ => {}
}
}
#[kithara::test]
fn generate_bands_3_first_is_low_shelf() {
let bands = generate_log_spaced_bands(3);
assert_eq!(bands[0].kind, FilterKind::LowShelf);
}
#[kithara::test]
fn generate_bands_3_last_is_high_shelf() {
let bands = generate_log_spaced_bands(3);
assert_eq!(bands[2].kind, FilterKind::HighShelf);
}
#[kithara::test]
fn generate_bands_3_mid_is_peaking() {
let bands = generate_log_spaced_bands(3);
assert_eq!(bands[1].kind, FilterKind::Peaking);
}
#[kithara::test]
fn generate_bands_10_edges_are_shelves() {
let bands = generate_log_spaced_bands(10);
assert_eq!(bands[0].kind, FilterKind::LowShelf);
assert_eq!(bands[9].kind, FilterKind::HighShelf);
for band in &bands[1..9] {
assert_eq!(band.kind, FilterKind::Peaking);
}
}
#[kithara::test]
fn filter_kind_default_is_peaking() {
assert_eq!(FilterKind::default(), FilterKind::Peaking);
}
#[kithara::test]
fn eq_band_config_has_filter_kind() {
let band = EqBandConfig::default();
assert_eq!(band.kind, FilterKind::Peaking);
}
#[kithara::test]
fn three_band_crossover_near_250_and_2500() {
let bands = generate_log_spaced_bands(3);
let xover_low = (bands[0].frequency * bands[1].frequency).sqrt();
let xover_high = (bands[1].frequency * bands[2].frequency).sqrt();
assert!(
(200.0..350.0).contains(&xover_low),
"low crossover {xover_low:.0} Hz should be near 250 Hz"
);
assert!(
(2000.0..6000.0).contains(&xover_high),
"high crossover {xover_high:.0} Hz should be near 2500 Hz"
);
}
#[kithara::test]
fn eq_flat_gain_preserves_magnitude() {
let bands = generate_log_spaced_bands(10);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
let warmup = vec![0.0f32; 4096];
let _ = eq.process(test_chunk(spec, warmup));
let num_frames: u16 = 44100;
let pcm: Vec<f32> = (0..num_frames)
.map(|i| (2.0 * PI * 1000.0 * f32::from(i) / 44100.0).sin())
.collect();
let input_rms: f32 =
(pcm.iter().map(|s| s * s).sum::<f32>() / f32::from(num_frames)).sqrt();
let chunk = test_chunk(spec, pcm);
let output = eq.process(chunk).unwrap();
let out = &output.pcm[..];
let steady = &out[4096..];
let steady_len = u16::try_from(steady.len()).expect("test fixture steady < u16::MAX");
let output_rms: f32 =
(steady.iter().map(|s| s * s).sum::<f32>() / f32::from(steady_len)).sqrt();
let gain = output_rms / input_rms;
assert!(
(gain - 1.0).abs() < 0.05,
"Unity gain should preserve magnitude, got gain={gain:.4}"
);
}
#[kithara::test]
fn eq_set_gain_clamps() {
let bands = generate_log_spaced_bands(3);
let mut eq = EqEffect::new(bands, 44100, 2);
eq.set_gain(0, 100.0);
assert!((eq.target_gain(0).unwrap() - MAX_GAIN_DB).abs() < f32::EPSILON);
eq.set_gain(0, -100.0);
assert!((eq.target_gain(0).unwrap() - MIN_GAIN_DB).abs() < f32::EPSILON);
eq.set_gain(0, 3.0);
assert!((eq.target_gain(0).unwrap() - 3.0).abs() < f32::EPSILON);
}
#[kithara::test]
fn eq_set_gain_out_of_bounds_band_is_noop() {
let bands = generate_log_spaced_bands(3);
let mut eq = EqEffect::new(bands, 44100, 2);
eq.set_gain(99, 5.0);
for i in 0..3 {
assert!(eq.target_gain(i).unwrap().abs() < f32::EPSILON);
}
}
#[kithara::test]
fn eq_reset_clears_gains_and_history() {
let bands = generate_log_spaced_bands(3);
let mut eq = EqEffect::new(bands, 44100, 2);
eq.set_gain(0, 6.0);
let spec = PcmSpec {
channels: 2,
sample_rate: 44100,
};
let pcm = vec![0.5f32; 256];
let chunk = test_chunk(spec, pcm);
let _ = eq.process(chunk);
eq.reset();
for i in 0..3 {
assert!(
eq.target_gain(i).unwrap().abs() < f32::EPSILON,
"target should be 0 after reset"
);
}
}
#[kithara::test]
fn eq_single_band_kill() {
let bands = vec![EqBandConfig {
frequency: 1000.0,
..Default::default()
}];
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(0, MIN_GAIN_DB);
converge_smoother(&mut eq, spec);
let gain = measure_sine_gain(&mut eq, 1000.0, spec);
assert!(
gain < 0.001,
"single band at min should be killed, got gain={gain:.6}"
);
}
#[kithara::test]
fn eq_3band_kill_low() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(0, MIN_GAIN_DB);
converge_smoother(&mut eq, spec);
let gain_bass = measure_sine_gain(&mut eq, 40.0, spec);
let gain_treble = measure_sine_gain(&mut eq, 10000.0, spec);
assert!(
gain_bass < 0.05,
"bass should be killed, got {gain_bass:.4}"
);
assert!(
gain_treble > 0.8,
"treble should pass, got {gain_treble:.4}"
);
}
#[kithara::test]
fn eq_3band_kill_high() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(2, MIN_GAIN_DB);
converge_smoother(&mut eq, spec);
let gain_treble = measure_sine_gain(&mut eq, 15000.0, spec);
let gain_bass = measure_sine_gain(&mut eq, 40.0, spec);
assert!(
gain_treble < 0.05,
"treble should be killed, got {gain_treble:.4}"
);
assert!(gain_bass > 0.8, "bass should pass, got {gain_bass:.4}");
}
#[kithara::test]
fn eq_3band_kill_all_produces_silence() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
for i in 0..3 {
eq.set_gain(i, MIN_GAIN_DB);
}
converge_smoother(&mut eq, spec);
for freq in [40.0, 1000.0, 10000.0] {
let gain = measure_sine_gain(&mut eq, freq, spec);
assert!(
gain < 0.001,
"all bands killed: {freq}Hz gain should be ~0, got {gain:.6}"
);
}
}
#[kithara::test]
fn eq_low_shelf_boosts_bass() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(0, MAX_GAIN_DB);
converge_smoother(&mut eq, spec);
let gain_bass = measure_sine_gain(&mut eq, 40.0, spec);
assert!(
gain_bass > 1.5,
"40Hz should be boosted, got gain={gain_bass:.3}"
);
}
#[kithara::test]
fn eq_high_shelf_boosts_treble() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(2, MAX_GAIN_DB);
converge_smoother(&mut eq, spec);
let gain_treble = measure_sine_gain(&mut eq, 15000.0, spec);
assert!(
gain_treble > 1.5,
"15kHz should be boosted, got gain={gain_treble:.3}"
);
}
#[kithara::test]
fn eq_gain_change_starts_smoothing() {
let bands = generate_log_spaced_bands(3);
let mut eq = EqEffect::new(bands, 44100, 2);
assert!(!eq.is_smoothing(), "should not be smoothing initially");
eq.set_gain(0, 6.0);
assert!(eq.is_smoothing(), "should be smoothing after set_gain");
}
#[kithara::test]
fn eq_smooth_gain_converges() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(0, 6.0);
converge_smoother(&mut eq, spec);
assert!(
!eq.is_smoothing(),
"should have converged after sufficient processing"
);
}
#[kithara::test]
fn eq_smooth_no_discontinuity() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
let warmup: Vec<f32> = (0u16..4096)
.map(|i| (2.0 * PI * 1000.0 * f32::from(i) / 44100.0).sin())
.collect();
let chunk = test_chunk(spec, warmup);
let _ = eq.process(chunk);
eq.set_gain(0, MAX_GAIN_DB);
let signal: Vec<f32> = (0u16..4096)
.map(|i| (2.0 * PI * 1000.0 * f32::from(i + 4096) / 44100.0).sin())
.collect();
let chunk = test_chunk(spec, signal);
let output = eq.process(chunk).unwrap();
let out = &output.pcm[..];
let max_diff = out
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.fold(0.0f32, f32::max);
assert!(
max_diff < 0.5,
"Discontinuity detected: max sample diff = {max_diff:.4}"
);
}
#[kithara::test]
#[case(2, 256, Some((2, 3.0)))]
#[case(1, 128, None)]
fn eq_process_supported_channel_layouts(
#[case] channels: u16,
#[case] sample_len: usize,
#[case] gain: Option<(usize, f32)>,
) {
let bands = generate_log_spaced_bands(5);
let spec = PcmSpec {
channels,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
if let Some((band, gain_db)) = gain {
eq.set_gain(band, gain_db);
}
let pcm = vec![0.5f32; sample_len];
let chunk = test_chunk(spec, pcm);
let result = eq.process(chunk);
assert!(result.is_some());
assert_eq!(result.unwrap().pcm.len(), sample_len);
}
#[kithara::test]
fn eq_flush_returns_none() {
let bands = generate_log_spaced_bands(3);
let mut eq = EqEffect::new(bands, 44100, 2);
assert!(eq.flush().is_none());
}
#[kithara::test]
fn eq_output_never_nan_or_inf() {
let bands = generate_log_spaced_bands(10);
let spec = PcmSpec {
channels: 2,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
for round in 0..100 {
let gain = if round % 2 == 0 {
MAX_GAIN_DB
} else {
MIN_GAIN_DB
};
for band in 0..10 {
eq.set_gain(band, gain);
}
let pcm: Vec<f32> = (0u16..1024).map(|i| (f32::from(i) * 0.1).sin()).collect();
let chunk = test_chunk(spec, pcm);
let output = eq.process(chunk).unwrap();
for (i, &s) in output.pcm.iter().enumerate() {
assert!(s.is_finite(), "round {round} sample {i}: got {s}");
}
}
}
#[kithara::test]
fn eq_nan_input_produces_safe_output() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq.set_gain(0, 6.0);
converge_smoother(&mut eq, spec);
let mut pcm = vec![0.5f32; 256];
pcm[10] = f32::NAN;
pcm[20] = f32::INFINITY;
pcm[30] = f32::NEG_INFINITY;
let chunk = test_chunk(spec, pcm);
let output = eq.process(chunk).unwrap();
for (i, &s) in output.pcm.iter().enumerate() {
assert!(s.is_finite(), "sample {i}: got {s}");
}
}
#[kithara::test]
fn eq_extreme_gain_oscillation_stays_safe() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 2,
sample_rate: 44100,
};
let mut eq = EqEffect::new(bands, spec.sample_rate, spec.channels);
for round in 0..200 {
let gain = if round % 2 == 0 {
MAX_GAIN_DB
} else {
MIN_GAIN_DB
};
eq.set_gain(0, gain);
eq.set_gain(1, -gain);
eq.set_gain(2, gain);
let pcm: Vec<f32> = (0u16..512).map(|i| (f32::from(i) * 0.3).sin()).collect();
let chunk = test_chunk(spec, pcm);
let output = eq.process(chunk).unwrap();
for &s in &output.pcm[..] {
assert!(s.is_finite());
}
}
}
#[kithara::test]
fn butterworth_lp_dc_gain_is_unity() {
let coeffs = biquad_coeffs(Type::LowPass, 250.0, 44100.0);
let dc_gain = (coeffs.b0 + coeffs.b1 + coeffs.b2) / (1.0 + coeffs.a1 + coeffs.a2);
assert!(
(dc_gain - 1.0).abs() < 0.001,
"LP DC gain should be 1.0, got {dc_gain}"
);
}
#[kithara::test]
fn db_to_linear_kill_at_min() {
assert!((db_to_linear(MIN_GAIN_DB)).abs() < f32::EPSILON);
assert!((db_to_linear(-30.0)).abs() < f32::EPSILON);
}
#[kithara::test]
#[case::unity_at_zero(0.0, 1.0, 0.001)]
#[case::boost_at_6db(6.0, 2.0, 0.02)]
fn db_to_linear_maps_to_gain(#[case] db: f32, #[case] expected_gain: f32, #[case] eps: f32) {
let gain = db_to_linear(db);
assert!(
(gain - expected_gain).abs() < eps,
"{db}dB should be ~{expected_gain}, got {gain}"
);
}
#[kithara::test]
fn eq_fresh_at_zero_db_is_bypass_active() {
let bands = generate_log_spaced_bands(3);
let eq = IsolatorEq::new(&bands, 44100);
assert!(
eq.cached_bypass_active,
"default 0 dB bands should activate bypass so the LR-4 chain \
never runs for users who never touch the EQ"
);
}
#[kithara::test]
fn eq_bypass_deactivates_on_gain_change() {
let bands = generate_log_spaced_bands(3);
let mut eq = IsolatorEq::new(&bands, 44100);
assert!(
eq.cached_bypass_active,
"precondition: fresh EQ is in bypass"
);
eq.set_gain(0, 3.0);
assert!(
!eq.cached_bypass_active,
"bypass must deactivate the instant any band targets a non-unity \
gain, so the next sample reaches the actual filter chain"
);
}
#[kithara::test]
fn eq_bypass_reactivates_after_return_to_unity() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq_effect = EqEffect::new(bands, spec.sample_rate, spec.channels);
eq_effect.set_gain(0, 6.0);
converge_smoother(&mut eq_effect, spec);
assert!(!eq_effect.eq_l.cached_bypass_active);
eq_effect.set_gain(0, 0.0);
converge_smoother(&mut eq_effect, spec);
converge_smoother(&mut eq_effect, spec);
assert!(
eq_effect.eq_l.cached_bypass_active,
"after gains smooth back to unity, bypass must reactivate so the \
filter chain stops running"
);
}
#[kithara::test]
fn eq_bypass_returns_input_unchanged() {
let bands = generate_log_spaced_bands(3);
let mut eq = IsolatorEq::new(&bands, 44100);
assert!(eq.cached_bypass_active, "precondition: bypass is active");
let inputs = [0.0_f32, 0.25, -0.5, 0.999, -0.999, 1e-6, -1e-6];
for &input in &inputs {
let output = eq.process_sample(input);
assert_eq!(
output, input,
"bypass must return input bit-for-bit, got {output} for {input}"
);
}
}
#[kithara::test]
fn eq_all_min_gain_after_smoothing_is_silence_active() {
let bands = generate_log_spaced_bands(3);
let spec = PcmSpec {
channels: 1,
sample_rate: 44100,
};
let mut eq_effect = EqEffect::new(bands, spec.sample_rate, spec.channels);
for i in 0..3 {
eq_effect.set_gain(i, MIN_GAIN_DB);
}
converge_smoother(&mut eq_effect, spec);
assert!(
eq_effect.eq_l.cached_silence_active,
"all bands at MIN_GAIN_DB after smoother converges must activate \
the silence fast path so the filter chain is skipped entirely"
);
}
#[kithara::test]
fn eq_silence_returns_zero() {
let bands = generate_log_spaced_bands(3);
let mut eq = IsolatorEq::new(&bands, 44100);
for i in 0..3 {
eq.set_gain(i, MIN_GAIN_DB);
eq.gains[i].current_linear = 0.0;
}
eq.refresh_fastpath_cache();
assert!(eq.cached_silence_active, "precondition: silence is active");
let inputs = [0.0_f32, 0.25, -0.5, 0.999, -0.999];
for &input in &inputs {
let output = eq.process_sample(input);
assert_eq!(
output, 0.0,
"silence must return literal 0.0 for any input, got {output} \
for {input}"
);
}
}
#[kithara::test]
fn eq_silence_deactivates_when_any_band_raised() {
let bands = generate_log_spaced_bands(3);
let mut eq = IsolatorEq::new(&bands, 44100);
for i in 0..3 {
eq.set_gain(i, MIN_GAIN_DB);
eq.gains[i].current_linear = 0.0;
}
eq.refresh_fastpath_cache();
assert!(eq.cached_silence_active, "precondition: silence is active");
eq.set_gain(1, -3.0);
assert!(
!eq.cached_silence_active,
"raising any band above MIN_GAIN_DB must disable silence so the \
filter chain re-engages via smoother ramp-up"
);
}
fn converge_smoother(eq: &mut EqEffect, spec: PcmSpec) {
let frames = (spec.sample_rate as usize) / 5;
let pcm = vec![0.0f32; frames * spec.channels as usize];
let chunk = test_chunk(spec, pcm);
let _ = eq.process(chunk);
}
#[expect(
clippy::cast_precision_loss,
reason = "frame count and index are small integers"
)]
fn measure_sine_gain(eq: &mut EqEffect, freq_hz: f32, spec: PcmSpec) -> f32 {
let num_frames = 44100;
let mut pcm = Vec::with_capacity(num_frames);
for i in 0..num_frames {
let sample = (2.0 * PI * freq_hz * i as f32 / spec.sample_rate as f32).sin();
pcm.push(sample);
}
let input_rms: f32 = (pcm.iter().map(|s| s * s).sum::<f32>() / num_frames as f32).sqrt();
let chunk = test_chunk(spec, pcm);
let output = eq.process(chunk).unwrap();
let out = &output.pcm[..];
let steady = &out[4096..];
let output_rms: f32 =
(steady.iter().map(|s| s * s).sum::<f32>() / steady.len() as f32).sqrt();
output_rms / input_rms
}
}