Skip to main content

aether_nodes/
compressor.rs

1//! RMS Compressor — dynamics processor.
2//!
3//! Param layout:
4//!   0 = threshold  (dB, -60 – 0)
5//!   1 = ratio      (1:1 – 20:1)
6//!   2 = attack     (ms, 0.1 – 200)
7//!   3 = release    (ms, 10 – 2000)
8//!   4 = makeup     (dB, 0 – 24)
9//!   5 = knee       (dB, 0 – 12, soft-knee width)
10
11use aether_core::{node::DspNode, param::ParamBlock, BUFFER_SIZE, MAX_INPUTS};
12
13pub struct Compressor {
14    /// RMS envelope follower state.
15    rms_env: f32,
16    /// Gain reduction envelope (smoothed).
17    gain_env: f32,
18}
19
20impl Compressor {
21    pub fn new() -> Self {
22        Self {
23            rms_env: 0.0,
24            gain_env: 1.0,
25        }
26    }
27
28    #[inline(always)]
29    fn db_to_linear(db: f32) -> f32 {
30        10.0f32.powf(db / 20.0)
31    }
32
33    #[inline(always)]
34    fn linear_to_db(linear: f32) -> f32 {
35        if linear <= 1e-10 {
36            return -200.0;
37        }
38        20.0 * linear.log10()
39    }
40}
41
42impl Default for Compressor {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl DspNode for Compressor {
49    fn process(
50        &mut self,
51        inputs: &[Option<&[f32; BUFFER_SIZE]>; MAX_INPUTS],
52        output: &mut [f32; BUFFER_SIZE],
53        params: &mut ParamBlock,
54        sample_rate: f32,
55    ) {
56        let silence = [0.0f32; BUFFER_SIZE];
57        let input = inputs[0].unwrap_or(&silence);
58
59        let threshold_db = params.get(0).current.clamp(-60.0, 0.0);
60        let ratio = params.get(1).current.clamp(1.0, 20.0);
61        let attack_ms = params.get(2).current.clamp(0.1, 200.0);
62        let release_ms = params.get(3).current.clamp(10.0, 2000.0);
63        let makeup_db = params.get(4).current.clamp(0.0, 24.0);
64        let knee_db = params.get(5).current.clamp(0.0, 12.0);
65
66        let attack_coeff = (-1.0 / (attack_ms * 0.001 * sample_rate)).exp();
67        let release_coeff = (-1.0 / (release_ms * 0.001 * sample_rate)).exp();
68        let makeup_linear = Self::db_to_linear(makeup_db);
69
70        for i in 0..BUFFER_SIZE {
71            let x = input[i];
72
73            // RMS envelope follower (squared signal, smoothed)
74            let x2 = x * x;
75            self.rms_env = if x2 > self.rms_env {
76                attack_coeff * self.rms_env + (1.0 - attack_coeff) * x2
77            } else {
78                release_coeff * self.rms_env + (1.0 - release_coeff) * x2
79            };
80            let rms_db = Self::linear_to_db(self.rms_env.sqrt());
81
82            // Gain computer with soft knee
83            let gain_reduction_db = if knee_db > 0.0 {
84                let knee_start = threshold_db - knee_db * 0.5;
85                let knee_end = threshold_db + knee_db * 0.5;
86                if rms_db <= knee_start {
87                    0.0
88                } else if rms_db >= knee_end {
89                    (rms_db - threshold_db) * (1.0 / ratio - 1.0)
90                } else {
91                    // Soft knee interpolation
92                    let t = (rms_db - knee_start) / knee_db;
93                    t * t * 0.5 * (1.0 / ratio - 1.0) * knee_db
94                }
95            } else {
96                if rms_db > threshold_db {
97                    (rms_db - threshold_db) * (1.0 / ratio - 1.0)
98                } else {
99                    0.0
100                }
101            };
102
103            let target_gain = Self::db_to_linear(gain_reduction_db);
104
105            // Smooth gain envelope
106            self.gain_env = if target_gain < self.gain_env {
107                attack_coeff * self.gain_env + (1.0 - attack_coeff) * target_gain
108            } else {
109                release_coeff * self.gain_env + (1.0 - release_coeff) * target_gain
110            };
111
112            output[i] = x * self.gain_env * makeup_linear;
113            params.tick_all();
114        }
115    }
116
117    fn type_name(&self) -> &'static str {
118        "Compressor"
119    }
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_compressor_silence_passthrough() {
128        let mut comp = Compressor::new();
129        let mut params = ParamBlock::new();
130        // threshold=0dB, ratio=1, attack=1ms, release=100ms, makeup=0dB, knee=0
131        for &v in &[-20.0f32, 2.0, 1.0, 100.0, 0.0, 0.0] {
132            params.add(v);
133        }
134        let input = [0.0f32; BUFFER_SIZE];
135        let inputs = [Some(&input); MAX_INPUTS];
136        let mut output = [0.0f32; BUFFER_SIZE];
137        comp.process(&inputs, &mut output, &mut params, 48000.0);
138        for s in &output {
139            assert!(s.abs() < 1e-6, "silence should pass through as silence");
140        }
141    }
142
143    #[test]
144    fn test_compressor_reduces_loud_signal() {
145        let mut comp = Compressor::new();
146        let mut params = ParamBlock::new();
147        // threshold=-20dB, ratio=4, attack=1ms, release=100ms, makeup=0dB, knee=0
148        for &v in &[-20.0f32, 4.0, 1.0, 100.0, 0.0, 0.0] {
149            params.add(v);
150        }
151        let input = [0.5f32; BUFFER_SIZE]; // ~-6dB, above threshold
152        let inputs = [Some(&input); MAX_INPUTS];
153        let mut output = [0.0f32; BUFFER_SIZE];
154        comp.process(&inputs, &mut output, &mut params, 48000.0);
155        // After settling, output should be quieter than input
156        let last = output[BUFFER_SIZE - 1].abs();
157        assert!(
158            last < 0.5,
159            "compressor should reduce gain above threshold, got {last}"
160        );
161    }
162}