From 20c5a17a61e9a22843b0422969dc800411aaf899 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Fri, 19 Jun 2026 15:50:06 +0900 Subject: [PATCH] Stage 4b: true-peak limiting via 4x polyphase oversampling Upgrades the brickwall limiter from sample-peak to true-peak (inter-sample). - src/dsp/oversampler.rs: 4x polyphase windowed-sinc (4 phases x 12 taps, Blackman, each phase normalized to unity DC). Detection-only: max_true_peak() returns the inter-sample max magnitude and discards the upsampled samples; the audio path is untouched. Built in prepare(), no realtime allocation. Cost ~ one base-rate FIR per channel; its small group delay is absorbed by the limiter look-ahead, so no added reported latency. - src/dsp/limiter.rs: detector peak = max(sample_peak, oversampler.max_true_peak()); targets a 0.3 dB margin under the ceiling to cover the 4x detection residual. - 16 unit tests (2 new: detects ~3 dB fs/4 inter-sample overshoot; preserves DC amplitude). README/docs updated: Stage 4 complete. Co-Authored-By: Claude Opus 4.8 --- README.md | 25 +++---- src/dsp/limiter.rs | 26 +++++-- src/dsp/mod.rs | 1 + src/dsp/oversampler.rs | 149 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 16 deletions(-) create mode 100644 src/dsp/oversampler.rs diff --git a/README.md b/README.md index b3f7785..b6baaff 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ a first-class mode, not an afterthought. ### Output Limiter - Brickwall, ceiling = 0 dBFS or user-defined (`output_ceiling`). Look-ahead + sliding-max peak detection + a ceiling clamp guarantee the output never exceeds the ceiling - Short attack (≤ 0.1 ms), auto-release (release time user-set) -- **Sample-peak today**; 4x-oversampled true-peak (inter-sample) detection is the remaining Stage-4 work +- **True-peak**: 4× polyphase oversampling estimates the inter-sample peak (detection only — the upsampled signal is discarded); the limiter targets a 0.3 dB margin under the ceiling to cover the 4× residual ### Latency - Reported via `context.set_latency_samples()` in `initialize()` — **never** from `process()`; renegotiating latency mid-stream crashes some hosts (FL included) - Reported latency is a **constant** (the max look-ahead); the look-ahead control only moves the detector tap within that fixed delay @@ -119,7 +119,7 @@ src/ biquad.rs # ✅ generic biquad (Transposed Direct Form II) limiter.rs # ✅ look-ahead brickwall limiter (sample-peak; true-peak pending) delay.rs # (planned) look-ahead delay (currently inside compressor.rs / limiter.rs) - oversampler.rs # (planned) 4x oversampler for true-peak detection + oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only) editor/ mod.rs # (planned) egui editor split out of lib.rs widgets/ @@ -176,11 +176,12 @@ is essential — without it FL silently skips a plugin it has seen before.) Work through these stages in order — each stage produces a loadable, audible plugin. -**Status (2026-06-19):** Stages 1–3 plus the **base-rate brickwall limiter** (Stage 4a) are done: -3-band LR4 crossover → per-band compressors (peak/RMS) → 'All' channel → look-ahead brickwall -limiter, with a basic 4-column UI. **Next: Stage 4b — 4× oversampling for true-peak (inter-sample) -limiting** (deferred as the CPU-heavy part). DSP is in `src/dsp/` (`biquad.rs`, `crossover.rs`, -`compressor.rs`, `limiter.rs`); params and the egui editor are still inline in `src/lib.rs`. +**Status (2026-06-19):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover → +per-band compressors (peak/RMS) → 'All' channel → **true-peak brickwall limiter** (4× oversampled +detection), with a basic 4-column UI. **Next: split `params.rs`/`editor/` out of `lib.rs`, then +Stage 6 visualisers (meters, gain curve).** DSP is in `src/dsp/` (`biquad.rs`, `crossover.rs`, +`compressor.rs`, `limiter.rs`, `oversampler.rs`); params and the egui editor are still inline in +`src/lib.rs`. ### Stage 1 — Skeleton plugin ✅ - [x] NIH-plug "passthrough" compiling and loading in DAW @@ -200,13 +201,13 @@ limiting** (deferred as the CPU-heavy part). DSP is in `src/dsp/` (`biquad.rs`, - [x] Apply per-band compressor to each band - [x] Sum bands back together - [x] Run the summed signal through the 'All' channel compressor before output -### Stage 4 — Output brickwall limiter + oversampler *(4a done; 4b = oversampling)* +### Stage 4 — Output brickwall limiter + oversampler ✅ - [x] Look-ahead delay (circular buffer) — inside `compressor.rs` and `limiter.rs`, no separate `delay.rs` - [x] Wire look-ahead: detector reads N samples ahead of the VCA -- [x] Report latency — `context.set_latency_samples()` once; now the constant three-stage total (bands + 'All' + limiter) -- [x] Brickwall output limiter (`limiter.rs`): look-ahead + sliding-max peak detect + ceiling clamp guarantee; limits **sample** peaks -- [ ] `oversampler.rs` (4x, polyphase FIR / windowed sinc) ⬅ next -- [ ] True-peak (inter-sample) limiting on top of the brickwall, via the oversampler +- [x] Report latency — `context.set_latency_samples()` once; constant three-stage total (bands + 'All' + limiter) +- [x] Brickwall output limiter (`limiter.rs`): look-ahead + sliding-max + ceiling clamp guarantee +- [x] `oversampler.rs` — 4× polyphase windowed-sinc, detection-only (returns the inter-sample max) +- [x] True-peak limiting: limiter peak = max(sample, inter-sample); targets a 0.3 dB margin under the ceiling for the 4× residual ### Stage 5 — Basic egui UI *(basic version done early)* - [x] Add `nih_plug_egui` editor - [x] Sliders for all current parameters (`ParamSlider` grid) diff --git a/src/dsp/limiter.rs b/src/dsp/limiter.rs index 2088a63..b3a4088 100644 --- a/src/dsp/limiter.rs +++ b/src/dsp/limiter.rs @@ -8,11 +8,16 @@ //! 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; + const MAX_CHANNELS: usize = 2; /// Fixed look-ahead — also this stage's constant latency contribution. const LOOKAHEAD_MS: f32 = 1.5; /// Near-instant attack; the look-ahead gives it time to act before the peak arrives. const ATTACK_MS: f32 = 0.05; +/// The 4× true-peak detector can still under-read by a few tenths of a dB near Nyquist, so we +/// target a hair below the ceiling to keep the actual inter-sample peak under it. +const TRUE_PEAK_MARGIN_DB: f32 = 0.3; fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 { if time_ms <= 0.0 { @@ -33,6 +38,8 @@ pub struct Limiter { /// Current smoothed gain (<= 1). gain: f32, attack_coef: f32, + /// 4× interpolator for true-peak (inter-sample) detection. + oversampler: Oversampler, } impl Default for Limiter { @@ -45,6 +52,7 @@ impl Default for Limiter { fixed_delay: 0, gain: 1.0, attack_coef: 0.0, + oversampler: Oversampler::new(), } } } @@ -62,6 +70,7 @@ impl Limiter { let channels = num_channels.clamp(1, MAX_CHANNELS); self.delay = vec![vec![0.0; self.capacity]; channels]; self.peaks = vec![0.0; self.capacity]; + self.oversampler.prepare(channels); self.reset(); } @@ -70,6 +79,7 @@ impl Limiter { ch.iter_mut().for_each(|s| *s = 0.0); } self.peaks.iter_mut().for_each(|p| *p = 0.0); + self.oversampler.reset(); self.write_pos = 0; self.gain = 1.0; } @@ -86,11 +96,15 @@ impl Limiter { pub fn process(&mut self, input: &[f32], output: &mut [f32], ceiling: f32, release_coef: f32) { let n = input.len().min(self.delay.len()); - // Linked peak of the current input. - let mut peak = 0.0f32; + // Detector = max of the sample peak and the 4× true-peak (inter-sample) estimate. + let mut sample_peak = 0.0f32; for &x in &input[..n] { - peak = 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. + let target_ceiling = ceiling * 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0); // Write into the ring. for ch in 0..n { @@ -105,7 +119,11 @@ impl Limiter { for &p in &self.peaks { window_max = window_max.max(p); } - let target = if window_max > ceiling { ceiling / window_max } else { 1.0 }; + let target = if window_max > target_ceiling { + target_ceiling / window_max + } else { + 1.0 + }; // Decoupled smoothing: fast attack down, slow release up. self.gain = if target < self.gain { diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs index c9d05e8..22e1806 100644 --- a/src/dsp/mod.rs +++ b/src/dsp/mod.rs @@ -8,3 +8,4 @@ pub mod biquad; pub mod compressor; pub mod crossover; pub mod limiter; +pub mod oversampler; diff --git a/src/dsp/oversampler.rs b/src/dsp/oversampler.rs new file mode 100644 index 0000000..29f802e --- /dev/null +++ b/src/dsp/oversampler.rs @@ -0,0 +1,149 @@ +//! 4× polyphase interpolation for **true-peak (inter-sample) detection only**. +//! +//! A band-limited signal can overshoot its sample values between samples, so the digital sample +//! peak under-reads the real (post-DAC) peak. We reconstruct the 4× grid with a polyphase +//! windowed-sinc interpolator and report only the maximum magnitude found — the interpolated +//! samples themselves are discarded. The audio path is untouched; this just feeds a better peak +//! estimate into the limiter. +//! +//! Speed: the prototype `PHASES * TAPS_PER_PHASE`-tap low-pass is split into `PHASES` sub-filters +//! of `TAPS_PER_PHASE` taps, each run at the base rate (no zero-stuffed multiplies). Cost is +//! `PHASES * TAPS_PER_PHASE` MACs per input sample per channel — about one base-rate FIR. +//! +//! The interpolation point sits at the centre of the tap window, so the estimate lags the input by +//! ~`TAPS_PER_PHASE/2` samples. That is far smaller than the limiter's look-ahead, which absorbs it +//! — so this adds no reported latency. + +const PHASES: usize = 4; +const TAPS_PER_PHASE: usize = 12; +const MAX_CHANNELS: usize = 2; + +/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES` +/// sub-filters, each normalised to unity DC gain so reconstruction preserves amplitude. +fn build_coefficients() -> [[f32; TAPS_PER_PHASE]; PHASES] { + use std::f32::consts::PI; + let n = PHASES * TAPS_PER_PHASE; + let center = (n as f32 - 1.0) / 2.0; + + let mut proto = [0.0f32; PHASES * TAPS_PER_PHASE]; + for (m, p) in proto.iter_mut().enumerate() { + // Sinc low-pass at the base-rate Nyquist (cutoff = 1/PHASES of the oversampled rate). + let x = (m as f32 - center) / PHASES as f32; + let sinc = if x.abs() < 1e-7 { 1.0 } else { (PI * x).sin() / (PI * x) }; + // Blackman window. + let t = m as f32 / (n as f32 - 1.0); + let window = 0.42 - 0.5 * (2.0 * PI * t).cos() + 0.08 * (4.0 * PI * t).cos(); + *p = sinc * window; + } + + let mut coeffs = [[0.0f32; TAPS_PER_PHASE]; PHASES]; + for (phase, row) in coeffs.iter_mut().enumerate() { + let mut sum = 0.0; + for (k, c) in row.iter_mut().enumerate() { + *c = proto[k * PHASES + phase]; + sum += *c; + } + if sum.abs() > 1e-12 { + for c in row.iter_mut() { + *c /= sum; // unity DC per phase -> amplitude-preserving + } + } + } + coeffs +} + +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, +} + +impl Default for Oversampler { + fn default() -> Self { + Self { + coeffs: build_coefficients(), + history: Vec::new(), + pos: 0, + } + } +} + +impl Oversampler { + pub fn new() -> Self { + Self::default() + } + + pub fn prepare(&mut self, num_channels: usize) { + let channels = num_channels.clamp(1, MAX_CHANNELS); + self.history = vec![[0.0; TAPS_PER_PHASE]; channels]; + self.reset(); + } + + pub fn reset(&mut self) { + for ch in &mut self.history { + *ch = [0.0; TAPS_PER_PHASE]; + } + self.pos = 0; + } + + /// 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 { + 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]; + } + + let mut peak = 0.0f32; + for ch in 0..n { + let hist = &self.history[ch]; + for phase in &self.coeffs { + let mut acc = 0.0f32; + for (k, &c) in phase.iter().enumerate() { + // k = 0 is the newest sample, increasing k goes back in time. + let idx = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE; + acc += c * hist[idx]; + } + peak = peak.max(acc.abs()); + } + } + + self.pos += 1; + peak + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::PI; + + #[test] + fn detects_inter_sample_overshoot() { + // A unit sine at fs/4 phased so every sample lands at ±0.707 while the true peak is 1.0 + // (a classic ~3 dB inter-sample overshoot). The detector must see well above 0.707. + let mut os = Oversampler::new(); + os.prepare(1); + let mut detected = 0.0f32; + for n in 0..2_000 { + let x = (PI * n as f32 / 2.0 + PI / 4.0).sin(); // sin(πn/2 + π/4) + detected = detected.max(os.max_true_peak(&[x])); + } + assert!(detected > 0.9, "missed inter-sample peak: {detected}"); + assert!(detected < 1.1, "implausible overshoot: {detected}"); + } + + #[test] + fn preserves_amplitude_of_constant() { + // Unity-DC normalisation: a constant signal reconstructs at its own level. + let mut os = Oversampler::new(); + os.prepare(1); + let mut last = 0.0f32; + for _ in 0..200 { + last = os.max_true_peak(&[0.5]); + } + assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}"); + } +}