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 {
57        Self::new()
58    }
59}
60
61impl DspNode for Chorus {
62    fn process(
63        &mut self,
64        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
65        output: &mut [f32; BUFFER_SIZE],
66        params: &mut ParamBlock,
67        sample_rate: f32,
68    ) {
69        let silence = [0.0f32; BUFFER_SIZE];
70        let input = inputs[0].unwrap_or(&silence);
71
72        let rate = params.get(0).current.clamp(0.1, 10.0);
73        let depth = params.get(1).current.clamp(0.0, 1.0);
74        let feedback = params.get(2).current.clamp(0.0, 0.95);
75        let wet = params.get(3).current.clamp(0.0, 1.0);
76
77        let phase_inc = rate / sample_rate;
78
79        // Base delay: 5ms center, depth modulates ±10ms
80        let base_delay = 0.005 * sample_rate;
81        let mod_depth = depth * 0.010 * sample_rate;
82
83        for (i, out) in output.iter_mut().enumerate() {
84            let dry = input[i];
85
86            // LFO modulation
87            let lfo_l = (self.phase_l * TAU).sin();
88            let lfo_r = (self.phase_r * TAU).sin();
89
90            let delay_l =
91                (base_delay + lfo_l * mod_depth).clamp(1.0, (MAX_DELAY_SAMPLES - 2) as f32);
92            let delay_r =
93                (base_delay + lfo_r * mod_depth).clamp(1.0, (MAX_DELAY_SAMPLES - 2) as f32);
94
95            // Read from delay lines
96            let delayed_l = Self::read_interp(&self.buf_l, self.write_pos, delay_l);
97            let delayed_r = Self::read_interp(&self.buf_r, self.write_pos, delay_r);
98
99            // Write to delay lines with feedback
100            self.buf_l[self.write_pos] = dry + delayed_l * feedback;
101            self.buf_r[self.write_pos] = dry + delayed_r * feedback;
102
103            // Advance write position
104            self.write_pos = (self.write_pos + 1) % MAX_DELAY_SAMPLES;
105
106            // Mix L+R and blend dry/wet
107            let wet_signal = (delayed_l + delayed_r) * 0.5;
108            *out = dry * (1.0 - wet) + wet_signal * wet;
109
110            // Advance LFO phases
111            self.phase_l = (self.phase_l + phase_inc).fract();
112            self.phase_r = (self.phase_r + phase_inc).fract();
113
114            params.tick_all();
115        }
116    }
117
118    fn type_name(&self) -> &'static str {
119        "Chorus"
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn test_chorus_dry_passthrough() {
129        let mut chorus = Chorus::new();
130        let mut params = ParamBlock::new();
131        for &v in &[1.0f32, 0.5, 0.0, 0.0] {
132            params.add(v);
133        } // wet=0 → dry passthrough
134        let input = [0.5f32; BUFFER_SIZE];
135        let inputs = [Some(&input); MAX_INPUTS];
136        let mut output = [0.0f32; BUFFER_SIZE];
137        chorus.process(&inputs, &mut output, &mut params, 48000.0);
138        for s in &output {
139            assert!(
140                (s - 0.5).abs() < 1e-5,
141                "wet=0 should pass dry signal unchanged"
142            );
143        }
144    }
145
146    #[test]
147    fn test_chorus_bounded_output() {
148        let mut chorus = Chorus::new();
149        let mut params = ParamBlock::new();
150        for &v in &[2.0f32, 1.0, 0.9, 1.0] {
151            params.add(v);
152        }
153        let input = [1.0f32; BUFFER_SIZE];
154        let inputs = [Some(&input); MAX_INPUTS];
155        let mut output = [0.0f32; BUFFER_SIZE];
156        chorus.process(&inputs, &mut output, &mut params, 48000.0);
157        for s in &output {
158            assert!(s.abs() < 10.0, "chorus output should be bounded, got {s}");
159        }
160    }
161}