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