Files
codename-206/src/dsp/limiter.rs
T
Mikkeli Matlock a420d5dd39 feat: per-channel meters, ceiling lamp, and pre-gain drive
Stage 6 begins. Add lock-free Meters (atomics shared audio->GUI) with a
peak-with-decay ballistic, published once per block and gated on the editor
being open. Editor draws a per-channel level + gain-reduction meter panel and
a ceiling lamp fed by the output limiter. Compressor/Limiter expose
gain_reduction_db() for this.

Also add a smoothed per-channel pre-gain applied before each compressor (and
before the All compressor), driving the signal into compression and on into
the limiter for a compressed semi-distortion. Pairs with makeup for full
per-channel input/output gain-staging. Compressor DSP untouched; 16 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 23:13:14 +09:00

226 lines
7.9 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<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,
/// 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);
}
}