use crate::graph::{
GroupHint, ParamDescriptor, ParamFlags, ParamGroup, ParamUnit, ProcessContext, Processor,
ProcessorInfo, Sig,
};
use std::f64::consts::PI;
pub struct ParametricEq {
bands: [EqBand; 3],
state_l: [BiquadState; 3],
state_r: [BiquadState; 3],
sample_rate: f64,
dirty: bool,
}
#[derive(Clone, Copy)]
struct EqBand {
freq: f64,
gain_db: f64,
q: f64,
}
#[derive(Clone, Copy)]
struct BiquadCoeffs {
b0: f64,
b1: f64,
b2: f64,
a1: f64,
a2: f64,
}
#[derive(Clone, Copy, Default)]
struct BiquadState {
x1: f64,
x2: f64,
y1: f64,
y2: f64,
}
impl BiquadState {
fn tick(&mut self, x: f64, c: &BiquadCoeffs) -> f64 {
let y = c.b0 * x + c.b1 * self.x1 + c.b2 * self.x2 - c.a1 * self.y1 - c.a2 * self.y2;
self.x2 = self.x1;
self.x1 = x;
self.y2 = self.y1;
self.y1 = y;
y
}
fn reset(&mut self) {
*self = Self::default();
}
}
fn compute_peaking(freq: f64, gain_db: f64, q: f64, sr: f64) -> BiquadCoeffs {
let a = 10.0f64.powf(gain_db / 40.0);
let w0 = 2.0 * PI * freq / sr;
let alpha = w0.sin() / (2.0 * q);
let a0 = 1.0 + alpha / a;
BiquadCoeffs {
b0: (1.0 + alpha * a) / a0,
b1: (-2.0 * w0.cos()) / a0,
b2: (1.0 - alpha * a) / a0,
a1: (-2.0 * w0.cos()) / a0,
a2: (1.0 - alpha / a) / a0,
}
}
impl ParametricEq {
pub fn new() -> Self {
Self {
bands: [
EqBand {
freq: 200.0,
gain_db: 0.0,
q: 0.707,
},
EqBand {
freq: 1000.0,
gain_db: 0.0,
q: 0.707,
},
EqBand {
freq: 5000.0,
gain_db: 0.0,
q: 0.707,
},
],
state_l: [BiquadState::default(); 3],
state_r: [BiquadState::default(); 3],
sample_rate: 44100.0,
dirty: true,
}
}
pub fn with_bands(lo_freq: f64, mid_freq: f64, hi_freq: f64) -> Self {
let mut eq = Self::new();
eq.bands[0].freq = lo_freq;
eq.bands[1].freq = mid_freq;
eq.bands[2].freq = hi_freq;
eq
}
fn coeffs(&self) -> [BiquadCoeffs; 3] {
[
compute_peaking(
self.bands[0].freq,
self.bands[0].gain_db,
self.bands[0].q,
self.sample_rate,
),
compute_peaking(
self.bands[1].freq,
self.bands[1].gain_db,
self.bands[1].q,
self.sample_rate,
),
compute_peaking(
self.bands[2].freq,
self.bands[2].gain_db,
self.bands[2].q,
self.sample_rate,
),
]
}
}
impl Default for ParametricEq {
fn default() -> Self {
Self::new()
}
}
impl Processor for ParametricEq {
fn info(&self) -> ProcessorInfo {
ProcessorInfo {
name: "eq",
sig: Sig::STEREO,
description: "3-band parametric equalizer",
}
}
fn process(&mut self, ctx: &mut ProcessContext) {
if self.sample_rate != ctx.sample_rate {
self.sample_rate = ctx.sample_rate;
self.dirty = true;
}
let coeffs = self.coeffs();
for i in 0..ctx.frames {
let mut l = ctx.inputs[0][i] as f64;
let mut r = ctx.inputs[1][i] as f64;
for band in 0..3 {
l = self.state_l[band].tick(l, &coeffs[band]);
r = self.state_r[band].tick(r, &coeffs[band]);
}
ctx.outputs[0][i] = l as f32;
ctx.outputs[1][i] = r as f32;
}
self.dirty = false;
}
fn reset(&mut self) {
for s in &mut self.state_l {
s.reset();
}
for s in &mut self.state_r {
s.reset();
}
}
fn params(&self) -> Vec<ParamDescriptor> {
let mut p = Vec::with_capacity(9);
for band in 0..3 {
let base = band as u32 * 3;
let gid = Some(band as u32);
p.push(ParamDescriptor {
id: base,
name: match band {
0 => "Lo Freq",
1 => "Mid Freq",
_ => "Hi Freq",
},
min: 20.0,
max: 20000.0,
default: self.bands[band].freq,
unit: ParamUnit::Hertz,
flags: ParamFlags::LOG_SCALE,
step: 50.0,
group: gid,
help: "",
});
p.push(ParamDescriptor {
id: base + 1,
name: match band {
0 => "Lo Gain",
1 => "Mid Gain",
_ => "Hi Gain",
},
min: -24.0,
max: 24.0,
default: 0.0,
unit: ParamUnit::Decibels,
flags: ParamFlags::BIPOLAR,
step: 0.5,
group: gid,
help: "",
});
p.push(ParamDescriptor {
id: base + 2,
name: match band {
0 => "Lo Q",
1 => "Mid Q",
_ => "Hi Q",
},
min: 0.1,
max: 10.0,
default: 0.707,
unit: ParamUnit::Linear,
flags: ParamFlags::LOG_SCALE,
step: 0.1,
group: gid,
help: "",
});
}
p
}
fn param_groups(&self) -> Vec<ParamGroup> {
vec![
ParamGroup {
id: 0,
name: "Lo Band",
hint: GroupHint::Filter,
},
ParamGroup {
id: 1,
name: "Mid Band",
hint: GroupHint::Filter,
},
ParamGroup {
id: 2,
name: "Hi Band",
hint: GroupHint::Filter,
},
]
}
fn get_param(&self, id: u32) -> f64 {
let band = (id / 3) as usize;
let field = id % 3;
if band >= 3 {
return 0.0;
}
match field {
0 => self.bands[band].freq,
1 => self.bands[band].gain_db,
2 => self.bands[band].q,
_ => 0.0,
}
}
fn set_param(&mut self, id: u32, value: f64) {
let band = (id / 3) as usize;
let field = id % 3;
if band >= 3 {
return;
}
match field {
0 => self.bands[band].freq = value.clamp(20.0, 20000.0),
1 => self.bands[band].gain_db = value.clamp(-24.0, 24.0),
2 => self.bands[band].q = value.clamp(0.1, 10.0),
_ => {}
}
self.dirty = true;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn flat_eq_passes_signal() {
let mut eq = ParametricEq::new();
let input = vec![0.5f32; 512];
let out_l = vec![0.0f32; 512];
let out_r = vec![0.0f32; 512];
let inputs: Vec<&[f32]> = vec![&input, &input];
let mut outputs = vec![out_l, out_r];
let mut ctx = ProcessContext {
inputs: &inputs,
outputs: &mut outputs,
frames: 512,
sample_rate: 44100.0,
events: &[],
};
eq.process(&mut ctx);
let tail_avg: f32 = outputs[0][256..].iter().sum::<f32>() / 256.0;
assert!(
(tail_avg - 0.5).abs() < 0.05,
"flat EQ should pass signal: got {tail_avg}"
);
}
#[test]
fn eq_boost_increases_energy() {
let mut eq = ParametricEq::new();
eq.set_param(4, 12.0);
let sr = 44100.0;
let input: Vec<f32> = (0..4096)
.map(|i| (2.0 * std::f64::consts::PI * 1000.0 * i as f64 / sr).sin() as f32 * 0.3)
.collect();
let out_l = vec![0.0f32; 4096];
let out_r = vec![0.0f32; 4096];
let inputs: Vec<&[f32]> = vec![&input, &input];
let mut outputs = vec![out_l, out_r];
let mut ctx = ProcessContext {
inputs: &inputs,
outputs: &mut outputs,
frames: 4096,
sample_rate: sr,
events: &[],
};
eq.process(&mut ctx);
let in_energy: f32 = input[2048..].iter().map(|s| s * s).sum();
let out_energy: f32 = outputs[0][2048..].iter().map(|s| s * s).sum();
assert!(
out_energy > in_energy * 2.0,
"12dB boost should significantly increase energy"
);
}
#[test]
fn eq_nine_params() {
let eq = ParametricEq::new();
assert_eq!(eq.params().len(), 9);
}
}