Skip to main content

math_audio_dsp/
envelope.rs

1// ============================================================================
2// Dual-Release Envelope — Program-dependent release for dynamics plugins
3// ============================================================================
4
5/// A dual-release envelope follower that switches between fast and slow release
6/// based on the behavior of the gain reduction signal.
7///
8/// When gain reduction increases quickly (transients), fast release is used.
9/// When gain reduction is sustained, slow release is used.
10/// This prevents pumping on sustained signals while still allowing fast recovery
11/// from transients.
12#[derive(Debug, Clone, Copy)]
13pub struct DualRelease {
14    fast_coeff: f32,
15    slow_coeff: f32,
16    /// The current blended release coefficient.
17    current_coeff: f32,
18    /// Smoothed measure of how "sustained" the gain reduction is.
19    sustain_tracker: f32,
20    /// Coefficient for the sustain tracker's own smoothing.
21    sustain_coeff: f32,
22    /// Previous gain reduction value for derivative estimation.
23    prev_gr: f32,
24}
25
26impl DualRelease {
27    /// Create a new dual-release envelope.
28    ///
29    /// * `fast_ms` — fast release time (e.g., 50ms)
30    /// * `slow_ms` — slow release time (e.g., 500ms)
31    /// * `sample_rate` — audio sample rate
32    pub fn new(fast_ms: f32, slow_ms: f32, sample_rate: u32) -> Self {
33        Self {
34            fast_coeff: Self::time_to_coeff(fast_ms, sample_rate),
35            slow_coeff: Self::time_to_coeff(slow_ms, sample_rate),
36            current_coeff: Self::time_to_coeff(fast_ms, sample_rate),
37            sustain_tracker: 0.0,
38            sustain_coeff: Self::time_to_coeff(200.0, sample_rate), // 200ms integration
39            prev_gr: 0.0,
40        }
41    }
42
43    fn time_to_coeff(time_ms: f32, sample_rate: u32) -> f32 {
44        if time_ms <= 0.0 {
45            0.0
46        } else {
47            (-1.0 / (time_ms * 0.001 * sample_rate as f32)).exp()
48        }
49    }
50
51    /// Process one sample of gain reduction and return the appropriate release coefficient.
52    ///
53    /// `gain_reduction_db` should be positive when gain is being reduced.
54    #[inline]
55    pub fn process(&mut self, gain_reduction_db: f32) -> f32 {
56        // Estimate the "sustain-ness": when GR is steady, sustain→1; when changing fast, sustain→0
57        let delta = (gain_reduction_db - self.prev_gr).abs();
58        self.prev_gr = gain_reduction_db;
59
60        // Fast changes → low sustain, steady → high sustain
61        // Absolute threshold: if delta < 0.01 dB/sample, treat as sustained.
62        // Typical compressor attack: 6 dB over 480 samples = 0.0125 dB/sample.
63        let sustained = if delta < 0.01 { 1.0 } else { 0.0 };
64        self.sustain_tracker = sustained + self.sustain_coeff * (self.sustain_tracker - sustained);
65
66        // Blend between fast and slow based on sustain level
67        let blend = self.sustain_tracker.clamp(0.0, 1.0);
68        self.current_coeff = self.fast_coeff * (1.0 - blend) + self.slow_coeff * blend;
69
70        self.current_coeff
71    }
72
73    /// Get the current blended release coefficient.
74    #[inline]
75    pub fn coeff(&self) -> f32 {
76        self.current_coeff
77    }
78
79    /// Update the fast and slow release times.
80    pub fn set_times(&mut self, fast_ms: f32, slow_ms: f32, sample_rate: u32) {
81        self.fast_coeff = Self::time_to_coeff(fast_ms, sample_rate);
82        self.slow_coeff = Self::time_to_coeff(slow_ms, sample_rate);
83    }
84
85    pub fn reset(&mut self) {
86        self.sustain_tracker = 0.0;
87        self.prev_gr = 0.0;
88        self.current_coeff = self.fast_coeff;
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn test_fast_release_on_transient() {
98        let mut dr = DualRelease::new(50.0, 500.0, 48000);
99        // Sudden large change → should use fast release
100        let coeff = dr.process(20.0);
101        // Fast coeff at 50ms
102        let expected_fast = (-1.0f32 / (50.0 * 0.001 * 48000.0)).exp();
103        assert!((coeff - expected_fast).abs() < 0.01);
104    }
105
106    #[test]
107    fn test_slow_release_on_sustained() {
108        let mut dr = DualRelease::new(50.0, 500.0, 48000);
109        // Feed steady GR for many samples
110        for _ in 0..48000 {
111            dr.process(10.0);
112        }
113        let coeff = dr.coeff();
114        let expected_slow = (-1.0f32 / (500.0 * 0.001 * 48000.0)).exp();
115        // Should be close to slow coeff after sustained signal
116        assert!((coeff - expected_slow).abs() < 0.01);
117    }
118
119    #[test]
120    fn test_reset() {
121        let mut dr = DualRelease::new(50.0, 500.0, 48000);
122        for _ in 0..10000 {
123            dr.process(10.0);
124        }
125        dr.reset();
126        assert_eq!(dr.sustain_tracker, 0.0);
127        assert_eq!(dr.prev_gr, 0.0);
128    }
129}