diff --git a/src/dsp/limiter.rs b/src/dsp/limiter.rs index 1d532e2..14c1a15 100644 --- a/src/dsp/limiter.rs +++ b/src/dsp/limiter.rs @@ -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]; diff --git a/src/dsp/oversampler.rs b/src/dsp/oversampler.rs index d243f16..23b55ca 100644 --- a/src/dsp/oversampler.rs +++ b/src/dsp/oversampler.rs @@ -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::()) + .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})"); + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index b318180..a774f79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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.