Skip to main content

aether_nodes/
chorus.rs

1//! Chorus / Flanger — BBD-style modulated delay.
2//!
3//! Uses two modulated delay lines (L/R) with slightly different LFO phases
4//! to create stereo width. Outputs to a mono mix (summed L+R * 0.5).
5//!
6//! Param layout:
7//!   0 = rate      (Hz, 0.1 – 10.0)
8//!   1 = depth     (0.0 – 1.0, modulation depth in ms: 0–20ms)
9//!   2 = feedback  (0.0 – 0.95, feedback amount)
10//!   3 = wet       (0.0 – 1.0, dry/wet mix)
11
12use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
13use std::f32::consts::TAU;
14
15/// Maximum delay line length in samples (at 48kHz: ~42ms).
16const MAX_DELAY_SAMPLES: usize = 2048;
17
18pub struct Chorus {
19    /// Delay buffer for left channel.
20    buf_l: Box<[f32; MAX_DELAY_SAMPLES]>,
21    /// Delay buffer for right channel.
22    buf_r: Box<[f32; MAX_DELAY_SAMPLES]>,
23    /// Write position.
24    write_pos: usize,
25    /// LFO phase for left channel.
26    phase_l: f32,
27    /// LFO phase for right channel (offset by 90°).
28    phase_r: f32,
29}
30
31impl Chorus {
32    pub fn new() -> Self {
33        Self {
34            buf_l: Box::new([0.0f32; MAX_DELAY_SAMPLES]),
35            buf_r: Box::new([0.0f32; MAX_DELAY_SAMPLES]),
36            write_pos: 0,
37            phase_l: 0.0,
38            phase_r: 0.25, // 90° offset for stereo width
39        }
40    }
41
42    /// Linear interpolation read from a circular buffer.
43    #[inline(always)]
44    fn read_interp(buf: &[f32; MAX_DELAY_SAMPLES], write_pos: usize, delay_samples: f32) -> f32 {
45        let delay_int = delay_samples as usize;
46        let frac = delay_samples - delay_int as f32;
47
48        let pos0 = (write_pos + MAX_DELAY_SAMPLES - delay_int) % MAX_DELAY_SAMPLES;
49        let pos1 = (write_pos + MAX_DELAY_SAMPLES - delay_int - 1) % MAX_DELAY_SAMPLES;
50
51        buf[pos0] * (1.0 - frac) + buf[pos1] * frac
52    }
53}
54
55impl Default for Chorus {
56    fn default() -> Self { Self::new() }
57}
58
59impl DspNode for Chorus {
60    fn process(
61        &mut self,
62        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
63        output: &mut [f32; BUFFER_SIZE],
64        params: &mut ParamBlock,
65        sample_rate: f32,
66    ) {
67        let silence = [0.0f32; BUFFER_SIZE];
68        let input = inputs[0].unwrap_or(&silence);
69
70        let rate     = params.get(0).current.clamp(0.1, 10.0);
71        let depth    = params.get(1).current.clamp(0.0, 1.0);
72        let feedback = params.get(2).current.clamp(0.0, 0.95);
73        let wet      = params.get(3).current.clamp(0.0, 1.0);
74
75        let phase_inc = rate / sample_rate;
76
77        // Base delay: 5ms center, depth modulates ±10ms
78        let base_delay = 0.005 * sample_rate;
79        let mod_depth  = depth * 0.010 * sample_rate;
80
81        for (i, out) in output.iter_mut().enumerate() {
82            let dry = input[i];
83
84            // LFO modulation
85            let lfo_l = (self.phase_l * TAU).sin();
86            let lfo_r = (self.phase_r * TAU).sin();
87
88            let delay_l = (base_delay + lfo_l * mod_depth).clamp(1.0, (MAX_DELAY_SAMPLES - 2) as f32);
89            let delay_r = (base_delay + lfo_r * mod_depth).clamp(1.0, (MAX_DELAY_SAMPLES - 2) as f32);
90
91            // Read from delay lines
92            let delayed_l = Self::read_interp(&self.buf_l, self.write_pos, delay_l);
93            let delayed_r = Self::read_interp(&self.buf_r, self.write_pos, delay_r);
94
95            // Write to delay lines with feedback
96            self.buf_l[self.write_pos] = dry + delayed_l * feedback;
97            self.buf_r[self.write_pos] = dry + delayed_r * feedback;
98
99            // Advance write position
100            self.write_pos = (self.write_pos + 1) % MAX_DELAY_SAMPLES;
101
102            // Mix L+R and blend dry/wet
103            let wet_signal = (delayed_l + delayed_r) * 0.5;
104            *out = dry * (1.0 - wet) + wet_signal * wet;
105
106            // Advance LFO phases
107            self.phase_l = (self.phase_l + phase_inc).fract();
108            self.phase_r = (self.phase_r + phase_inc).fract();
109
110            params.tick_all();
111        }
112    }
113
114    fn type_name(&self) -> &'static str { "Chorus" }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn test_chorus_dry_passthrough() {
123        let mut chorus = Chorus::new();
124        let mut params = ParamBlock::new();
125        for &v in &[1.0f32, 0.5, 0.0, 0.0] { params.add(v); } // wet=0 → dry passthrough
126        let input = [0.5f32; BUFFER_SIZE];
127        let inputs = [Some(&input); MAX_INPUTS];
128        let mut output = [0.0f32; BUFFER_SIZE];
129        chorus.process(&inputs, &mut output, &mut params, 48000.0);
130        for s in &output {
131            assert!((s - 0.5).abs() < 1e-5, "wet=0 should pass dry signal unchanged");
132        }
133    }
134
135    #[test]
136    fn test_chorus_bounded_output() {
137        let mut chorus = Chorus::new();
138        let mut params = ParamBlock::new();
139        for &v in &[2.0f32, 1.0, 0.9, 1.0] { params.add(v); }
140        let input = [1.0f32; BUFFER_SIZE];
141        let inputs = [Some(&input); MAX_INPUTS];
142        let mut output = [0.0f32; BUFFER_SIZE];
143        chorus.process(&inputs, &mut output, &mut params, 48000.0);
144        for s in &output {
145            assert!(s.abs() < 10.0, "chorus output should be bounded, got {s}");
146        }
147    }
148}