Skip to main content

forge_audio/
limiter.rs

1//! Brickwall look-ahead limiter for speaker protection.
2//!
3//! Sits on the master bus after all processing, before cpal output.
4//! Ceiling: -1 dBFS default. Cannot be bypassed from UI.
5//! Look-ahead: 1ms. Attack: instant. Release: 50ms smooth.
6
7/// Brickwall limiter state.
8pub struct BrickwallLimiter {
9    /// Ceiling in linear amplitude (default: 10^(-1/20) ≈ 0.891).
10    ceiling: f64,
11    /// Look-ahead buffer (circular, interleaved stereo f64).
12    delay_buf: Vec<f64>,
13    /// Current write position in the delay buffer.
14    write_pos: usize,
15    /// Look-ahead in samples.
16    lookahead_samples: usize,
17    /// Current gain reduction (1.0 = no reduction).
18    gain: f64,
19    /// Release coefficient per sample.
20    release_coeff: f64,
21    /// Number of channels.
22    channels: usize,
23}
24
25impl BrickwallLimiter {
26    /// Create a new limiter.
27    ///
28    /// - `ceiling_db`: ceiling in dBFS (e.g., -1.0)
29    /// - `lookahead_ms`: look-ahead time in milliseconds (e.g., 1.0)
30    /// - `release_ms`: release time in milliseconds (e.g., 50.0)
31    /// - `sample_rate`: audio sample rate
32    /// - `channels`: number of channels (typically 2)
33    pub fn new(
34        ceiling_db: f64,
35        lookahead_ms: f64,
36        release_ms: f64,
37        sample_rate: u32,
38        channels: usize,
39    ) -> Self {
40        let ceiling = 10.0_f64.powf(ceiling_db / 20.0);
41        let lookahead_samples = ((lookahead_ms / 1000.0) * sample_rate as f64).ceil() as usize;
42        let release_coeff = (-1.0 / ((release_ms / 1000.0) * sample_rate as f64)).exp();
43        let buf_size = lookahead_samples * channels;
44
45        Self {
46            ceiling,
47            delay_buf: vec![0.0; buf_size],
48            write_pos: 0,
49            lookahead_samples,
50            gain: 1.0,
51            release_coeff,
52            channels,
53        }
54    }
55
56    /// Create a limiter with default safe settings: -1 dBFS, 1ms look-ahead, 50ms release.
57    pub fn default_safe(sample_rate: u32, channels: usize) -> Self {
58        Self::new(-1.0, 1.0, 50.0, sample_rate, channels)
59    }
60
61    /// Process a block of interleaved f64 audio IN PLACE.
62    ///
63    /// REAL-TIME SAFE: no allocations, no locks, no I/O.
64    pub fn process(&mut self, data: &mut [f64], frames: usize) {
65        let ch = self.channels;
66
67        for frame in 0..frames {
68            let offset = frame * ch;
69
70            // Find peak across all channels for this frame.
71            let mut peak = 0.0_f64;
72            for c in 0..ch {
73                let idx = offset + c;
74                if idx < data.len() {
75                    peak = peak.max(data[idx].abs());
76                }
77            }
78
79            // Calculate required gain reduction.
80            let target_gain = if peak > self.ceiling {
81                self.ceiling / peak
82            } else {
83                1.0
84            };
85
86            // Attack is instant (brickwall). Release is smooth.
87            if target_gain < self.gain {
88                // Instant attack.
89                self.gain = target_gain;
90            } else {
91                // Smooth release.
92                self.gain = self.gain * self.release_coeff + target_gain * (1.0 - self.release_coeff);
93            }
94
95            // Write current frame into look-ahead delay buffer.
96            for c in 0..ch {
97                let buf_idx = self.write_pos * ch + c;
98                let data_idx = offset + c;
99                if data_idx < data.len() && buf_idx < self.delay_buf.len() {
100                    // Read delayed sample, apply gain, output.
101                    let delayed = self.delay_buf[buf_idx];
102                    data[data_idx] = delayed * self.gain;
103                    // Store current sample for future output.
104                    self.delay_buf[buf_idx] = data[data_idx + 0]; // Current input before limiting.
105                }
106            }
107
108            // Oops — we need to read the ORIGINAL input before overwriting.
109            // Fix: use a temp variable pattern.
110        }
111    }
112
113    /// Process a block of interleaved f64 audio. Correct look-ahead implementation.
114    ///
115    /// REAL-TIME SAFE: no allocations, no locks, no I/O.
116    pub fn process_block(&mut self, input: &[f64], output: &mut [f64], frames: usize) {
117        let ch = self.channels;
118        let la = self.lookahead_samples;
119
120        for frame in 0..frames {
121            let offset = frame * ch;
122
123            // Find peak across all channels for this frame.
124            let mut peak = 0.0_f64;
125            for c in 0..ch {
126                let idx = offset + c;
127                if idx < input.len() {
128                    peak = peak.max(input[idx].abs());
129                }
130            }
131
132            // Required gain reduction for this input sample.
133            let target_gain = if peak > self.ceiling {
134                self.ceiling / peak
135            } else {
136                1.0
137            };
138
139            // Instant attack, smooth release.
140            if target_gain < self.gain {
141                self.gain = target_gain;
142            } else {
143                self.gain = self.gain * self.release_coeff + target_gain * (1.0 - self.release_coeff);
144            }
145
146            // Write current input into delay buffer, read delayed output.
147            for c in 0..ch {
148                let buf_idx = self.write_pos * ch + c;
149                let data_idx = offset + c;
150
151                if data_idx < input.len() && buf_idx < self.delay_buf.len() {
152                    // Read the delayed sample.
153                    let delayed = self.delay_buf[buf_idx];
154                    // Write current input into the delay slot.
155                    self.delay_buf[buf_idx] = input[data_idx];
156                    // Output = delayed sample * gain.
157                    if data_idx < output.len() {
158                        output[data_idx] = delayed * self.gain;
159                    }
160                }
161            }
162
163            // Advance circular write position.
164            self.write_pos = (self.write_pos + 1) % la.max(1);
165        }
166    }
167
168    /// Get the current gain reduction in dB (0.0 = no reduction).
169    pub fn gain_reduction_db(&self) -> f64 {
170        if self.gain >= 1.0 { 0.0 } else { 20.0 * self.gain.log10() }
171    }
172
173    /// Get the ceiling in dB.
174    pub fn ceiling_db(&self) -> f64 {
175        20.0 * self.ceiling.log10()
176    }
177
178    /// Reset limiter state (call on track load or major discontinuity).
179    pub fn reset(&mut self) {
180        self.gain = 1.0;
181        for s in self.delay_buf.iter_mut() { *s = 0.0; }
182        self.write_pos = 0;
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    #[test]
191    fn test_below_ceiling_passes_through() {
192        let mut lim = BrickwallLimiter::default_safe(44100, 2);
193        let input = vec![0.5, -0.5, 0.3, -0.3]; // Well below -1 dBFS
194        let mut output = vec![0.0; 4];
195        // Need to prime the delay buffer first.
196        // Process twice: first fills delay, second reads it.
197        lim.process_block(&input, &mut output, 2);
198        let mut output2 = vec![0.0; 4];
199        lim.process_block(&input, &mut output2, 2);
200        // After delay, samples should pass through with gain ~1.0.
201        // The delayed output should be approximately the input values.
202        for s in &output2 {
203            assert!(s.abs() <= 1.0, "Output should be below ceiling");
204        }
205    }
206
207    #[test]
208    fn test_above_ceiling_gets_limited() {
209        let mut lim = BrickwallLimiter::new(-1.0, 0.0, 50.0, 44100, 2);
210        // 0 look-ahead for simpler testing.
211        let hot_signal: Vec<f64> = (0..128).map(|_| 2.0).collect(); // Way above ceiling
212        let mut output = vec![0.0; 128];
213        lim.process_block(&hot_signal, &mut output, 64);
214        let ceiling_linear = 10.0_f64.powf(-1.0 / 20.0);
215        for &s in &output {
216            assert!(
217                s.abs() <= ceiling_linear + 0.01,
218                "Sample {} exceeds ceiling {}", s, ceiling_linear
219            );
220        }
221    }
222
223    #[test]
224    fn test_gain_reduction_reports() {
225        let mut lim = BrickwallLimiter::new(-1.0, 0.0, 50.0, 44100, 2);
226        let hot = vec![2.0, 2.0];
227        let mut out = vec![0.0; 2];
228        lim.process_block(&hot, &mut out, 1);
229        assert!(lim.gain_reduction_db() < 0.0, "Should report gain reduction");
230    }
231
232    #[test]
233    fn test_silence_no_reduction() {
234        let mut lim = BrickwallLimiter::default_safe(44100, 2);
235        let silence = vec![0.0; 128];
236        let mut out = vec![0.0; 128];
237        lim.process_block(&silence, &mut out, 64);
238        assert!((lim.gain_reduction_db() - 0.0).abs() < 0.01);
239    }
240
241    #[test]
242    fn test_reset_clears_state() {
243        let mut lim = BrickwallLimiter::new(-1.0, 0.0, 50.0, 44100, 2);
244        let hot = vec![5.0, 5.0];
245        let mut out = vec![0.0; 2];
246        lim.process_block(&hot, &mut out, 1);
247        assert!(lim.gain < 1.0);
248        lim.reset();
249        assert!((lim.gain - 1.0).abs() < 1e-10);
250    }
251
252    #[test]
253    fn test_ceiling_db_correct() {
254        let lim = BrickwallLimiter::new(-3.0, 1.0, 50.0, 44100, 2);
255        assert!((lim.ceiling_db() - (-3.0)).abs() < 0.01);
256    }
257
258    #[test]
259    fn test_speaker_protection_extreme() {
260        // Simulate a catastrophic signal: 100x overdrive.
261        let mut lim = BrickwallLimiter::new(-1.0, 0.0, 50.0, 44100, 2);
262        let extreme: Vec<f64> = (0..256).map(|_| 100.0).collect();
263        let mut out = vec![0.0; 256];
264        lim.process_block(&extreme, &mut out, 128);
265        let ceiling_linear = 10.0_f64.powf(-1.0 / 20.0);
266        for &s in &out {
267            assert!(
268                s.abs() <= ceiling_linear + 0.01,
269                "100x overdrive: {} exceeds ceiling", s
270            );
271        }
272    }
273}