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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
+22
-4
@@ -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 {
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod biquad;
|
||||
pub mod compressor;
|
||||
pub mod crossover;
|
||||
pub mod limiter;
|
||||
pub mod oversampler;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user