Merge branch 'perf/true-peak-early-out'
This commit is contained in:
+26
-2
@@ -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];
|
||||
|
||||
+86
-7
@@ -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})");
|
||||
}
|
||||
}
|
||||
+55
-10
@@ -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: ¶ms::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());
|
||||
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;
|
||||
}
|
||||
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();
|
||||
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());
|
||||
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;
|
||||
}
|
||||
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();
|
||||
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||
|
||||
// Output brickwall limiter.
|
||||
|
||||
Reference in New Issue
Block a user