use std::sync::OnceLock;
use crate::{
utils::{FractionalDelayLine, InterpolationMode, Lfo, LfoWaveform},
AudioEffect,
};
const WAVETABLE_SIZE: usize = 1024;
static SINE_TABLE: OnceLock<Box<[f32; WAVETABLE_SIZE]>> = OnceLock::new();
fn get_sine_table() -> &'static [f32; WAVETABLE_SIZE] {
SINE_TABLE.get_or_init(|| {
let mut table = Box::new([0.0f32; WAVETABLE_SIZE]);
for (i, entry) in table.iter_mut().enumerate() {
*entry = (2.0 * std::f32::consts::PI * i as f32 / WAVETABLE_SIZE as f32).sin();
}
table
})
}
fn wavetable_sin(phase: f32) -> f32 {
let tbl = get_sine_table();
let idx_f = phase.rem_euclid(1.0) * WAVETABLE_SIZE as f32;
let i0 = idx_f as usize % WAVETABLE_SIZE;
let i1 = (i0 + 1) % WAVETABLE_SIZE;
let frac = idx_f.fract();
tbl[i0] * (1.0 - frac) + tbl[i1] * frac
}
struct WtVoice {
delay: FractionalDelayLine,
phase: f32,
phase_inc: f32,
index_f32: f32,
}
pub struct WavetableChorus {
voices: Vec<WtVoice>,
config: ChorusConfig,
sample_rate: f32,
}
impl WavetableChorus {
#[must_use]
pub fn new(config: ChorusConfig, sample_rate: f32) -> Self {
let n_voices = config.voices.clamp(MIN_VOICES, MAX_VOICES);
let n_voices_f = n_voices as f32;
let max_delay_ms = config.delay_ms + config.depth_ms;
let max_delay_samples_f = (max_delay_ms * sample_rate / 1000.0).max(1.0);
let max_delay_samples = max_delay_samples_f as usize;
let phase_inc = config.rate / sample_rate;
let voices: Vec<WtVoice> = (0..n_voices)
.map(|i| {
let index_f32 = i as f32;
let initial_phase = index_f32 / n_voices_f;
WtVoice {
delay: FractionalDelayLine::new(max_delay_samples, InterpolationMode::Linear),
phase: initial_phase,
phase_inc,
index_f32,
}
})
.collect();
Self {
voices,
config,
sample_rate,
}
}
pub fn set_rate(&mut self, rate: f32) {
self.config.rate = rate.clamp(0.1, 10.0);
let inc = self.config.rate / self.sample_rate;
for v in &mut self.voices {
v.phase_inc = inc;
}
}
pub fn process_mono(&mut self, input: f32) -> f32 {
let (l, _r) = self.process_stereo_sample(input, input);
l
}
pub fn process_stereo_sample(&mut self, input_l: f32, input_r: f32) -> (f32, f32) {
let mut out_l = 0.0_f32;
let mut out_r = 0.0_f32;
let n = self.voices.len();
let inv_n = if n == 0 { 1.0_f32 } else { 1.0 / n as f32 };
let n_minus_one = if n <= 1 { 1.0_f32 } else { (n - 1) as f32 };
for voice in &mut self.voices {
let lfo_bipolar = wavetable_sin(voice.phase);
let lfo_uni = (lfo_bipolar + 1.0) * 0.5;
voice.phase = (voice.phase + voice.phase_inc).rem_euclid(1.0);
let delay_ms = self.config.delay_ms + lfo_uni * self.config.depth_ms;
let delay_samples = (delay_ms * self.sample_rate) / 1000.0;
let delayed = voice.delay.read(delay_samples);
voice.delay.write((input_l + input_r) * 0.5);
let pan_norm = voice.index_f32 / n_minus_one * 2.0 - 1.0;
let pan = pan_norm * self.config.spread;
let gain_l = if pan <= 0.0 { 1.0 } else { 1.0 - pan };
let gain_r = if pan >= 0.0 { 1.0 } else { 1.0 + pan };
out_l += delayed * gain_l * inv_n;
out_r += delayed * gain_r * inv_n;
}
let out_l = out_l * self.config.wet + input_l * self.config.dry;
let out_r = out_r * self.config.wet + input_r * self.config.dry;
(out_l, out_r)
}
}
impl AudioEffect for WavetableChorus {
const EFFECT_ID: &'static str = "wavetable_chorus";
fn process_sample(&mut self, input: f32) -> f32 {
self.process_mono(input)
}
fn process_sample_stereo(&mut self, left: f32, right: f32) -> (f32, f32) {
self.process_stereo_sample(left, right)
}
fn reset(&mut self) {
for v in &mut self.voices {
v.delay.clear();
v.phase = 0.0;
}
}
}
pub const MAX_VOICES: usize = 8;
pub const MIN_VOICES: usize = 2;
#[derive(Debug, Clone)]
pub struct ChorusConfig {
pub voices: usize,
pub rate: f32,
pub depth_ms: f32,
pub delay_ms: f32,
pub wet: f32,
pub dry: f32,
pub spread: f32,
pub waveform: LfoWaveform,
}
impl Default for ChorusConfig {
fn default() -> Self {
Self {
voices: 4,
rate: 0.5,
depth_ms: 5.0,
delay_ms: 25.0,
wet: 0.5,
dry: 0.5,
spread: 0.8,
waveform: LfoWaveform::Sine,
}
}
}
impl ChorusConfig {
#[must_use]
pub fn subtle() -> Self {
Self {
voices: 2,
rate: 0.3,
depth_ms: 2.0,
delay_ms: 20.0,
wet: 0.3,
dry: 0.7,
spread: 0.5,
waveform: LfoWaveform::Sine,
}
}
#[must_use]
pub fn lush() -> Self {
Self {
voices: 6,
rate: 0.8,
depth_ms: 8.0,
delay_ms: 30.0,
wet: 0.6,
dry: 0.4,
spread: 1.0,
waveform: LfoWaveform::Sine,
}
}
#[must_use]
pub fn vibrato() -> Self {
Self {
voices: 3,
rate: 4.0,
depth_ms: 3.0,
delay_ms: 15.0,
wet: 1.0,
dry: 0.0,
spread: 0.3,
waveform: LfoWaveform::Triangle,
}
}
}
pub struct StereoChorus {
delay_lines: Vec<FractionalDelayLine>,
lfos: Vec<Lfo>,
config: ChorusConfig,
sample_rate: f32,
}
impl StereoChorus {
#[must_use]
pub fn new(config: ChorusConfig, sample_rate: f32) -> Self {
let voices = config.voices.clamp(MIN_VOICES, MAX_VOICES);
let max_delay_ms = config.delay_ms + config.depth_ms;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let max_delay_samples = ((max_delay_ms * sample_rate) / 1000.0) as usize;
let delay_lines: Vec<FractionalDelayLine> = (0..voices)
.map(|_| FractionalDelayLine::new(max_delay_samples.max(1), InterpolationMode::Linear))
.collect();
let lfos: Vec<Lfo> = (0..voices)
.map(|i| {
let mut lfo = Lfo::new(config.rate, sample_rate, config.waveform);
#[allow(clippy::cast_precision_loss)]
let phase = i as f32 / voices as f32;
lfo.set_phase(phase);
lfo
})
.collect();
Self {
delay_lines,
lfos,
config,
sample_rate,
}
}
pub fn set_rate(&mut self, rate: f32) {
self.config.rate = rate.clamp(0.1, 10.0);
for lfo in &mut self.lfos {
lfo.set_frequency(self.config.rate);
}
}
pub fn set_depth(&mut self, depth_ms: f32) {
self.config.depth_ms = depth_ms.clamp(0.0, 20.0);
}
pub fn set_wet(&mut self, wet: f32) {
self.config.wet = wet.clamp(0.0, 1.0);
}
pub fn set_dry(&mut self, dry: f32) {
self.config.dry = dry.clamp(0.0, 1.0);
}
fn process_sample_internal(&mut self, input_l: f32, input_r: f32) -> (f32, f32) {
let mut out_l = 0.0;
let mut out_r = 0.0;
#[allow(clippy::cast_precision_loss)]
let num_voices = self.delay_lines.len() as f32;
for (i, delay_line) in self.delay_lines.iter_mut().enumerate() {
let mod_value = self.lfos[i].next_unipolar();
let delay_ms = self.config.delay_ms + mod_value * self.config.depth_ms;
let delay_samples = (delay_ms * self.sample_rate) / 1000.0;
let delayed = delay_line.read(delay_samples);
delay_line.write((input_l + input_r) * 0.5);
#[allow(clippy::cast_precision_loss)]
let pan = ((i as f32 / num_voices) * 2.0 - 1.0) * self.config.spread;
let left_gain = if pan <= 0.0 { 1.0 } else { 1.0 - pan };
let right_gain = if pan >= 0.0 { 1.0 } else { 1.0 + pan };
out_l += delayed * left_gain / num_voices;
out_r += delayed * right_gain / num_voices;
}
out_l = out_l * self.config.wet + input_l * self.config.dry;
out_r = out_r * self.config.wet + input_r * self.config.dry;
(out_l, out_r)
}
}
impl AudioEffect for StereoChorus {
const EFFECT_ID: &'static str = "stereo_chorus";
fn process_sample(&mut self, input: f32) -> f32 {
let (left, _right) = self.process_sample_internal(input, input);
left
}
fn process_sample_stereo(&mut self, left: f32, right: f32) -> (f32, f32) {
self.process_sample_internal(left, right)
}
fn reset(&mut self) {
for delay_line in &mut self.delay_lines {
delay_line.clear();
}
for lfo in &mut self.lfos {
lfo.reset();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_chorus_config() {
let config = ChorusConfig::default();
assert_eq!(config.voices, 4);
assert_eq!(config.rate, 0.5);
}
#[test]
fn test_chorus_presets() {
let subtle = ChorusConfig::subtle();
assert_eq!(subtle.voices, 2);
let lush = ChorusConfig::lush();
assert_eq!(lush.voices, 6);
}
#[test]
fn test_chorus_process() {
let config = ChorusConfig::default();
let mut chorus = StereoChorus::new(config, 48000.0);
let (out_l, out_r) = chorus.process_sample_stereo(1.0, 1.0);
assert!(out_l.is_finite());
assert!(out_r.is_finite());
for _ in 0..1000 {
chorus.process_sample_stereo(0.1, 0.1);
}
}
#[test]
fn test_chorus_voices() {
let config = ChorusConfig {
voices: 3,
..Default::default()
};
let chorus = StereoChorus::new(config, 48000.0);
assert_eq!(chorus.delay_lines.len(), 3);
assert_eq!(chorus.lfos.len(), 3);
}
#[test]
fn test_chorus_stereo_spread() {
let config = ChorusConfig {
spread: 1.0,
..Default::default()
};
let mut chorus = StereoChorus::new(config, 48000.0);
let (out_l, out_r) = chorus.process_sample_stereo(1.0, 0.0);
assert!(out_l.is_finite());
assert!(out_r.is_finite());
}
#[test]
fn test_wavetable_sin_accuracy() {
use std::f32::consts::PI;
let mut max_err = 0.0f32;
for i in 0..100_usize {
let phase = i as f32 / 100.0; let wt = wavetable_sin(phase);
let exact = (2.0 * PI * phase).sin();
let err = (wt - exact).abs();
if err > max_err {
max_err = err;
}
}
assert!(
max_err < 0.005,
"wavetable_sin max error {max_err:.6} exceeds 0.005"
);
}
#[test]
fn test_chorus_wavetable_output() {
let config = ChorusConfig {
voices: 4,
rate: 5.0, depth_ms: 2.0,
delay_ms: 5.0, wet: 1.0,
dry: 0.0,
spread: 0.8,
waveform: LfoWaveform::Sine,
};
let mut chorus = WavetableChorus::new(config, 48_000.0);
use std::f32::consts::TAU;
let total = 2048_usize;
let mut outputs = Vec::with_capacity(total);
for i in 0..total {
let s = (i as f32 * TAU * 440.0 / 48_000.0).sin() * 0.7;
outputs.push(chorus.process_mono(s));
}
for (i, &s) in outputs.iter().enumerate() {
assert!(s.is_finite(), "non-finite output at sample {i}: {s}");
}
let warmed: &[f32] = &outputs[512..];
let mean: f32 = warmed.iter().sum::<f32>() / warmed.len() as f32;
let variance: f32 =
warmed.iter().map(|&x| (x - mean) * (x - mean)).sum::<f32>() / warmed.len() as f32;
assert!(
variance > 1e-6,
"WavetableChorus output should have non-zero variance after warm-up; got {variance}"
);
}
}