Skip to main content

proteus_lib/dsp/effects/
compressor.rs

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