202 lines
6.8 KiB
Rust
202 lines
6.8 KiB
Rust
|
|
//! 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.
|
||
|
|
|
||
|
|
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;
|
||
|
|
|
||
|
|
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<Vec<f32>>,
|
||
|
|
/// Linked `|x|` history, same length as the delay ring (for the sliding maximum).
|
||
|
|
peaks: Vec<f32>,
|
||
|
|
capacity: usize,
|
||
|
|
write_pos: usize,
|
||
|
|
fixed_delay: usize,
|
||
|
|
/// Current smoothed gain (<= 1).
|
||
|
|
gain: f32,
|
||
|
|
attack_coef: f32,
|
||
|
|
}
|
||
|
|
|
||
|
|
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,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
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.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.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
|
||
|
|
}
|
||
|
|
|
||
|
|
/// 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());
|
||
|
|
|
||
|
|
// Linked peak of the current input.
|
||
|
|
let mut peak = 0.0f32;
|
||
|
|
for &x in &input[..n] {
|
||
|
|
peak = peak.max(x.abs());
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 > ceiling { 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);
|
||
|
|
}
|
||
|
|
}
|