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