Skip to main content

sim_lib_audio_dsp/
dynamics.rs

1use sim_lib_audio_graph_core::{PrepareConfig, ProcessBlock, Processor};
2
3use crate::common::{db_to_gain, gain_to_db, input_sample, output_channels, prepare_channels};
4
5/// A peak envelope follower with separate attack and release time constants.
6#[derive(Clone, Copy, Debug, PartialEq)]
7pub struct DynamicsEnvelope {
8    value: f32,
9    attack_coeff: f32,
10    release_coeff: f32,
11}
12
13impl DynamicsEnvelope {
14    /// Creates an envelope follower from attack and release times in
15    /// milliseconds at the given sample rate.
16    pub fn new(sample_rate_hz: f32, attack_ms: f32, release_ms: f32) -> Self {
17        Self {
18            value: 0.0,
19            attack_coeff: time_coeff(sample_rate_hz, attack_ms),
20            release_coeff: time_coeff(sample_rate_hz, release_ms),
21        }
22    }
23
24    /// Advances the envelope with one input sample and returns the new level.
25    pub fn next(&mut self, input: f32) -> f32 {
26        let level = input.abs();
27        let coeff = if level > self.value {
28            self.attack_coeff
29        } else {
30            self.release_coeff
31        };
32        self.value = coeff * self.value + (1.0 - coeff) * level;
33        self.value
34    }
35
36    /// Resets the envelope to silence.
37    pub fn reset(&mut self) {
38        self.value = 0.0;
39    }
40}
41
42fn time_coeff(sample_rate_hz: f32, time_ms: f32) -> f32 {
43    let samples = (sample_rate_hz * time_ms.max(0.001)) / 1000.0;
44    (-1.0 / samples).exp()
45}
46
47/// Nonlinear transfer curve used by [`Waveshaper`].
48#[derive(Clone, Copy, Debug, PartialEq, Eq)]
49pub enum Waveshape {
50    /// Hyperbolic-tangent soft saturation.
51    Tanh,
52    /// Cubic soft saturation.
53    Cubic,
54    /// Hard clip to `-1.0..=1.0`.
55    HardClip,
56}
57
58/// A [`Processor`] applying a nonlinear waveshaping curve with drive and output
59/// gain.
60#[derive(Clone, Debug, PartialEq)]
61pub struct Waveshaper {
62    drive: f32,
63    output_gain: f32,
64    shape: Waveshape,
65}
66
67impl Waveshaper {
68    /// Creates a waveshaper with the given curve and drive (clamped to `>= 0`).
69    pub fn new(shape: Waveshape, drive: f32) -> Self {
70        Self {
71            drive: drive.max(0.0),
72            output_gain: 1.0,
73            shape,
74        }
75    }
76
77    /// Returns the waveshaper with its post-curve output gain set.
78    pub fn with_output_gain(mut self, output_gain: f32) -> Self {
79        self.output_gain = output_gain;
80        self
81    }
82
83    /// Applies the drive, curve, and output gain to one sample.
84    pub fn process_sample(&self, input: f32) -> f32 {
85        let x = input * self.drive;
86        let shaped = match self.shape {
87            Waveshape::Tanh => x.tanh(),
88            Waveshape::Cubic => (x - x.powi(3) / 3.0).clamp(-1.0, 1.0),
89            Waveshape::HardClip => x.clamp(-1.0, 1.0),
90        };
91        shaped * self.output_gain
92    }
93}
94
95impl Processor for Waveshaper {
96    fn prepare(&mut self, _cfg: PrepareConfig) {}
97
98    fn reset(&mut self) {}
99
100    fn process(&mut self, block: &mut ProcessBlock<'_>) {
101        let frames = block.frames as usize;
102        for channel in 0..output_channels(block) {
103            for frame in 0..frames {
104                block.out_audio[channel][frame] =
105                    self.process_sample(input_sample(block, channel, frame));
106            }
107        }
108    }
109}
110
111/// A [`Processor`] soft-clipping via a tanh [`Waveshaper`].
112#[derive(Clone, Debug, PartialEq)]
113pub struct SoftClipper {
114    inner: Waveshaper,
115}
116
117impl SoftClipper {
118    /// Creates a soft clipper with the given drive.
119    pub fn new(drive: f32) -> Self {
120        Self {
121            inner: Waveshaper::new(Waveshape::Tanh, drive),
122        }
123    }
124}
125
126impl Processor for SoftClipper {
127    fn prepare(&mut self, cfg: PrepareConfig) {
128        self.inner.prepare(cfg);
129    }
130
131    fn reset(&mut self) {
132        self.inner.reset();
133    }
134
135    fn process(&mut self, block: &mut ProcessBlock<'_>) {
136        self.inner.process(block);
137    }
138}
139
140/// A per-channel feed-forward compressor [`Processor`].
141#[derive(Clone, Debug, PartialEq)]
142pub struct Compressor {
143    threshold_db: f32,
144    ratio: f32,
145    makeup_gain: f32,
146    sample_rate_hz: f32,
147    attack_ms: f32,
148    release_ms: f32,
149    envelopes: Vec<DynamicsEnvelope>,
150}
151
152impl Compressor {
153    /// Creates a compressor with the given threshold (dB) and ratio (clamped to
154    /// `>= 1.0`), with default timing and unity makeup gain.
155    pub fn new(threshold_db: f32, ratio: f32) -> Self {
156        Self {
157            threshold_db,
158            ratio: ratio.max(1.0),
159            makeup_gain: 1.0,
160            sample_rate_hz: 48_000.0,
161            attack_ms: 5.0,
162            release_ms: 80.0,
163            envelopes: Vec::new(),
164        }
165    }
166
167    /// Returns the compressor with attack and release times (ms) set.
168    pub fn with_timing(mut self, attack_ms: f32, release_ms: f32) -> Self {
169        self.attack_ms = attack_ms;
170        self.release_ms = release_ms;
171        self
172    }
173
174    /// Returns the compressor with makeup gain set, in decibels.
175    pub fn with_makeup_gain_db(mut self, makeup_db: f32) -> Self {
176        self.makeup_gain = db_to_gain(makeup_db);
177        self
178    }
179
180    fn envelope(&self) -> DynamicsEnvelope {
181        DynamicsEnvelope::new(self.sample_rate_hz, self.attack_ms, self.release_ms)
182    }
183
184    fn gain_for_level(&self, level: f32) -> f32 {
185        let level_db = gain_to_db(level);
186        if level_db <= self.threshold_db {
187            return self.makeup_gain;
188        }
189        let compressed_db = self.threshold_db + (level_db - self.threshold_db) / self.ratio;
190        db_to_gain(compressed_db - level_db) * self.makeup_gain
191    }
192}
193
194impl Processor for Compressor {
195    fn prepare(&mut self, cfg: PrepareConfig) {
196        self.sample_rate_hz = cfg.sample_rate_hz as f32;
197        let envelope = self.envelope();
198        prepare_channels(&mut self.envelopes, cfg.out_channels as usize, envelope);
199    }
200
201    fn reset(&mut self) {
202        for envelope in &mut self.envelopes {
203            envelope.reset();
204        }
205    }
206
207    fn process(&mut self, block: &mut ProcessBlock<'_>) {
208        let channels = output_channels(block);
209        if self.envelopes.len() < channels {
210            self.envelopes.resize(channels, self.envelope());
211        }
212        let frames = block.frames as usize;
213        for channel in 0..channels {
214            for frame in 0..frames {
215                let input = input_sample(block, channel, frame);
216                let level = self.envelopes[channel].next(input);
217                block.out_audio[channel][frame] = input * self.gain_for_level(level);
218            }
219        }
220    }
221}
222
223/// A brick-wall limiter [`Processor`] built on a fast, high-ratio compressor.
224#[derive(Clone, Debug, PartialEq)]
225pub struct Limiter {
226    inner: Compressor,
227}
228
229impl Limiter {
230    /// Creates a limiter at the given threshold in decibels.
231    pub fn new(threshold_db: f32) -> Self {
232        Self {
233            inner: Compressor::new(threshold_db, 20.0).with_timing(0.5, 30.0),
234        }
235    }
236}
237
238impl Processor for Limiter {
239    fn prepare(&mut self, cfg: PrepareConfig) {
240        self.inner.prepare(cfg);
241    }
242
243    fn reset(&mut self) {
244        self.inner.reset();
245    }
246
247    fn process(&mut self, block: &mut ProcessBlock<'_>) {
248        self.inner.process(block);
249    }
250}
251
252/// A per-channel noise gate [`Processor`].
253#[derive(Clone, Debug, PartialEq)]
254pub struct Gate {
255    threshold_db: f32,
256    closed_gain: f32,
257    sample_rate_hz: f32,
258    envelopes: Vec<DynamicsEnvelope>,
259}
260
261impl Gate {
262    /// Creates a gate with an open threshold and closed gain, both in decibels.
263    pub fn new(threshold_db: f32, closed_gain_db: f32) -> Self {
264        Self {
265            threshold_db,
266            closed_gain: db_to_gain(closed_gain_db),
267            sample_rate_hz: 48_000.0,
268            envelopes: Vec::new(),
269        }
270    }
271
272    fn envelope(&self) -> DynamicsEnvelope {
273        DynamicsEnvelope::new(self.sample_rate_hz, 2.0, 40.0)
274    }
275}
276
277impl Processor for Gate {
278    fn prepare(&mut self, cfg: PrepareConfig) {
279        self.sample_rate_hz = cfg.sample_rate_hz as f32;
280        let envelope = self.envelope();
281        prepare_channels(&mut self.envelopes, cfg.out_channels as usize, envelope);
282    }
283
284    fn reset(&mut self) {
285        for envelope in &mut self.envelopes {
286            envelope.reset();
287        }
288    }
289
290    fn process(&mut self, block: &mut ProcessBlock<'_>) {
291        let channels = output_channels(block);
292        if self.envelopes.len() < channels {
293            self.envelopes.resize(channels, self.envelope());
294        }
295        let frames = block.frames as usize;
296        for channel in 0..channels {
297            for frame in 0..frames {
298                let input = input_sample(block, channel, frame);
299                let level_db = gain_to_db(self.envelopes[channel].next(input));
300                let gain = if level_db < self.threshold_db {
301                    self.closed_gain
302                } else {
303                    1.0
304                };
305                block.out_audio[channel][frame] = input * gain;
306            }
307        }
308    }
309}