Skip to main content

proteus_lib/dsp/effects/
limiter.rs

1//! Limiter effect using rodio's built-in limiter.
2
3use std::collections::VecDeque;
4use std::time::Duration;
5
6use rodio::source::{Limit, LimitSettings, SeekError, Source};
7use serde::{Deserialize, Serialize};
8
9use super::EffectContext;
10
11const DEFAULT_THRESHOLD_DB: f32 = -1.0;
12const DEFAULT_KNEE_WIDTH_DB: f32 = 4.0;
13const DEFAULT_ATTACK_MS: f32 = 5.0;
14const DEFAULT_RELEASE_MS: f32 = 100.0;
15
16/// Serialized configuration for limiter parameters.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct LimiterSettings {
20    #[serde(alias = "threshold", alias = "threshold_db")]
21    pub threshold_db: f32,
22    #[serde(alias = "knee_width", alias = "knee_width_db")]
23    pub knee_width_db: f32,
24    #[serde(alias = "attack_ms", alias = "attack")]
25    pub attack_ms: f32,
26    #[serde(alias = "release_ms", alias = "release")]
27    pub release_ms: f32,
28}
29
30impl LimiterSettings {
31    /// Create limiter settings.
32    pub fn new(threshold_db: f32, knee_width_db: f32, attack_ms: f32, release_ms: f32) -> Self {
33        Self {
34            threshold_db,
35            knee_width_db,
36            attack_ms,
37            release_ms,
38        }
39    }
40}
41
42impl Default for LimiterSettings {
43    fn default() -> Self {
44        Self {
45            threshold_db: DEFAULT_THRESHOLD_DB,
46            knee_width_db: DEFAULT_KNEE_WIDTH_DB,
47            attack_ms: DEFAULT_ATTACK_MS,
48            release_ms: DEFAULT_RELEASE_MS,
49        }
50    }
51}
52
53/// Configured limiter effect with runtime state.
54#[derive(Clone, Serialize, Deserialize)]
55#[serde(default)]
56pub struct LimiterEffect {
57    pub enabled: bool,
58    #[serde(flatten)]
59    pub settings: LimiterSettings,
60    #[serde(skip)]
61    state: Option<LimiterState>,
62}
63
64impl std::fmt::Debug for LimiterEffect {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        f.debug_struct("LimiterEffect")
67            .field("enabled", &self.enabled)
68            .field("settings", &self.settings)
69            .finish()
70    }
71}
72
73impl Default for LimiterEffect {
74    fn default() -> Self {
75        Self {
76            enabled: false,
77            settings: LimiterSettings::default(),
78            state: None,
79        }
80    }
81}
82
83impl LimiterEffect {
84    /// Process interleaved samples through the limiter.
85    ///
86    /// # Arguments
87    /// - `samples`: Interleaved input samples.
88    /// - `context`: Environment details (sample rate, channels, etc.).
89    /// - `drain`: Unused for this effect.
90    ///
91    /// # Returns
92    /// Processed interleaved samples.
93    pub fn process(&mut self, samples: &[f32], context: &EffectContext, _drain: bool) -> Vec<f32> {
94        if !self.enabled {
95            return samples.to_vec();
96        }
97
98        self.ensure_state(context);
99        let Some(state) = self.state.as_mut() else {
100            return samples.to_vec();
101        };
102
103        if samples.is_empty() {
104            return Vec::new();
105        }
106
107        state.process(samples)
108    }
109
110    /// Reset any internal state held by the limiter.
111    pub fn reset_state(&mut self) {
112        if let Some(state) = self.state.as_mut() {
113            state.reset();
114        }
115        self.state = None;
116    }
117
118    fn ensure_state(&mut self, context: &EffectContext) {
119        let settings = sanitize_settings(&self.settings);
120        let channels = context.channels.max(1);
121
122        let needs_reset = self
123            .state
124            .as_ref()
125            .map(|state| !state.matches(context.sample_rate, channels, &settings))
126            .unwrap_or(true);
127
128        if needs_reset {
129            self.state = Some(LimiterState::new(context.sample_rate, channels, settings));
130        }
131    }
132}
133
134#[derive(Clone)]
135struct LimiterState {
136    sample_rate: u32,
137    channels: usize,
138    settings: LimiterSettings,
139    limiter: Limit<ChunkSource>,
140}
141
142impl LimiterState {
143    fn new(sample_rate: u32, channels: usize, settings: LimiterSettings) -> Self {
144        let source = ChunkSource::new(channels as u16, sample_rate);
145        let limiter = source.limit(build_limit_settings(&settings));
146        Self {
147            sample_rate,
148            channels,
149            settings,
150            limiter,
151        }
152    }
153
154    fn matches(&self, sample_rate: u32, channels: usize, settings: &LimiterSettings) -> bool {
155        self.sample_rate == sample_rate
156            && self.channels == channels
157            && (self.settings.threshold_db - settings.threshold_db).abs() < f32::EPSILON
158            && (self.settings.knee_width_db - settings.knee_width_db).abs() < f32::EPSILON
159            && (self.settings.attack_ms - settings.attack_ms).abs() < f32::EPSILON
160            && (self.settings.release_ms - settings.release_ms).abs() < f32::EPSILON
161    }
162
163    fn process(&mut self, samples: &[f32]) -> Vec<f32> {
164        {
165            let inner = self.limiter.inner_mut();
166            inner.push_samples(samples);
167        }
168
169        let mut output = Vec::with_capacity(samples.len());
170        for _ in 0..samples.len() {
171            if let Some(sample) = self.limiter.next() {
172                output.push(sample);
173            } else {
174                break;
175            }
176        }
177        output
178    }
179
180    fn reset(&mut self) {
181        let source = ChunkSource::new(self.channels as u16, self.sample_rate);
182        self.limiter = source.limit(build_limit_settings(&self.settings));
183    }
184}
185
186#[derive(Clone, Debug)]
187struct ChunkSource {
188    channels: u16,
189    sample_rate: u32,
190    queue: VecDeque<f32>,
191}
192
193impl ChunkSource {
194    fn new(channels: u16, sample_rate: u32) -> Self {
195        Self {
196            channels,
197            sample_rate,
198            queue: VecDeque::new(),
199        }
200    }
201
202    fn push_samples(&mut self, samples: &[f32]) {
203        self.queue.extend(samples.iter().copied());
204    }
205}
206
207impl Iterator for ChunkSource {
208    type Item = f32;
209
210    fn next(&mut self) -> Option<Self::Item> {
211        self.queue.pop_front()
212    }
213
214    fn size_hint(&self) -> (usize, Option<usize>) {
215        let len = self.queue.len();
216        (len, Some(len))
217    }
218}
219
220impl Source for ChunkSource {
221    fn current_span_len(&self) -> Option<usize> {
222        Some(self.queue.len())
223    }
224
225    fn channels(&self) -> u16 {
226        self.channels
227    }
228
229    fn sample_rate(&self) -> u32 {
230        self.sample_rate
231    }
232
233    fn total_duration(&self) -> Option<Duration> {
234        None
235    }
236
237    fn try_seek(&mut self, _pos: Duration) -> Result<(), SeekError> {
238        Err(SeekError::NotSupported {
239            underlying_source: "ChunkSource",
240        })
241    }
242}
243
244fn build_limit_settings(settings: &LimiterSettings) -> LimitSettings {
245    LimitSettings::default()
246        .with_threshold(settings.threshold_db)
247        .with_knee_width(settings.knee_width_db)
248        .with_attack(Duration::from_secs_f32(settings.attack_ms / 1000.0))
249        .with_release(Duration::from_secs_f32(settings.release_ms / 1000.0))
250}
251
252fn sanitize_settings(settings: &LimiterSettings) -> LimiterSettings {
253    LimiterSettings {
254        threshold_db: sanitize_threshold_db(settings.threshold_db),
255        knee_width_db: sanitize_knee_width_db(settings.knee_width_db),
256        attack_ms: sanitize_time_ms(settings.attack_ms, DEFAULT_ATTACK_MS),
257        release_ms: sanitize_time_ms(settings.release_ms, DEFAULT_RELEASE_MS),
258    }
259}
260
261fn sanitize_threshold_db(value: f32) -> f32 {
262    if !value.is_finite() {
263        return DEFAULT_THRESHOLD_DB;
264    }
265    value.min(0.0)
266}
267
268fn sanitize_knee_width_db(value: f32) -> f32 {
269    if !value.is_finite() {
270        return DEFAULT_KNEE_WIDTH_DB;
271    }
272    value.max(0.1)
273}
274
275fn sanitize_time_ms(value: f32, fallback: f32) -> f32 {
276    if !value.is_finite() {
277        return fallback;
278    }
279    value.max(0.0)
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    fn context(channels: usize) -> EffectContext {
287        EffectContext {
288            sample_rate: 48_000,
289            channels,
290            container_path: None,
291            impulse_response_spec: None,
292            impulse_response_tail_db: -60.0,
293        }
294    }
295
296    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
297        (a - b).abs() <= eps
298    }
299
300    #[test]
301    fn limiter_disabled_passthrough() {
302        let mut effect = LimiterEffect::default();
303        let samples = vec![0.25_f32, -0.25, 0.5, -0.5];
304        let output = effect.process(&samples, &context(2), false);
305        assert_eq!(output, samples);
306    }
307
308    #[test]
309    fn limiter_reduces_hot_signal() {
310        let mut effect = LimiterEffect::default();
311        effect.enabled = true;
312        effect.settings.threshold_db = -12.0;
313        effect.settings.knee_width_db = 0.5;
314        effect.settings.attack_ms = 0.0;
315        effect.settings.release_ms = 0.0;
316
317        let samples = vec![1.0_f32, -1.0, 1.0, -1.0];
318        let output = effect.process(&samples, &context(2), false);
319        assert_eq!(output.len(), samples.len());
320        assert!(output.iter().all(|value| value.is_finite()));
321        assert!(output.iter().any(|value| value.abs() < 1.0));
322    }
323
324    #[test]
325    fn limiter_split_matches_single_pass() {
326        let mut settings = LimiterEffect::default();
327        settings.enabled = true;
328        settings.settings.threshold_db = -6.0;
329        settings.settings.knee_width_db = 1.0;
330        settings.settings.attack_ms = 0.0;
331        settings.settings.release_ms = 0.0;
332
333        let samples = vec![1.0_f32, -1.0, 0.8, -0.8, 0.6, -0.6, 0.4, -0.4];
334
335        let mut effect_full = settings.clone();
336        let out_full = effect_full.process(&samples, &context(2), false);
337
338        let mut effect_split = settings;
339        let mid = samples.len() / 2;
340        let mut out_split = effect_split.process(&samples[..mid], &context(2), false);
341        out_split.extend(effect_split.process(&samples[mid..], &context(2), false));
342
343        assert_eq!(out_full.len(), out_split.len());
344        for (a, b) in out_full.iter().zip(out_split.iter()) {
345            assert!(approx_eq(*a, *b, 1e-5));
346        }
347    }
348}