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(
130                context.sample_rate,
131                channels,
132                settings,
133            ));
134        }
135    }
136}
137
138#[derive(Clone)]
139struct LimiterState {
140    sample_rate: u32,
141    channels: usize,
142    settings: LimiterSettings,
143    limiter: Limit<ChunkSource>,
144}
145
146impl LimiterState {
147    fn new(sample_rate: u32, channels: usize, settings: LimiterSettings) -> Self {
148        let source = ChunkSource::new(channels as u16, sample_rate);
149        let limiter = source.limit(build_limit_settings(&settings));
150        Self {
151            sample_rate,
152            channels,
153            settings,
154            limiter,
155        }
156    }
157
158    fn matches(&self, sample_rate: u32, channels: usize, settings: &LimiterSettings) -> bool {
159        self.sample_rate == sample_rate
160            && self.channels == channels
161            && (self.settings.threshold_db - settings.threshold_db).abs() < f32::EPSILON
162            && (self.settings.knee_width_db - settings.knee_width_db).abs() < f32::EPSILON
163            && (self.settings.attack_ms - settings.attack_ms).abs() < f32::EPSILON
164            && (self.settings.release_ms - settings.release_ms).abs() < f32::EPSILON
165    }
166
167    fn process(&mut self, samples: &[f32]) -> Vec<f32> {
168        {
169            let inner = self.limiter.inner_mut();
170            inner.push_samples(samples);
171        }
172
173        let mut output = Vec::with_capacity(samples.len());
174        for _ in 0..samples.len() {
175            if let Some(sample) = self.limiter.next() {
176                output.push(sample);
177            } else {
178                break;
179            }
180        }
181        output
182    }
183
184    fn reset(&mut self) {
185        let source = ChunkSource::new(self.channels as u16, self.sample_rate);
186        self.limiter = source.limit(build_limit_settings(&self.settings));
187    }
188}
189
190#[derive(Clone, Debug)]
191struct ChunkSource {
192    channels: u16,
193    sample_rate: u32,
194    queue: VecDeque<f32>,
195}
196
197impl ChunkSource {
198    fn new(channels: u16, sample_rate: u32) -> Self {
199        Self {
200            channels,
201            sample_rate,
202            queue: VecDeque::new(),
203        }
204    }
205
206    fn push_samples(&mut self, samples: &[f32]) {
207        self.queue.extend(samples.iter().copied());
208    }
209}
210
211impl Iterator for ChunkSource {
212    type Item = f32;
213
214    fn next(&mut self) -> Option<Self::Item> {
215        self.queue.pop_front()
216    }
217
218    fn size_hint(&self) -> (usize, Option<usize>) {
219        let len = self.queue.len();
220        (len, Some(len))
221    }
222}
223
224impl Source for ChunkSource {
225    fn current_span_len(&self) -> Option<usize> {
226        Some(self.queue.len())
227    }
228
229    fn channels(&self) -> u16 {
230        self.channels
231    }
232
233    fn sample_rate(&self) -> u32 {
234        self.sample_rate
235    }
236
237    fn total_duration(&self) -> Option<Duration> {
238        None
239    }
240
241    fn try_seek(&mut self, _pos: Duration) -> Result<(), SeekError> {
242        Err(SeekError::NotSupported {
243            underlying_source: "ChunkSource",
244        })
245    }
246}
247
248fn build_limit_settings(settings: &LimiterSettings) -> LimitSettings {
249    LimitSettings::default()
250        .with_threshold(settings.threshold_db)
251        .with_knee_width(settings.knee_width_db)
252        .with_attack(Duration::from_secs_f32(settings.attack_ms / 1000.0))
253        .with_release(Duration::from_secs_f32(settings.release_ms / 1000.0))
254}
255
256fn sanitize_settings(settings: &LimiterSettings) -> LimiterSettings {
257    LimiterSettings {
258        threshold_db: sanitize_threshold_db(settings.threshold_db),
259        knee_width_db: sanitize_knee_width_db(settings.knee_width_db),
260        attack_ms: sanitize_time_ms(settings.attack_ms, DEFAULT_ATTACK_MS),
261        release_ms: sanitize_time_ms(settings.release_ms, DEFAULT_RELEASE_MS),
262    }
263}
264
265fn sanitize_threshold_db(value: f32) -> f32 {
266    if !value.is_finite() {
267        return DEFAULT_THRESHOLD_DB;
268    }
269    value.min(0.0)
270}
271
272fn sanitize_knee_width_db(value: f32) -> f32 {
273    if !value.is_finite() {
274        return DEFAULT_KNEE_WIDTH_DB;
275    }
276    value.max(0.1)
277}
278
279fn sanitize_time_ms(value: f32, fallback: f32) -> f32 {
280    if !value.is_finite() {
281        return fallback;
282    }
283    value.max(0.0)
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289
290    fn context(channels: usize) -> EffectContext {
291        EffectContext {
292            sample_rate: 48_000,
293            channels,
294            container_path: None,
295            impulse_response_spec: None,
296            impulse_response_tail_db: -60.0,
297        }
298    }
299
300    fn approx_eq(a: f32, b: f32, eps: f32) -> bool {
301        (a - b).abs() <= eps
302    }
303
304    #[test]
305    fn limiter_disabled_passthrough() {
306        let mut effect = LimiterEffect::default();
307        let samples = vec![0.25_f32, -0.25, 0.5, -0.5];
308        let output = effect.process(&samples, &context(2), false);
309        assert_eq!(output, samples);
310    }
311
312    #[test]
313    fn limiter_reduces_hot_signal() {
314        let mut effect = LimiterEffect::default();
315        effect.enabled = true;
316        effect.settings.threshold_db = -12.0;
317        effect.settings.knee_width_db = 0.5;
318        effect.settings.attack_ms = 0.0;
319        effect.settings.release_ms = 0.0;
320
321        let samples = vec![1.0_f32, -1.0, 1.0, -1.0];
322        let output = effect.process(&samples, &context(2), false);
323        assert_eq!(output.len(), samples.len());
324        assert!(output.iter().all(|value| value.is_finite()));
325        assert!(output.iter().any(|value| value.abs() < 1.0));
326    }
327
328    #[test]
329    fn limiter_split_matches_single_pass() {
330        let mut settings = LimiterEffect::default();
331        settings.enabled = true;
332        settings.settings.threshold_db = -6.0;
333        settings.settings.knee_width_db = 1.0;
334        settings.settings.attack_ms = 0.0;
335        settings.settings.release_ms = 0.0;
336
337        let samples = vec![1.0_f32, -1.0, 0.8, -0.8, 0.6, -0.6, 0.4, -0.4];
338
339        let mut effect_full = settings.clone();
340        let out_full = effect_full.process(&samples, &context(2), false);
341
342        let mut effect_split = settings;
343        let mid = samples.len() / 2;
344        let mut out_split = effect_split.process(&samples[..mid], &context(2), false);
345        out_split.extend(effect_split.process(&samples[mid..], &context(2), false));
346
347        assert_eq!(out_full.len(), out_split.len());
348        for (a, b) in out_full.iter().zip(out_split.iter()) {
349            assert!(approx_eq(*a, *b, 1e-5));
350        }
351    }
352}