Skip to main content

audio_engine_core/processor/loudness/
limiter.rs

1//! True-peak limiter with 10ms look-ahead and exponential release.
2//!
3//! Ring-buffered, allocation-free in the audio callback path.
4
5use crate::processor::dsp::{db_to_linear, linear_to_db};
6
7#[derive(Debug, Clone)]
8struct MonotonicMaxQueue {
9    indices: Box<[u64]>,
10    peaks: Box<[f64]>,
11    head: usize,
12    tail: usize,
13    len: usize,
14}
15
16impl MonotonicMaxQueue {
17    fn new(capacity: usize) -> Self {
18        let capacity = capacity.max(1);
19        Self {
20            indices: vec![0; capacity].into_boxed_slice(),
21            peaks: vec![0.0; capacity].into_boxed_slice(),
22            head: 0,
23            tail: 0,
24            len: 0,
25        }
26    }
27
28    #[inline]
29    fn clear(&mut self) {
30        self.head = 0;
31        self.tail = 0;
32        self.len = 0;
33    }
34
35    #[inline]
36    fn current_peak(&self) -> f64 {
37        if self.len == 0 {
38            0.0
39        } else {
40            self.peaks[self.head]
41        }
42    }
43
44    #[inline]
45    fn push(&mut self, frame_index: u64, peak: f64) {
46        while self.len > 0 && self.back_peak() <= peak {
47            self.pop_back();
48        }
49
50        if self.len == self.indices.len() {
51            self.pop_front();
52        }
53
54        self.indices[self.tail] = frame_index;
55        self.peaks[self.tail] = peak;
56        self.tail = (self.tail + 1) % self.indices.len();
57        self.len += 1;
58    }
59
60    #[inline]
61    fn expire_through(&mut self, max_expired_index: u64) {
62        while self.len > 0 && self.indices[self.head] <= max_expired_index {
63            self.pop_front();
64        }
65    }
66
67    #[inline]
68    fn back_peak(&self) -> f64 {
69        let index = (self.tail + self.indices.len() - 1) % self.indices.len();
70        self.peaks[index]
71    }
72
73    #[inline]
74    fn pop_front(&mut self) {
75        self.head = (self.head + 1) % self.indices.len();
76        self.len -= 1;
77    }
78
79    #[inline]
80    fn pop_back(&mut self) {
81        self.tail = (self.tail + self.indices.len() - 1) % self.indices.len();
82        self.len -= 1;
83    }
84}
85
86/// True Peak Limiter with look-ahead and proper release behavior.
87///
88/// # Design
89///
90/// - 10ms look-ahead buffer for peak detection
91/// - -1.0 dBTP threshold (EBU R128 recommendation)
92/// - Proper release coefficient using exponential smoothing
93/// - Fixed ring buffer avoids heap allocation in audio callback
94pub struct PeakLimiter {
95    /// Linear threshold (e.g., 0.8913 for -1 dB)
96    threshold: f64,
97    /// Look-ahead buffer size in frames
98    lookahead_frames: usize,
99    /// Fixed-size ring buffer (frames * channels)
100    delay_buffer: Box<[f64]>,
101    /// Sliding maximum of per-frame peaks in the delay buffer
102    peak_queue: MonotonicMaxQueue,
103    /// Monotonic input frame index used by `peak_queue`
104    global_frame: u64,
105    /// Current write position in the ring buffer
106    write_pos: usize,
107    /// Current gain reduction (linear, < 1.0 when limiting)
108    gain_reduction: f64,
109    /// Release coefficient per sample (< 1.0, for multiplication)
110    release_coeff: f64,
111    /// Number of channels
112    channels: usize,
113    /// Sample rate (needed for in-place release_ms updates)
114    sample_rate: f64,
115}
116
117impl PeakLimiter {
118    /// Create a new True Peak Limiter
119    ///
120    /// # Arguments
121    /// * `channels` - Number of audio channels
122    /// * `sample_rate` - Sample rate in Hz
123    /// * `threshold_db` - Threshold in dBTP (default: -1.0)
124    /// * `lookahead_ms` - Look-ahead time in ms (default: 10.0)
125    /// * `release_ms` - Release time in ms (default: 100.0)
126    pub fn new(
127        channels: usize,
128        sample_rate: u32,
129        threshold_db: f64,
130        lookahead_ms: f64,
131        release_ms: f64,
132    ) -> Self {
133        let threshold = db_to_linear(threshold_db);
134        let lookahead_frames = ((lookahead_ms / 1000.0) * sample_rate as f64).ceil() as usize;
135        let lookahead_frames = lookahead_frames.max(1);
136
137        // Release coefficient: exp(-1 / tau) where tau = release_samples
138        // This gives us a coefficient < 1 for multiplication
139        let release_samples = (release_ms / 1000.0) * sample_rate as f64;
140        let release_coeff = (-1.0 / release_samples).exp();
141
142        // Pre-allocate fixed-size buffer
143        let buffer_size = lookahead_frames * channels;
144        let delay_buffer = vec![0.0; buffer_size].into_boxed_slice();
145
146        Self {
147            threshold,
148            lookahead_frames,
149            delay_buffer,
150            peak_queue: MonotonicMaxQueue::new(lookahead_frames),
151            global_frame: 0,
152            write_pos: 0,
153            gain_reduction: 1.0,
154            release_coeff,
155            channels,
156            sample_rate: sample_rate as f64,
157        }
158    }
159
160    /// Process interleaved samples in-place
161    ///
162    /// This function is real-time safe:
163    /// - No heap allocations
164    /// - No system calls
165    /// - O(n) complexity where n = number of samples
166    pub fn process(&mut self, samples: &mut [f64]) {
167        let total_samples = samples.len();
168        let frames = total_samples / self.channels;
169        if frames == 0 {
170            return;
171        }
172
173        for frame in 0..frames {
174            // Step 1: Read peak across all channels in the look-ahead window.
175            // Query before writing the current input frame to preserve the
176            // existing delay-buffer semantics exactly.
177            let peak = self.peak_queue.current_peak();
178
179            // Step 2: Calculate required gain reduction (instant attack)
180            let target_gain = if peak > self.threshold {
181                self.threshold / peak
182            } else {
183                1.0
184            };
185
186            // Step 3: Apply release smoothing (gain_reduction can only decrease or recover)
187            // Instant attack: take minimum of current and target
188            // Smooth release: recover towards 1.0 using multiplication
189            if target_gain < self.gain_reduction {
190                // Attack: instant
191                self.gain_reduction = target_gain;
192            } else {
193                // Release: smooth recovery
194                self.gain_reduction =
195                    self.gain_reduction + (1.0 - self.gain_reduction) * (1.0 - self.release_coeff);
196                // Ensure we don't exceed target
197                self.gain_reduction = self.gain_reduction.min(target_gain);
198            }
199
200            // Step 4: Read from delay buffer, write new samples, apply gain
201            let mut frame_peak = 0.0_f64;
202            for ch in 0..self.channels {
203                let input_idx = frame * self.channels + ch;
204                let buffer_idx = self.write_pos * self.channels + ch;
205                let input = samples[input_idx];
206                frame_peak = frame_peak.max(input.abs());
207
208                // Get delayed sample
209                let delayed = self.delay_buffer[buffer_idx];
210
211                // Store new sample in buffer
212                self.delay_buffer[buffer_idx] = input;
213
214                // Output delayed sample with gain reduction
215                samples[input_idx] = delayed * self.gain_reduction;
216            }
217
218            self.push_frame_peak(frame_peak);
219
220            // Advance write position
221            self.write_pos = (self.write_pos + 1) % self.lookahead_frames;
222        }
223    }
224
225    #[inline]
226    fn push_frame_peak(&mut self, frame_peak: f64) {
227        if self.global_frame >= self.lookahead_frames as u64 {
228            self.peak_queue
229                .expire_through(self.global_frame - self.lookahead_frames as u64);
230        }
231        self.peak_queue.push(self.global_frame, frame_peak);
232        self.global_frame = self.global_frame.wrapping_add(1);
233    }
234
235    /// Set threshold in dB
236    pub fn set_threshold_db(&mut self, threshold_db: f64) {
237        self.threshold = db_to_linear(threshold_db);
238    }
239
240    /// Update threshold in-place without reallocating lookahead buffer.
241    pub fn set_threshold(&mut self, threshold_db: f64) {
242        self.threshold = db_to_linear(threshold_db);
243    }
244
245    /// Update release time in-place without reallocating lookahead buffer.
246    pub fn set_release_ms(&mut self, release_ms: f64) {
247        let release_samples = (release_ms / 1000.0) * self.sample_rate;
248        self.release_coeff = (-1.0 / release_samples.max(1.0)).exp();
249    }
250
251    /// Check if limiter is conceptually enabled (always true for PeakLimiter)
252    pub fn is_enabled(&self) -> bool {
253        true
254    }
255
256    /// Get current gain reduction in dB (for metering)
257    pub fn gain_reduction_db(&self) -> f64 {
258        linear_to_db(self.gain_reduction)
259    }
260
261    /// Reset limiter state
262    pub fn reset(&mut self) {
263        for sample in self.delay_buffer.iter_mut() {
264            *sample = 0.0;
265        }
266        self.peak_queue.clear();
267        self.global_frame = 0;
268        self.write_pos = 0;
269        self.gain_reduction = 1.0;
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276
277    struct LegacyPeakLimiter {
278        threshold: f64,
279        lookahead_frames: usize,
280        delay_buffer: Box<[f64]>,
281        write_pos: usize,
282        gain_reduction: f64,
283        release_coeff: f64,
284        channels: usize,
285    }
286
287    impl LegacyPeakLimiter {
288        fn new(
289            channels: usize,
290            sample_rate: u32,
291            threshold_db: f64,
292            lookahead_ms: f64,
293            release_ms: f64,
294        ) -> Self {
295            let threshold = db_to_linear(threshold_db);
296            let lookahead_frames = ((lookahead_ms / 1000.0) * sample_rate as f64).ceil() as usize;
297            let lookahead_frames = lookahead_frames.max(1);
298            let release_samples = (release_ms / 1000.0) * sample_rate as f64;
299            let release_coeff = (-1.0 / release_samples).exp();
300
301            Self {
302                threshold,
303                lookahead_frames,
304                delay_buffer: vec![0.0; lookahead_frames * channels].into_boxed_slice(),
305                write_pos: 0,
306                gain_reduction: 1.0,
307                release_coeff,
308                channels,
309            }
310        }
311
312        fn process(&mut self, samples: &mut [f64]) {
313            let frames = samples.len() / self.channels;
314            if frames == 0 {
315                return;
316            }
317
318            for frame in 0..frames {
319                let peak = self.scan_lookahead_peak();
320                let target_gain = if peak > self.threshold {
321                    self.threshold / peak
322                } else {
323                    1.0
324                };
325
326                if target_gain < self.gain_reduction {
327                    self.gain_reduction = target_gain;
328                } else {
329                    self.gain_reduction = self.gain_reduction
330                        + (1.0 - self.gain_reduction) * (1.0 - self.release_coeff);
331                    self.gain_reduction = self.gain_reduction.min(target_gain);
332                }
333
334                for ch in 0..self.channels {
335                    let input_idx = frame * self.channels + ch;
336                    let buffer_idx = self.write_pos * self.channels + ch;
337                    let delayed = self.delay_buffer[buffer_idx];
338                    self.delay_buffer[buffer_idx] = samples[input_idx];
339                    samples[input_idx] = delayed * self.gain_reduction;
340                }
341
342                self.write_pos = (self.write_pos + 1) % self.lookahead_frames;
343            }
344        }
345
346        fn scan_lookahead_peak(&self) -> f64 {
347            let mut peak = 0.0_f64;
348            for frame in 0..self.lookahead_frames {
349                let pos = (self.write_pos + frame) % self.lookahead_frames;
350                for ch in 0..self.channels {
351                    let idx = pos * self.channels + ch;
352                    peak = peak.max(self.delay_buffer[idx].abs());
353                }
354            }
355            peak
356        }
357    }
358
359    fn assert_samples_eq(left: &[f64], right: &[f64]) {
360        assert_eq!(left.len(), right.len());
361        for (index, (a, b)) in left.iter().zip(right.iter()).enumerate() {
362            assert_eq!(
363                a.to_bits(),
364                b.to_bits(),
365                "sample {index}: left={a}, right={b}"
366            );
367        }
368    }
369
370    fn deterministic_transient_corpus(frames: usize, channels: usize) -> Vec<f64> {
371        let mut samples = Vec::with_capacity(frames * channels);
372        for frame in 0..frames {
373            let base =
374                ((frame as f64 * 0.037).sin() * 0.35) + ((frame as f64 * 0.011).cos() * 0.08);
375            for ch in 0..channels {
376                let mut sample = base * (1.0 - ch as f64 * 0.15);
377                if matches!(frame, 32 | 257 | 513 | 1024) {
378                    sample = if ch == 0 { 1.8 } else { -1.35 };
379                }
380                samples.push(sample);
381            }
382        }
383        samples
384    }
385
386    #[test]
387    fn monotonic_queue_matches_legacy_scan_for_transient_corpus() {
388        let mut limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
389        let mut legacy = LegacyPeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
390        let mut samples = deterministic_transient_corpus(2_000, 2);
391        let mut expected = samples.clone();
392
393        limiter.process(&mut samples);
394        legacy.process(&mut expected);
395
396        assert_samples_eq(&samples, &expected);
397    }
398
399    #[test]
400    fn monotonic_queue_preserves_cross_buffer_continuity() {
401        let source = deterministic_transient_corpus(6_400, 2);
402        let mut one_shot = source.clone();
403        let mut chunked = source.clone();
404
405        let mut one_shot_limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
406        let mut chunked_limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
407
408        one_shot_limiter.process(&mut one_shot);
409        for chunk in chunked.chunks_mut(64 * 2) {
410            chunked_limiter.process(chunk);
411        }
412
413        assert_samples_eq(&chunked, &one_shot);
414    }
415
416    #[test]
417    fn monotonic_queue_handles_sustained_pre_clipping() {
418        let mut limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
419        let mut samples = vec![1.2; 2_000 * 2];
420
421        limiter.process(&mut samples);
422
423        let expected_gain = db_to_linear(-1.0) / 1.2;
424        assert!((limiter.gain_reduction - expected_gain).abs() < 1e-12);
425        assert!(samples
426            .iter()
427            .all(|sample| sample.abs() <= db_to_linear(-1.0) + 1e-12));
428    }
429
430    #[test]
431    fn monotonic_queue_resets_state() {
432        let mut limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
433        let mut samples = deterministic_transient_corpus(1_000, 2);
434
435        limiter.process(&mut samples);
436        assert!(limiter.peak_queue.current_peak() > 0.0);
437
438        limiter.reset();
439
440        assert_eq!(limiter.peak_queue.current_peak(), 0.0);
441        assert_eq!(limiter.global_frame, 0);
442        assert_eq!(limiter.write_pos, 0);
443        assert_eq!(limiter.gain_reduction, 1.0);
444    }
445
446    #[test]
447    fn lookahead_one_frame_matches_legacy_scan() {
448        let mut limiter = PeakLimiter::new(2, 1_000, -1.0, 1.0, 10.0);
449        let mut legacy = LegacyPeakLimiter::new(2, 1_000, -1.0, 1.0, 10.0);
450        let mut samples = deterministic_transient_corpus(128, 2);
451        let mut expected = samples.clone();
452
453        limiter.process(&mut samples);
454        legacy.process(&mut expected);
455
456        assert_samples_eq(&samples, &expected);
457    }
458
459    #[test]
460    fn non_finite_samples_do_not_poison_queue_peak() {
461        let mut limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
462        let mut samples = vec![0.2; 64 * 2];
463        samples[4] = f64::NAN;
464        samples[9] = f64::INFINITY;
465
466        limiter.process(&mut samples);
467
468        assert!(limiter.peak_queue.current_peak().is_infinite());
469
470        let mut finite_samples = vec![0.25; 600 * 2];
471        limiter.process(&mut finite_samples);
472
473        assert!(limiter.peak_queue.current_peak().is_finite());
474        assert_eq!(limiter.peak_queue.current_peak(), 0.25);
475    }
476
477    #[test]
478    fn process_is_steady_state_no_alloc() {
479        let mut limiter = PeakLimiter::new(2, 48_000, -1.0, 10.0, 100.0);
480        let mut samples = deterministic_transient_corpus(64, 2);
481
482        assert_no_alloc::assert_no_alloc(|| {
483            for _ in 0..1_000 {
484                limiter.process(&mut samples);
485            }
486        });
487    }
488}