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
|
//! 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.
|
//! 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;
|
const MAX_CHANNELS: usize = 2;
|
||||||
/// Fixed look-ahead — also this stage's constant latency contribution.
|
/// Fixed look-ahead — also this stage's constant latency contribution.
|
||||||
@@ -121,6 +121,10 @@ pub struct Limiter {
|
|||||||
margin_gain: f32,
|
margin_gain: f32,
|
||||||
/// 4× interpolator for true-peak (inter-sample) detection.
|
/// 4× interpolator for true-peak (inter-sample) detection.
|
||||||
oversampler: Oversampler,
|
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 {
|
impl Default for Limiter {
|
||||||
@@ -135,6 +139,7 @@ impl Default for Limiter {
|
|||||||
attack_coef: 0.0,
|
attack_coef: 0.0,
|
||||||
margin_gain: 1.0,
|
margin_gain: 1.0,
|
||||||
oversampler: Oversampler::new(),
|
oversampler: Oversampler::new(),
|
||||||
|
support_max: SlidingMax::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,6 +158,7 @@ impl Limiter {
|
|||||||
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
let channels = num_channels.clamp(1, MAX_CHANNELS);
|
||||||
self.delay = vec![vec![0.0; self.capacity]; channels];
|
self.delay = vec![vec![0.0; self.capacity]; channels];
|
||||||
self.sliding_max.prepare(self.capacity);
|
self.sliding_max.prepare(self.capacity);
|
||||||
|
self.support_max.prepare(oversampler::SUPPORT_LEN);
|
||||||
self.oversampler.prepare(channels);
|
self.oversampler.prepare(channels);
|
||||||
self.reset();
|
self.reset();
|
||||||
}
|
}
|
||||||
@@ -162,6 +168,7 @@ impl Limiter {
|
|||||||
ch.iter_mut().for_each(|s| *s = 0.0);
|
ch.iter_mut().for_each(|s| *s = 0.0);
|
||||||
}
|
}
|
||||||
self.sliding_max.reset();
|
self.sliding_max.reset();
|
||||||
|
self.support_max.reset();
|
||||||
self.oversampler.reset();
|
self.oversampler.reset();
|
||||||
self.write_pos = 0;
|
self.write_pos = 0;
|
||||||
self.gain = 1.0;
|
self.gain = 1.0;
|
||||||
@@ -190,11 +197,28 @@ impl Limiter {
|
|||||||
for &x in &input[..n] {
|
for &x in &input[..n] {
|
||||||
sample_peak = sample_peak.max(x.abs());
|
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.
|
// Target a hair below the ceiling so the (slightly under-read) true peak stays under it.
|
||||||
let target_ceiling = ceiling * self.margin_gain;
|
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.
|
// Write into the ring.
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
self.delay[ch][self.write_pos] = input[ch];
|
self.delay[ch][self.write_pos] = input[ch];
|
||||||
|
|||||||
+86
-7
@@ -17,6 +17,9 @@
|
|||||||
|
|
||||||
const PHASES: usize = 4;
|
const PHASES: usize = 4;
|
||||||
const TAPS_PER_PHASE: usize = 12;
|
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;
|
const MAX_CHANNELS: usize = 2;
|
||||||
|
|
||||||
/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES`
|
/// 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
|
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 {
|
pub struct Oversampler {
|
||||||
coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
|
coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
|
||||||
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
|
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
|
||||||
history: Vec<[f32; TAPS_PER_PHASE]>,
|
history: Vec<[f32; TAPS_PER_PHASE]>,
|
||||||
pos: usize,
|
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 {
|
impl Default for Oversampler {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
|
let coeffs = build_coefficients();
|
||||||
Self {
|
Self {
|
||||||
coeffs: build_coefficients(),
|
peak_gain: build_peak_gain(&coeffs),
|
||||||
|
coeffs,
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
pos: 0,
|
pos: 0,
|
||||||
}
|
}
|
||||||
@@ -90,19 +108,38 @@ impl Oversampler {
|
|||||||
|
|
||||||
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
|
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
|
||||||
/// 4× grid (the reconstructed samples are not kept).
|
/// 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 n = input.len().min(self.history.len());
|
||||||
let slot = self.pos % TAPS_PER_PHASE;
|
let slot = self.pos % TAPS_PER_PHASE;
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
self.history[ch][slot] = input[ch];
|
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.
|
// 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];
|
let mut idx = [0usize; TAPS_PER_PHASE];
|
||||||
for (k, slot) in idx.iter_mut().enumerate() {
|
for (k, slot) in idx.iter_mut().enumerate() {
|
||||||
// k = 0 is the newest sample, increasing k goes back in time.
|
// 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;
|
let mut peak = 0.0f32;
|
||||||
@@ -116,10 +153,18 @@ impl Oversampler {
|
|||||||
peak = peak.max(acc.abs());
|
peak = peak.max(acc.abs());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.pos += 1;
|
|
||||||
peak
|
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)]
|
#[cfg(test)]
|
||||||
@@ -153,4 +198,38 @@ mod tests {
|
|||||||
}
|
}
|
||||||
assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}");
|
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 gr = [0.0f32; meters::NUM_CHANNELS];
|
||||||
let mut lim_gr = 0.0f32;
|
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 in_frame = [0.0f32; 2];
|
||||||
let mut band_in = [[0.0f32; 2]; 3];
|
let mut band_in = [[0.0f32; 2]; 3];
|
||||||
let mut band_out = [[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 {
|
for b in 0..3 {
|
||||||
// `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range
|
// `_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().
|
// 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 {
|
for ch in 0..n {
|
||||||
band_in[b][ch] *= pre;
|
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]);
|
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
summed[ch] += band_out[b][ch];
|
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).
|
// '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 {
|
for ch in 0..n {
|
||||||
summed[ch] *= all_pre;
|
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);
|
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||||
|
|
||||||
// Output brickwall limiter.
|
// Output brickwall limiter.
|
||||||
|
|||||||
Reference in New Issue
Block a user