//! Look-ahead brickwall peak limiter (base-rate). //! //! Guarantees the output never exceeds the ceiling. A short fixed look-ahead lets the gain ramp //! down *before* a peak reaches the output (click-free), driven by a **sliding maximum** over the //! look-ahead window so the reduction is fully in place in time. A final clamp at the ceiling is //! the hard guarantee against any residual from smoothing lag or float error. //! //! Detection is stereo-linked (one gain for all channels). This stage limits **sample** peaks at //! the base rate; true-peak (inter-sample) limiting via oversampling is a later addition. use super::oversampler::Oversampler; const MAX_CHANNELS: usize = 2; /// Fixed look-ahead — also this stage's constant latency contribution. const LOOKAHEAD_MS: f32 = 1.5; /// Near-instant attack; the look-ahead gives it time to act before the peak arrives. const ATTACK_MS: f32 = 0.05; /// The 4× true-peak detector can still under-read by a few tenths of a dB near Nyquist, so we /// target a hair below the ceiling to keep the actual inter-sample peak under it. const TRUE_PEAK_MARGIN_DB: f32 = 0.3; fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 { if time_ms <= 0.0 { 0.0 } else { (-1.0 / (time_ms * 0.001 * sample_rate)).exp() } } pub struct Limiter { /// Per-channel audio delay ring. delay: Vec>, /// Linked `|x|` history, same length as the delay ring (for the sliding maximum). peaks: Vec, capacity: usize, write_pos: usize, fixed_delay: usize, /// Current smoothed gain (<= 1). gain: f32, attack_coef: f32, /// 4× interpolator for true-peak (inter-sample) detection. oversampler: Oversampler, } impl Default for Limiter { fn default() -> Self { Self { delay: Vec::new(), peaks: Vec::new(), capacity: 0, write_pos: 0, fixed_delay: 0, gain: 1.0, attack_coef: 0.0, oversampler: Oversampler::new(), } } } impl Limiter { pub fn new() -> Self { Self::default() } /// Allocate buffers. Call from `initialize()` (allocation allowed). pub fn prepare(&mut self, sample_rate: f32, num_channels: usize) { self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize; self.capacity = self.fixed_delay + 1; self.attack_coef = time_to_coef(ATTACK_MS, sample_rate); let channels = num_channels.clamp(1, MAX_CHANNELS); self.delay = vec![vec![0.0; self.capacity]; channels]; self.peaks = vec![0.0; self.capacity]; self.oversampler.prepare(channels); self.reset(); } pub fn reset(&mut self) { for ch in &mut self.delay { ch.iter_mut().for_each(|s| *s = 0.0); } self.peaks.iter_mut().for_each(|p| *p = 0.0); self.oversampler.reset(); self.write_pos = 0; self.gain = 1.0; } /// Constant reported latency (the fixed look-ahead delay). pub fn latency(&self) -> u32 { self.fixed_delay as u32 } /// Current limiter gain reduction in dB (>= 0). `gain` is linear (<= 1); expressed here as a /// positive dB amount for the ceiling lamp / metering. pub fn gain_reduction_db(&self) -> f32 { -20.0 * self.gain.max(1e-9).log10() } /// Limit one frame in place: `input[ch]` -> `output[ch]`. /// /// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a /// release time. Output is guaranteed `|y| <= ceiling`. pub fn process(&mut self, input: &[f32], output: &mut [f32], ceiling: f32, release_coef: f32) { let n = input.len().min(self.delay.len()); // Detector = max of the sample peak and the 4× true-peak (inter-sample) estimate. let mut sample_peak = 0.0f32; for &x in &input[..n] { sample_peak = sample_peak.max(x.abs()); } let peak = sample_peak.max(self.oversampler.max_true_peak(&input[..n])); // Target a hair below the ceiling so the (slightly under-read) true peak stays under it. let target_ceiling = ceiling * 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0); // Write into the ring. for ch in 0..n { self.delay[ch][self.write_pos] = input[ch]; } self.peaks[self.write_pos] = peak; // Sliding maximum over the look-ahead window (= the whole ring). Because the oldest sample // (the one we output now) is in this window, `ceiling / window_max` applied to it can never // exceed the ceiling, and the gain has pre-dropped for any louder sample still to come. let mut window_max = 0.0f32; for &p in &self.peaks { window_max = window_max.max(p); } let target = if window_max > target_ceiling { target_ceiling / window_max } else { 1.0 }; // Decoupled smoothing: fast attack down, slow release up. self.gain = if target < self.gain { self.attack_coef * self.gain + (1.0 - self.attack_coef) * target } else { release_coef * self.gain + (1.0 - release_coef) * target }; // Output the delayed sample, clamped to the ceiling as the hard guarantee. let out_pos = (self.write_pos + 1) % self.capacity; // oldest sample = fixed_delay ago for ch in 0..n { output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling); } self.write_pos = (self.write_pos + 1) % self.capacity; } } #[cfg(test)] mod tests { use super::*; use std::f32::consts::TAU; const SR: f32 = 48_000.0; fn release() -> f32 { time_to_coef(50.0, SR) } #[test] fn never_exceeds_ceiling_on_spikes() { // Mostly silence with occasional large spikes — output must never exceed the ceiling. let mut lim = Limiter::new(); lim.prepare(SR, 1); let ceiling = 1.0; let mut out = [0.0f32]; for i in 0..10_000 { let x = if i % 500 == 0 { 5.0 } else { 0.01 }; lim.process(&[x], &mut out, ceiling, release()); assert!(out[0].abs() <= ceiling + 1e-6, "overshoot at {i}: {}", out[0]); } } #[test] fn limits_loud_sine_to_ceiling() { // A sine well above the ceiling settles to ~ceiling, not silenced. let mut lim = Limiter::new(); lim.prepare(SR, 1); let ceiling = 1.0; let (amp, freq) = (2.0f32, 1_000.0); let mut out = [0.0f32]; let mut max_tail = 0.0f32; let total = SR as usize; for i in 0..total { let x = amp * (TAU * freq * i as f32 / SR).sin(); lim.process(&[x], &mut out, ceiling, release()); if i >= total - 4_800 { max_tail = max_tail.max(out[0].abs()); } } assert!(max_tail <= ceiling + 1e-6, "exceeded ceiling: {max_tail}"); assert!(max_tail > 0.9, "over-attenuated: {max_tail}"); } #[test] fn transparent_below_ceiling() { // A signal under the ceiling passes through unattenuated (just delayed). let mut lim = Limiter::new(); lim.prepare(SR, 1); let ceiling = 1.0; let (amp, freq) = (0.5f32, 1_000.0); let mut out = [0.0f32]; let mut max_tail = 0.0f32; let total = SR as usize / 2; for i in 0..total { let x = amp * (TAU * freq * i as f32 / SR).sin(); lim.process(&[x], &mut out, ceiling, release()); if i >= total - 4_800 { max_tail = max_tail.max(out[0].abs()); } } assert!((max_tail - amp).abs() < 1e-3, "not transparent: {max_tail}"); } #[test] fn latency_is_the_lookahead() { let mut lim = Limiter::new(); lim.prepare(SR, 1); let expected = (LOOKAHEAD_MS * 0.001 * SR).ceil() as u32; assert_eq!(lim.latency(), expected); assert!(expected > 0); } }