Skip to main content

aether_nodes/
waveshaper.rs

1//! Waveshaper / Saturation — nonlinear distortion.
2//!
3//! Param layout:
4//!   0 = drive  (0.0 – 1.0, input gain before shaping)
5//!   1 = mode   (0=soft-clip tanh, 1=hard-clip, 2=fold-back, 3=bit-crush, 4=tube)
6//!   2 = tone   (0.0 – 1.0, post-shape high-shelf brightness)
7//!   3 = wet    (0.0 – 1.0, dry/wet mix)
8
9use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
10
11pub struct Waveshaper {
12    /// One-pole high-shelf state for tone control.
13    tone_state: f32,
14    /// Bit-crush phase accumulator.
15    crush_phase: f32,
16    crush_held: f32,
17}
18
19impl Waveshaper {
20    pub fn new() -> Self {
21        Self {
22            tone_state: 0.0,
23            crush_phase: 0.0,
24            crush_held: 0.0,
25        }
26    }
27
28    /// Soft-clip using tanh approximation (Padé approximant, fast).
29    #[inline(always)]
30    fn tanh_approx(x: f32) -> f32 {
31        // Padé [3/3] approximation — accurate to ±0.001 for |x| < 4
32        let x2 = x * x;
33        x * (27.0 + x2) / (27.0 + 9.0 * x2)
34    }
35
36    /// Tube-style asymmetric saturation (positive half harder than negative).
37    #[inline(always)]
38    fn tube_sat(x: f32) -> f32 {
39        if x >= 0.0 {
40            1.0 - (-x).exp()
41        } else {
42            -1.0 + (x).exp()
43        }
44    }
45}
46
47impl Default for Waveshaper {
48    fn default() -> Self { Self::new() }
49}
50
51impl DspNode for Waveshaper {
52    fn process(
53        &mut self,
54        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
55        output: &mut [f32; BUFFER_SIZE],
56        params: &mut ParamBlock,
57        sample_rate: f32,
58    ) {
59        let silence = [0.0f32; BUFFER_SIZE];
60        let input = inputs[0].unwrap_or(&silence);
61
62        let drive = params.get(0).current.clamp(0.0, 1.0);
63        let mode  = params.get(1).current as u32;
64        let tone  = params.get(2).current.clamp(0.0, 1.0);
65        let wet   = params.get(3).current.clamp(0.0, 1.0);
66
67        // Drive maps 0–1 to 1–20× gain
68        let gain = 1.0 + drive * 19.0;
69
70        // Tone: one-pole high-shelf coefficient
71        let tone_coeff = tone * 0.95;
72
73        for (i, out) in output.iter_mut().enumerate() {
74            let dry = input[i];
75            let driven = dry * gain;
76
77            let shaped = match mode {
78                0 => Self::tanh_approx(driven),
79                1 => driven.clamp(-1.0, 1.0),
80                2 => {
81                    // Fold-back: reflect signal at ±1
82                    let mut v = driven;
83                    while v.abs() > 1.0 {
84                        if v > 1.0 { v = 2.0 - v; }
85                        else if v < -1.0 { v = -2.0 - v; }
86                    }
87                    v
88                }
89                3 => {
90                    // Bit-crush: sample-and-hold at reduced rate
91                    let crush_rate = 1.0 - drive * 0.95; // 0.05–1.0 of sample rate
92                    self.crush_phase += crush_rate;
93                    if self.crush_phase >= 1.0 {
94                        self.crush_phase -= 1.0;
95                        // Quantize to 4–16 bits
96                        let bits = 4.0 + (1.0 - drive) * 12.0;
97                        let levels = 2.0f32.powf(bits);
98                        self.crush_held = (driven * levels).round() / levels;
99                    }
100                    self.crush_held
101                }
102                _ => Self::tube_sat(driven),
103            };
104
105            // Normalize output (compensate for gain)
106            let normalized = shaped / gain.sqrt();
107
108            // Tone: high-shelf boost/cut
109            self.tone_state = self.tone_state + tone_coeff * (normalized - self.tone_state);
110            let toned = normalized + tone * (normalized - self.tone_state);
111
112            *out = dry + wet * (toned - dry);
113            params.tick_all();
114        }
115        let _ = sample_rate;
116    }
117
118    fn type_name(&self) -> &'static str { "Waveshaper" }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_waveshaper_zero_drive_passthrough() {
127        let mut ws = Waveshaper::new();
128        let mut params = ParamBlock::new();
129        for &v in &[0.0f32, 0.0, 0.5, 1.0] { params.add(v); }
130        let input: [f32; BUFFER_SIZE] = std::array::from_fn(|i| (i as f32 / BUFFER_SIZE as f32) * 0.5);
131        let inputs = [Some(&input); MAX_INPUTS];
132        let mut output = [0.0f32; BUFFER_SIZE];
133        ws.process(&inputs, &mut output, &mut params, 48000.0);
134        // With drive=0, gain=1, tanh(x)≈x for small x, output ≈ input
135        for i in 0..BUFFER_SIZE {
136            assert!((output[i] - input[i]).abs() < 0.05, "zero drive should be near-passthrough");
137        }
138    }
139
140    #[test]
141    fn test_waveshaper_hard_clip_bounds() {
142        let mut ws = Waveshaper::new();
143        let mut params = ParamBlock::new();
144        for &v in &[1.0f32, 1.0, 0.0, 1.0] { params.add(v); } // hard clip, full wet
145        let input = [2.0f32; BUFFER_SIZE]; // way above clip
146        let inputs = [Some(&input); MAX_INPUTS];
147        let mut output = [0.0f32; BUFFER_SIZE];
148        ws.process(&inputs, &mut output, &mut params, 48000.0);
149        for s in &output {
150            assert!(s.abs() <= 1.5, "hard clip output should be bounded, got {s}");
151        }
152    }
153}