Skip to main content

aether_nodes/
moog_ladder.rs

1//! Moog ladder filter — the filter that defined synthesizer sound.
2//!
3//! Huovilainen model: accurate self-oscillation, stable at high resonance,
4//! correct at audio-rate cutoff modulation.
5//!
6//! Param layout:
7//!   0 = cutoff     (Hz, 20 – 20000)
8//!   1 = resonance  (0.0 – 4.0, self-oscillates above ~3.8)
9//!   2 = drive      (0.0 – 1.0, input saturation)
10
11use aether_core::{node::DspNode, param::ParamBlock, state::StateBlob, BUFFER_SIZE, MAX_INPUTS};
12use std::f32::consts::PI;
13
14#[derive(Clone, Copy, Default)]
15struct LadderState {
16    stage: [f32; 4],
17    #[allow(dead_code)]
18    stage_tanh: [f32; 4],
19    delay: [f32; 6],
20}
21
22pub struct MoogLadder {
23    state: LadderState,
24    /// Thermal voltage constant — used in the Huovilainen nonlinear model.
25    /// Kept as a field so it can be tuned per-instance if needed.
26    #[allow(dead_code)]
27    thermal: f32,
28}
29
30impl MoogLadder {
31    pub fn new() -> Self {
32        Self {
33            state: LadderState::default(),
34            thermal: 0.000_025, // thermal voltage
35        }
36    }
37
38    #[inline(always)]
39    fn process_sample(
40        &mut self,
41        input: f32,
42        cutoff: f32,
43        resonance: f32,
44        drive: f32,
45        sr: f32,
46    ) -> f32 {
47        let f = cutoff / (sr * 0.5);
48        let f = f.clamp(0.0, 1.0);
49
50        // Huovilainen's nonlinear ladder
51        let fc = f * PI;
52        let fc2 = fc * fc;
53        let fc3 = fc2 * fc;
54
55        let fcr = 1.8730 * fc3 + 0.4955 * fc2 - 0.6490 * fc + 0.9988;
56        let acr = -3.9364 * fc2 + 1.8409 * fc + 0.9968;
57
58        let f2 = (2.0 / 1.3) * f;
59        let res4 = resonance * acr;
60
61        // Input with drive saturation
62        let inp = (input * (1.0 + drive * 3.0)).tanh();
63
64        // Four-stage ladder with feedback
65        let inp_sub = inp - res4 * self.state.delay[5];
66
67        // Stage 1
68        let t1 = self.state.stage[0] * fcr;
69        let t2 = self.state.delay[0] * fcr;
70        self.state.stage[0] = inp_sub * f2 - t1;
71        self.state.delay[0] = self.state.stage[0] + t1;
72        let out1 = self.state.delay[0].tanh();
73
74        // Stage 2
75        let t1 = self.state.stage[1] * fcr;
76        let t2_2 = self.state.delay[1] * fcr;
77        self.state.stage[1] = out1 * f2 - t1;
78        self.state.delay[1] = self.state.stage[1] + t1;
79        let out2 = self.state.delay[1].tanh();
80
81        // Stage 3
82        let t1 = self.state.stage[2] * fcr;
83        let _t2_3 = self.state.delay[2] * fcr;
84        self.state.stage[2] = out2 * f2 - t1;
85        self.state.delay[2] = self.state.stage[2] + t1;
86        let out3 = self.state.delay[2].tanh();
87
88        // Stage 4
89        let t1 = self.state.stage[3] * fcr;
90        let _t2_4 = self.state.delay[3] * fcr;
91        self.state.stage[3] = out3 * f2 - t1;
92        self.state.delay[3] = self.state.stage[3] + t1;
93        let out4 = self.state.delay[3];
94
95        // Feedback
96        self.state.delay[5] = (self.state.delay[4] + out4) * 0.5;
97        self.state.delay[4] = out4;
98
99        // Suppress unused variable warnings
100        let _ = (t2, t2_2, fc3);
101
102        out4
103    }
104}
105
106impl Default for MoogLadder {
107    fn default() -> Self {
108        Self::new()
109    }
110}
111
112impl DspNode for MoogLadder {
113    fn process(
114        &mut self,
115        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
116        output: &mut [f32; BUFFER_SIZE],
117        params: &mut ParamBlock,
118        sample_rate: f32,
119    ) {
120        let silence = [0.0f32; BUFFER_SIZE];
121        let input = inputs[0].unwrap_or(&silence);
122
123        for (i, out) in output.iter_mut().enumerate() {
124            let cutoff = params.get(0).current.clamp(20.0, sample_rate * 0.45);
125            let resonance = params.get(1).current.clamp(0.0, 4.0);
126            let drive = params.get(2).current.clamp(0.0, 1.0);
127            *out = self.process_sample(input[i], cutoff, resonance, drive, sample_rate);
128            params.tick_all();
129        }
130    }
131
132    fn capture_state(&self) -> StateBlob {
133        StateBlob::EMPTY
134    }
135    fn type_name(&self) -> &'static str {
136        "MoogLadder"
137    }
138}