use core::f32::consts::TAU;
use crate::params::ChorusParams;
const MAX_CHORUS_DELAY_MS: f32 = 50.0;
const CENTER_DELAY_MS: f32 = 15.0;
const NUM_TAPS: usize = 3;
pub struct Chorus {
buf: Vec<f32>,
write_idx: usize,
lfo: [(f32, f32); NUM_TAPS],
sin_inc: f32,
cos_inc: f32,
cached_rate: f32,
sample_rate: f32,
}
impl Chorus {
#[must_use]
pub fn new(sample_rate: f32) -> Self {
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let len = ((MAX_CHORUS_DELAY_MS / 1000.0) * sample_rate).ceil() as usize;
let phases = [0.0_f32, TAU / 3.0, 2.0 * TAU / 3.0];
let lfo = phases.map(crate::math::sin_cos);
Self {
buf: vec![0.0; len.max(1)],
write_idx: 0,
lfo,
sin_inc: 0.0,
cos_inc: 1.0,
cached_rate: -1.0,
sample_rate,
}
}
#[allow(clippy::cast_precision_loss)]
pub fn process(&mut self, input: f32, params: &ChorusParams) -> f32 {
if params.mix < 1e-4 {
return input;
}
debug_assert!(
params.depth_ms <= CENTER_DELAY_MS,
"depth_ms ({}) exceeds center delay ({}ms) — read offset would wrap to zero-latency tap",
params.depth_ms,
CENTER_DELAY_MS
);
#[allow(clippy::float_cmp)]
if params.rate != self.cached_rate {
let phase_inc = TAU * params.rate / self.sample_rate;
(self.sin_inc, self.cos_inc) = crate::math::sin_cos(phase_inc);
self.cached_rate = params.rate;
}
let buf_len = self.buf.len();
let buf_len_f = buf_len as f32;
self.buf[self.write_idx] = input;
let center_samples = CENTER_DELAY_MS / 1000.0 * self.sample_rate;
let depth_samples = params.depth_ms.clamp(0.0, CENTER_DELAY_MS) / 1000.0 * self.sample_rate;
let write_idx_f = self.write_idx as f32;
let mut tap_sum = 0.0_f32;
for (s, c) in &mut self.lfo {
let offset = (center_samples + depth_samples * *s).clamp(0.0, buf_len_f - 1.001);
let new_s = *s * self.cos_inc + *c * self.sin_inc;
let new_c = *c * self.cos_inc - *s * self.sin_inc;
*s = new_s;
*c = new_c;
let raw = write_idx_f + buf_len_f - offset;
let read_pos = if raw >= buf_len_f {
raw - buf_len_f
} else {
raw
};
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let floor_idx = read_pos.floor() as usize % buf_len;
let ceil_idx = (floor_idx + 1) % buf_len;
let frac = read_pos.fract();
tap_sum += self.buf[floor_idx] * (1.0 - frac) + self.buf[ceil_idx] * frac;
}
self.write_idx = (self.write_idx + 1) % buf_len;
#[allow(clippy::cast_precision_loss)]
let wet = tap_sum / NUM_TAPS as f32;
input * (1.0 - params.mix) + wet * params.mix
}
}
#[cfg(test)]
mod tests {
use super::*;
fn params(rate: f32, depth_ms: f32, mix: f32) -> ChorusParams {
ChorusParams {
rate,
depth_ms,
mix,
}
}
#[test]
#[allow(clippy::float_cmp)]
fn bypass_at_zero_mix() {
let mut c = Chorus::new(44100.0);
let p = params(0.5, 3.0, 0.0);
for &x in &[-1.0_f32, -0.5, 0.0, 0.5, 1.0] {
assert_eq!(c.process(x, &p), x, "zero-mix bypass failed for {x}");
}
}
#[test]
fn output_finite_at_boundary_params() {
let mut c = Chorus::new(44100.0);
for &rate in &[0.1_f32, 2.5, 5.0] {
for &depth_ms in &[0.0_f32, 5.0, 10.0] {
for &mix in &[0.0_f32, 0.5, 1.0] {
let p = params(rate, depth_ms, mix);
for &x in &[-1.0_f32, 0.0, 1.0] {
let out = c.process(x, &p);
assert!(
out.is_finite(),
"NaN/Inf: rate={rate}, depth={depth_ms}, mix={mix}, in={x}, out={out}"
);
}
}
}
}
}
#[test]
fn modulation_active_after_warmup() {
let mut c = Chorus::new(44100.0);
let p = params(1.0, 5.0, 1.0);
for _ in 0..3000 {
c.process(1.0, &p);
}
let out = c.process(1.0, &p);
assert!(
out.abs() > 0.01,
"chorus output should be non-zero after warmup: {out}"
);
}
#[test]
fn dc_input_steady_state_near_unity() {
let mut c = Chorus::new(44100.0);
let p = params(0.1, 0.0, 1.0); for _ in 0..3000 {
c.process(1.0, &p);
}
let out = c.process(1.0, &p);
assert!(
(out - 1.0).abs() < 0.02,
"expected ~1.0 for DC+depth=0, got {out}"
);
}
}