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        // The audio callback never allocates: `prepare` sized the per-channel
209        // state, so clamp to it rather than grow a wider block in place.
210        let prepared = self.envelopes.len();
211        debug_assert!(
212            output_channels(block) <= prepared,
213            "Compressor::process received more channels than prepare configured"
214        );
215        let channels = output_channels(block).min(prepared);
216        let frames = block.frames as usize;
217        for channel in 0..channels {
218            for frame in 0..frames {
219                let input = input_sample(block, channel, frame);
220                let level = self.envelopes[channel].next(input);
221                block.out_audio[channel][frame] = input * self.gain_for_level(level);
222            }
223        }
224    }
225}
226
227/// A brick-wall limiter [`Processor`] built on a fast, high-ratio compressor.
228#[derive(Clone, Debug, PartialEq)]
229pub struct Limiter {
230    inner: Compressor,
231}
232
233impl Limiter {
234    /// Creates a limiter at the given threshold in decibels.
235    pub fn new(threshold_db: f32) -> Self {
236        Self {
237            inner: Compressor::new(threshold_db, 20.0).with_timing(0.5, 30.0),
238        }
239    }
240}
241
242impl Processor for Limiter {
243    fn prepare(&mut self, cfg: PrepareConfig) {
244        self.inner.prepare(cfg);
245    }
246
247    fn reset(&mut self) {
248        self.inner.reset();
249    }
250
251    fn process(&mut self, block: &mut ProcessBlock<'_>) {
252        self.inner.process(block);
253    }
254}
255
256/// A per-channel noise gate [`Processor`].
257#[derive(Clone, Debug, PartialEq)]
258pub struct Gate {
259    threshold_db: f32,
260    closed_gain: f32,
261    sample_rate_hz: f32,
262    envelopes: Vec<DynamicsEnvelope>,
263}
264
265impl Gate {
266    /// Creates a gate with an open threshold and closed gain, both in decibels.
267    pub fn new(threshold_db: f32, closed_gain_db: f32) -> Self {
268        Self {
269            threshold_db,
270            closed_gain: db_to_gain(closed_gain_db),
271            sample_rate_hz: 48_000.0,
272            envelopes: Vec::new(),
273        }
274    }
275
276    fn envelope(&self) -> DynamicsEnvelope {
277        DynamicsEnvelope::new(self.sample_rate_hz, 2.0, 40.0)
278    }
279}
280
281impl Processor for Gate {
282    fn prepare(&mut self, cfg: PrepareConfig) {
283        self.sample_rate_hz = cfg.sample_rate_hz as f32;
284        let envelope = self.envelope();
285        prepare_channels(&mut self.envelopes, cfg.out_channels as usize, envelope);
286    }
287
288    fn reset(&mut self) {
289        for envelope in &mut self.envelopes {
290            envelope.reset();
291        }
292    }
293
294    fn process(&mut self, block: &mut ProcessBlock<'_>) {
295        // The audio callback never allocates: `prepare` sized the per-channel
296        // state, so clamp to it rather than grow a wider block in place.
297        let prepared = self.envelopes.len();
298        debug_assert!(
299            output_channels(block) <= prepared,
300            "Gate::process received more channels than prepare configured"
301        );
302        let channels = output_channels(block).min(prepared);
303        let frames = block.frames as usize;
304        for channel in 0..channels {
305            for frame in 0..frames {
306                let input = input_sample(block, channel, frame);
307                let level_db = gain_to_db(self.envelopes[channel].next(input));
308                let gain = if level_db < self.threshold_db {
309                    self.closed_gain
310                } else {
311                    1.0
312                };
313                block.out_audio[channel][frame] = input * gain;
314            }
315        }
316    }
317}