Merge branch 'perf/true-peak-early-out'

This commit is contained in:
Mikkeli Matlock
2026-08-31 21:55:41 +09:00
3 changed files with 167 additions and 19 deletions
+26 -2
View File
@@ -9,7 +9,7 @@
//! 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;
use super::oversampler::{self, Oversampler};
const MAX_CHANNELS: usize = 2;
/// Fixed look-ahead — also this stage's constant latency contribution.
@@ -121,6 +121,10 @@ pub struct Limiter {
margin_gain: f32,
/// 4× interpolator for true-peak (inter-sample) detection.
oversampler: Oversampler,
/// Sliding max of the sample peak over the interpolator's FIR support, which bounds what
/// its next evaluation can possibly return. Lets `process` skip the evaluation entirely
/// when that bound is already under the ceiling.
support_max: SlidingMax,
}
impl Default for Limiter {
@@ -135,6 +139,7 @@ impl Default for Limiter {
attack_coef: 0.0,
margin_gain: 1.0,
oversampler: Oversampler::new(),
support_max: SlidingMax::new(),
}
}
}
@@ -153,6 +158,7 @@ impl Limiter {
let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; channels];
self.sliding_max.prepare(self.capacity);
self.support_max.prepare(oversampler::SUPPORT_LEN);
self.oversampler.prepare(channels);
self.reset();
}
@@ -162,6 +168,7 @@ impl Limiter {
ch.iter_mut().for_each(|s| *s = 0.0);
}
self.sliding_max.reset();
self.support_max.reset();
self.oversampler.reset();
self.write_pos = 0;
self.gain = 1.0;
@@ -190,11 +197,28 @@ impl Limiter {
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 * self.margin_gain;
// The polyphase evaluation is ~PHASES*TAPS multiply-adds per channel and runs on every
// sample, which makes it the most expensive single thing in the chain. It is also
// skippable: the interpolator is linear, so over its FIR support
// |true peak| <= peak_gain * max|history|
// and when that bound sits under the ceiling the true peak provably cannot reach it.
// The limiter's target would be 1.0 for this sample either way, so feeding the sample
// peak instead cannot change the outcome - `sliding_max` still sees any genuinely loud
// neighbour through that neighbour's own evaluation.
//
// The history must still be fed on every sample, or later evaluations read holes.
self.oversampler.push(&input[..n]);
let support_max = self.support_max.push(sample_peak);
let peak = if support_max * self.oversampler.peak_gain() <= target_ceiling {
sample_peak
} else {
sample_peak.max(self.oversampler.evaluate(n))
};
// Write into the ring.
for ch in 0..n {
self.delay[ch][self.write_pos] = input[ch];
+85 -6
View File
@@ -17,6 +17,9 @@
const PHASES: usize = 4;
const TAPS_PER_PHASE: usize = 12;
/// Length of the FIR support, i.e. how many past input frames an evaluation reads. A caller
/// bounding the true peak needs the max over exactly this many frames.
pub const SUPPORT_LEN: usize = TAPS_PER_PHASE;
const MAX_CHANNELS: usize = 2;
/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES`
@@ -53,17 +56,32 @@ fn build_coefficients() -> [[f32; TAPS_PER_PHASE]; PHASES] {
coeffs
}
/// Largest L1 norm across phases: `max_phase sum |c|`. Bounds the interpolator's output for a
/// bounded input, which is what makes the [`Oversampler::peak_gain`] early-out sound.
fn build_peak_gain(coeffs: &[[f32; TAPS_PER_PHASE]; PHASES]) -> f32 {
coeffs
.iter()
.map(|row| row.iter().map(|c| c.abs()).sum::<f32>())
.fold(0.0f32, f32::max)
}
pub struct Oversampler {
coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
history: Vec<[f32; TAPS_PER_PHASE]>,
pos: usize,
/// Largest L1 norm across the phases. Since every interpolated sample is a dot product of
/// one phase with the history, `|interpolated| <= peak_gain * max|history|` - a hard bound,
/// which lets a caller prove the true peak cannot reach a ceiling without evaluating it.
peak_gain: f32,
}
impl Default for Oversampler {
fn default() -> Self {
let coeffs = build_coefficients();
Self {
coeffs: build_coefficients(),
peak_gain: build_peak_gain(&coeffs),
coeffs,
history: Vec::new(),
pos: 0,
}
@@ -90,19 +108,38 @@ impl Oversampler {
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
/// 4× grid (the reconstructed samples are not kept).
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 {
/// Upper bound on `|interpolated|` for an input bounded by 1.0. See [`Self::peak_gain`].
#[inline(always)]
pub fn peak_gain(&self) -> f32 {
self.peak_gain
}
/// Feed one input frame into the history. Cheap - a write per channel plus a counter.
/// Must be called for EVERY frame even when [`Self::evaluate`] is skipped, or the FIR
/// history develops holes and later evaluations read stale samples.
#[inline]
pub fn push(&mut self, input: &[f32]) {
let n = input.len().min(self.history.len());
let slot = self.pos % TAPS_PER_PHASE;
for ch in 0..n {
self.history[ch][slot] = input[ch];
}
self.pos += 1;
}
// The tap index only depends on `pos` and `k`, not on `phase` or `ch` — hoist it out of
/// The actual polyphase evaluation: `PHASES * TAPS_PER_PHASE` multiply-adds per channel.
/// This is the expensive half, and the only half a caller can skip.
#[inline]
pub fn evaluate(&self, channels: usize) -> f32 {
let n = channels.min(self.history.len());
// The tap index only depends on `pos` and `k`, not on `phase` or `ch` - hoist it out of
// those loops instead of recomputing (and re-dividing) it PHASES*MAX_CHANNELS times over.
// `pos` already advanced past the newest sample in push(), hence the -1.
let base = self.pos + TAPS_PER_PHASE - 1;
let mut idx = [0usize; TAPS_PER_PHASE];
for (k, slot) in idx.iter_mut().enumerate() {
// k = 0 is the newest sample, increasing k goes back in time.
*slot = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE;
*slot = (base - k) % TAPS_PER_PHASE;
}
let mut peak = 0.0f32;
@@ -116,10 +153,18 @@ impl Oversampler {
peak = peak.max(acc.abs());
}
}
self.pos += 1;
peak
}
/// Push a frame and evaluate it unconditionally. The real path splits these so the
/// evaluation can be skipped (see `Limiter::process`); this is kept for tests that want
/// the true peak of every frame regardless of any ceiling.
#[cfg(test)]
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 {
let n = input.len().min(self.history.len());
self.push(input);
self.evaluate(n)
}
}
#[cfg(test)]
@@ -153,4 +198,38 @@ mod tests {
}
assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}");
}
/// The early-out in `Limiter::process` is only sound if `evaluate()` can never exceed
/// `peak_gain * max|history|`. Assert that directly against adversarial input - full-scale
/// square waves and alternating impulses maximise the interpolator's overshoot.
#[test]
fn evaluate_never_exceeds_peak_gain_bound() {
let mut os = Oversampler::default();
os.prepare(2);
let mut rng: u32 = 0xC0FF_EE01;
let mut worst_ratio = 0.0f32;
for i in 0..20_000 {
rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
// Mix of adversarial shapes: alternating full scale, impulses, and noise.
let x = match i % 3 {
0 => if (i / 3) % 2 == 0 { 1.0 } else { -1.0 },
1 => if i % 97 == 0 { 1.0 } else { 0.0 },
_ => (rng >> 8) as f32 / (1 << 23) as f32 - 1.0,
};
let frame = [x, -x];
os.push(&frame);
let got = os.evaluate(2);
// Bound uses the max over the FIR support, which for these inputs is at most 1.0.
let bound = os.peak_gain() * 1.0;
assert!(
got <= bound + 1e-4,
"evaluate() = {got} exceeded peak_gain bound {bound} at i={i}"
);
if bound > 0.0 {
worst_ratio = worst_ratio.max(got / bound);
}
}
// Sanity: the bound must be reachable enough to be useful, not vacuously huge.
assert!(worst_ratio > 0.3, "bound looks far too loose (worst ratio {worst_ratio})");
}
}
+53 -8
View File
@@ -216,6 +216,43 @@ impl Plugin for Codename206 {
let mut gr = [0.0f32; meters::NUM_CHANNELS];
let mut lim_gr = 0.0f32;
// Params only change on block boundaries here (we do not split blocks on events), so a
// smoother that is idle now stays idle for this whole block. When every smoother feeding
// a channel is idle - the overwhelming common case, i.e. nobody is touching a knob -
// its per-sample values are constants, so lift them out of the sample loop entirely.
// That removes 5 Smoother::next() calls and a db_to_gain per channel per sample.
let chan_static = |p: &params::CompressorParams| {
!p.pre_gain_db.smoothed.is_smoothing()
&& !p.makeup_db.smoothed.is_smoothing()
&& !p.mix.smoothed.is_smoothing()
&& !p.low_slope.smoothed.is_smoothing()
&& !p.low_curve.smoothed.is_smoothing()
};
let band_static = [
chan_static(&self.params.low),
chan_static(&self.params.mid),
chan_static(&self.params.high),
];
let all_static = chan_static(&self.params.all);
let mut band_pre = [1.0f32; 3];
for b in 0..3 {
if band_static[b] {
band_pre[b] = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next());
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
band_set[b].mix = band_params[b].mix.smoothed.next();
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
}
}
let mut all_pre_static = 1.0f32;
if all_static {
all_pre_static = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
all_set.mix = self.params.all.mix.smoothed.next();
all_set.low_slope = self.params.all.low_slope.smoothed.next();
all_set.low_curve = self.params.all.low_curve.smoothed.next();
}
let mut in_frame = [0.0f32; 2];
let mut band_in = [[0.0f32; 2]; 3];
let mut band_out = [[0.0f32; 2]; 3];
@@ -243,14 +280,18 @@ impl Plugin for Codename206 {
for b in 0..3 {
// `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range
// never reaches, in exchange for a cheaper exp() instead of a general powf().
let pre = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next());
for ch in 0..n {
band_in[b][ch] *= pre;
}
let pre = if band_static[b] {
band_pre[b]
} else {
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
band_set[b].mix = band_params[b].mix.smoothed.next();
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next())
};
for ch in 0..n {
band_in[b][ch] *= pre;
}
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
for ch in 0..n {
summed[ch] += band_out[b][ch];
@@ -274,14 +315,18 @@ impl Plugin for Codename206 {
}
// 'All' aggregate channel over the summed bands (driven before its compressor).
let all_pre = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
for ch in 0..n {
summed[ch] *= all_pre;
}
let all_pre = if all_static {
all_pre_static
} else {
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
all_set.mix = self.params.all.mix.smoothed.next();
all_set.low_slope = self.params.all.low_slope.smoothed.next();
all_set.low_curve = self.params.all.low_curve.smoothed.next();
util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next())
};
for ch in 0..n {
summed[ch] *= all_pre;
}
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
// Output brickwall limiter.