use super::smoother::Smoother;
use std::f64::consts::{PI, TAU};
const F_MIN: f64 = 200.0; const F_MAX: f64 = 8_000.0; const Q_MIN: f64 = 4.0; const Q_MAX: f64 = 24.0; const SMOOTH_S: f64 = 0.015;
pub struct PeakFilter {
pub enabled: bool,
sample_rate: f64,
lfo_phase: f64,
lfo_rate_hz: f64,
bpm: f64,
beat_value: f64,
ic1eq: [f64; 2],
ic2eq: [f64; 2],
depth_target: f64,
depth_smooth: Smoother,
shape_target: f64,
shape_smooth: Smoother,
}
impl PeakFilter {
pub fn new(sample_rate: f64) -> Self {
let bpm = 120.0;
let beat_value = 4.0; Self {
enabled: false,
sample_rate,
lfo_phase: 0.0,
lfo_rate_hz: bpm / (60.0 * beat_value),
bpm,
beat_value,
ic1eq: [0.0; 2],
ic2eq: [0.0; 2],
depth_target: 0.75,
depth_smooth: Smoother::new(sample_rate, SMOOTH_S),
shape_target: 0.5,
shape_smooth: Smoother::new(sample_rate, SMOOTH_S),
}
}
pub fn set_enabled(&mut self, enabled: bool) {
if enabled && !self.enabled {
self.lfo_phase = 0.0;
self.ic1eq = [0.0; 2];
self.ic2eq = [0.0; 2];
self.depth_smooth.reset(0.0);
self.shape_smooth.reset(self.shape_target);
}
self.enabled = enabled;
}
pub fn set_depth(&mut self, depth: f64) {
self.depth_target = depth.clamp(0.0, 1.0);
}
pub fn set_shape(&mut self, shape: f64) {
self.shape_target = shape.clamp(0.0, 1.0);
}
pub fn set_bpm(&mut self, bpm: f64) {
if bpm > 0.0 && (bpm - self.bpm).abs() > 0.01 {
self.bpm = bpm;
self.update_lfo_rate();
}
}
pub fn set_beat_value(&mut self, beat_value: f64) {
if beat_value > 0.0 && (beat_value - self.beat_value).abs() > 1e-9 {
self.beat_value = beat_value;
self.update_lfo_rate();
}
}
fn update_lfo_rate(&mut self) {
self.lfo_rate_hz = self.bpm / (60.0 * self.beat_value.max(1e-6));
}
pub fn process_sample(&mut self, sample: &mut [f64; 2]) {
if !self.enabled {
return;
}
let depth = self.depth_smooth.process(self.depth_target);
let shape = self.shape_smooth.process(self.shape_target);
self.lfo_phase += self.lfo_rate_hz / self.sample_rate;
if self.lfo_phase >= 1.0 {
self.lfo_phase -= 1.0;
}
let lfo_norm = 0.5 * (1.0 + (TAU * self.lfo_phase).sin());
let f0 = (F_MIN * (F_MAX / F_MIN).powf(lfo_norm)).clamp(F_MIN, F_MAX);
let q = (Q_MIN + shape * (Q_MAX - Q_MIN)).max(0.5);
let g = (PI * f0 / self.sample_rate).tan();
let k = 1.0 / q;
let a1 = 1.0 / (1.0 + g * (g + k));
let a2 = g * a1;
let a3 = g * a2;
for ch in 0..2 {
let x = sample[ch];
let v3 = x - self.ic2eq[ch];
let v1 = a1 * self.ic1eq[ch] + a2 * v3;
let v2 = self.ic2eq[ch] + a2 * self.ic1eq[ch] + a3 * v3;
self.ic1eq[ch] = 2.0 * v1 - self.ic1eq[ch];
self.ic2eq[ch] = 2.0 * v2 - self.ic2eq[ch];
let filtered = v1 * k;
sample[ch] = x * (1.0 - depth) + filtered * depth;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f64 = 44100.0;
fn armed(beat_value: f64, depth: f64, shape: f64) -> PeakFilter {
let mut pf = PeakFilter::new(SR);
pf.set_bpm(120.0);
pf.set_beat_value(beat_value);
pf.set_depth(depth);
pf.set_shape(shape);
pf.set_enabled(true);
pf
}
#[test]
fn disabled_passes_through() {
let mut pf = PeakFilter::new(SR);
pf.set_depth(1.0);
let mut s = [0.7_f64, 0.3_f64];
pf.process_sample(&mut s);
assert_eq!(s, [0.7, 0.3]);
}
#[test]
fn depth_zero_is_dry() {
let mut pf = armed(4.0, 0.0, 0.5);
for _ in 0..4_000 {
let mut s = [0.5_f64; 2];
pf.process_sample(&mut s);
}
let mut s = [0.6_f64, 0.4_f64];
pf.process_sample(&mut s);
assert!((s[0] - 0.6).abs() < 1e-3, "depth=0 must be dry; got {}", s[0]);
assert!((s[1] - 0.4).abs() < 1e-3, "depth=0 must be dry; got {}", s[1]);
}
#[test]
fn dc_attenuated_at_full_depth() {
let mut pf = armed(4.0, 1.0, 0.5);
pf.lfo_rate_hz = 0.0;
pf.lfo_phase = 0.25; for _ in 0..8_000 {
let mut s = [1.0_f64; 2];
pf.process_sample(&mut s);
}
let mut power = 0.0_f64;
for _ in 0..1_024 {
let mut s = [1.0_f64; 2];
pf.process_sample(&mut s);
power += s[0] * s[0];
}
let rms = (power / 1_024.0).sqrt();
assert!(rms < 0.05, "DC should be attenuated by bandpass; rms={rms:.4}");
}
#[test]
fn lfo_shifts_filter_frequency() {
let measure_1khz_power = |phase: f64| -> f64 {
let mut pf = PeakFilter::new(SR);
pf.set_bpm(120.0);
pf.set_beat_value(4.0);
pf.set_depth(1.0);
pf.set_shape(0.5);
pf.lfo_rate_hz = 0.0; pf.set_enabled(true);
pf.lfo_phase = phase; for i in 0..8_000_usize {
let v = (TAU * 1000.0 * i as f64 / SR).sin();
let mut s = [v; 2]; pf.process_sample(&mut s);
}
let mut e = 0.0_f64;
for i in 8_000_usize..9_024 {
let v = (TAU * 1000.0 * i as f64 / SR).sin();
let mut s = [v; 2]; pf.process_sample(&mut s);
e += s[0] * s[0];
}
e / 1_024.0
};
let power_near = measure_1khz_power(0.0); let power_far = measure_1khz_power(0.25); assert!(power_near > power_far * 3.0,
"LFO should shift filter freq; near_1k={power_near:.5} far_1k={power_far:.5}");
}
#[test]
fn enable_resets_svf_state() {
let mut pf = armed(1.0, 0.8, 0.5);
for _ in 0..2_000 {
let mut s = [1.0_f64; 2];
pf.process_sample(&mut s);
}
pf.set_enabled(false);
pf.set_enabled(true);
assert_eq!(pf.ic1eq, [0.0; 2]);
assert_eq!(pf.ic2eq, [0.0; 2]);
assert!(pf.lfo_phase < 1e-9, "LFO phase should reset on re-arm");
}
#[test]
fn depth_fades_in_on_arm() {
let mut pf = PeakFilter::new(SR);
pf.set_depth(1.0);
pf.set_shape(0.5);
pf.lfo_rate_hz = 0.0;
pf.lfo_phase = 0.0;
pf.set_enabled(true);
let mut s_first = [0.5_f64; 2];
pf.process_sample(&mut s_first);
for _ in 0..4_000 {
let mut s = [0.5_f64; 2];
pf.process_sample(&mut s);
}
let mut s_settled = [0.5_f64; 2];
pf.process_sample(&mut s_settled);
assert!((s_first[0] - 0.5).abs() < (s_settled[0] - 0.5).abs() + 1e-2,
"first sample should be near-dry on arm; first={} settled={}", s_first[0], s_settled[0]);
}
#[test]
fn output_bounded() {
let mut pf = armed(0.5, 1.0, 1.0);
for i in 0..44_100_usize {
let v = if i % 2 == 0 { 0.9 } else { -0.9 };
let mut s = [v; 2];
pf.process_sample(&mut s);
assert!(s[0].abs() <= 2.0 && s[1].abs() <= 2.0,
"output out of range at sample {i}: {} {}", s[0], s[1]);
}
}
#[test]
fn shape_affects_q() {
let run = |shape: f64| -> f64 {
let mut pf = PeakFilter::new(SR);
pf.set_bpm(120.0);
pf.set_beat_value(4.0);
pf.set_depth(1.0);
pf.set_shape(shape);
pf.lfo_rate_hz = 0.0;
pf.lfo_phase = 0.0; pf.set_enabled(true);
for _ in 0..8_000 { let mut s = [0.5_f64; 2]; pf.process_sample(&mut s); }
let mut e = 0.0_f64;
let mut sign = 1.0_f64;
for _ in 0..1_024 {
let mut s = [sign; 2]; pf.process_sample(&mut s);
e += s[0] * s[0]; sign = -sign;
}
e / 1_024.0
};
let power_wide = run(0.0); let power_narrow = run(1.0); assert!(power_narrow < power_wide,
"higher shape/Q should pass less broadband energy; wide={power_wide:.4} narrow={power_narrow:.4}");
}
}