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