diff --git a/README.md b/README.md index a727781..b3f7785 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ a first-class mode, not an afterthought. - Bands are individually bypassable; with all three bypassed the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim - Has its own look-ahead; the plugin reports a single **constant** total latency (the fixed band + 'All' look-ahead), set once — see Latency below ### Output Limiter -- True-peak brickwall (ceiling = 0 dBFS or user-defined) -- 4x oversampling for inter-sample peak detection -- Short attack (≤ 0.1 ms), auto-release +- 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 ### 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 @@ -87,6 +87,7 @@ a first-class mode, not an afterthought. ### Global - `input_gain` — pre-gain before filterbank (dB) - `output_ceiling` — brickwall ceiling (dBFS, default 0.0) +- `limiter_release_ms` — output limiter release time - `look_ahead_ms` — look-ahead time (0–5 ms). Reported latency is **constant** (the max look-ahead); the knob only moves the detector tap within that fixed delay, so it is safe to adjust during playback (changing reported latency mid-stream crashes some hosts, FL included) - `crossover_low_hz` — low/mid crossover frequency - `crossover_high_hz` — mid/high crossover frequency @@ -116,8 +117,8 @@ src/ compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay crossover.rs # ✅ LR4 3-band filterbank with all-pass phase compensation biquad.rs # ✅ generic biquad (Transposed Direct Form II) - limiter.rs # (planned) output true-peak brickwall limiter - delay.rs # (planned) look-ahead delay (currently lives inside compressor.rs) + 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 editor/ mod.rs # (planned) egui editor split out of lib.rs @@ -175,12 +176,11 @@ 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-17):** Stages 1–3 complete — full-band compressor, peak/RMS detection, and now -the 3-band LR4 crossover feeding per-band compressors summed into the 'All' channel (4 reusable -`Compressor` instances). Look-ahead + latency (Stage 4) and a basic 4-column UI (Stage 5) are in. -**Next: Stage 4 — output brickwall limiter + oversampler.** DSP is in `src/dsp/` -(`biquad.rs`, `crossover.rs`, `compressor.rs`); params and the egui editor are still inline in -`src/lib.rs` (not yet split into `params.rs` / `editor/`). +**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`. ### Stage 1 — Skeleton plugin ✅ - [x] NIH-plug "passthrough" compiling and loading in DAW @@ -200,12 +200,13 @@ the 3-band LR4 crossover feeding per-band compressors summed into the 'All' chan - [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 ⬅ next *(look-ahead + latency already done)* -- [x] Look-ahead delay (circular buffer) — inside `compressor.rs`, no separate `delay.rs` +### Stage 4 — Output brickwall limiter + oversampler *(4a done; 4b = oversampling)* +- [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 two-stage total (bands + 'All') -- [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc) -- [ ] Implement brickwall output limiter with true-peak detection +- [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 ### 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 new file mode 100644 index 0000000..2088a63 --- /dev/null +++ b/src/dsp/limiter.rs @@ -0,0 +1,201 @@ +//! Look-ahead brickwall peak limiter (base-rate). +//! +//! Guarantees the output never exceeds the ceiling. A short fixed look-ahead lets the gain ramp +//! down *before* a peak reaches the output (click-free), driven by a **sliding maximum** over the +//! look-ahead window so the reduction is fully in place in time. A final clamp at the ceiling is +//! the hard guarantee against any residual from smoothing lag or float error. +//! +//! 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. + +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; + +fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 { + if time_ms <= 0.0 { + 0.0 + } else { + (-1.0 / (time_ms * 0.001 * sample_rate)).exp() + } +} + +pub struct Limiter { + /// Per-channel audio delay ring. + delay: Vec>, + /// Linked `|x|` history, same length as the delay ring (for the sliding maximum). + peaks: Vec, + capacity: usize, + write_pos: usize, + fixed_delay: usize, + /// Current smoothed gain (<= 1). + gain: f32, + attack_coef: f32, +} + +impl Default for Limiter { + fn default() -> Self { + Self { + delay: Vec::new(), + peaks: Vec::new(), + capacity: 0, + write_pos: 0, + fixed_delay: 0, + gain: 1.0, + attack_coef: 0.0, + } + } +} + +impl Limiter { + pub fn new() -> Self { + Self::default() + } + + /// Allocate buffers. Call from `initialize()` (allocation allowed). + pub fn prepare(&mut self, sample_rate: f32, num_channels: usize) { + self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize; + self.capacity = self.fixed_delay + 1; + self.attack_coef = time_to_coef(ATTACK_MS, sample_rate); + 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.reset(); + } + + pub fn reset(&mut self) { + for ch in &mut self.delay { + ch.iter_mut().for_each(|s| *s = 0.0); + } + self.peaks.iter_mut().for_each(|p| *p = 0.0); + self.write_pos = 0; + self.gain = 1.0; + } + + /// Constant reported latency (the fixed look-ahead delay). + pub fn latency(&self) -> u32 { + self.fixed_delay as u32 + } + + /// Limit one frame in place: `input[ch]` -> `output[ch]`. + /// + /// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a + /// release time. Output is guaranteed `|y| <= ceiling`. + 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; + for &x in &input[..n] { + peak = peak.max(x.abs()); + } + + // Write into the ring. + for ch in 0..n { + self.delay[ch][self.write_pos] = input[ch]; + } + self.peaks[self.write_pos] = peak; + + // Sliding maximum over the look-ahead window (= the whole ring). Because the oldest sample + // (the one we output now) is in this window, `ceiling / window_max` applied to it can never + // exceed the ceiling, and the gain has pre-dropped for any louder sample still to come. + let mut window_max = 0.0f32; + for &p in &self.peaks { + window_max = window_max.max(p); + } + let target = if window_max > ceiling { ceiling / window_max } else { 1.0 }; + + // Decoupled smoothing: fast attack down, slow release up. + self.gain = if target < self.gain { + self.attack_coef * self.gain + (1.0 - self.attack_coef) * target + } else { + release_coef * self.gain + (1.0 - release_coef) * target + }; + + // Output the delayed sample, clamped to the ceiling as the hard guarantee. + let out_pos = (self.write_pos + 1) % self.capacity; // oldest sample = fixed_delay ago + for ch in 0..n { + output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling); + } + + self.write_pos = (self.write_pos + 1) % self.capacity; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::TAU; + + const SR: f32 = 48_000.0; + + fn release() -> f32 { + time_to_coef(50.0, SR) + } + + #[test] + fn never_exceeds_ceiling_on_spikes() { + // Mostly silence with occasional large spikes — output must never exceed the ceiling. + let mut lim = Limiter::new(); + lim.prepare(SR, 1); + let ceiling = 1.0; + let mut out = [0.0f32]; + for i in 0..10_000 { + let x = if i % 500 == 0 { 5.0 } else { 0.01 }; + lim.process(&[x], &mut out, ceiling, release()); + assert!(out[0].abs() <= ceiling + 1e-6, "overshoot at {i}: {}", out[0]); + } + } + + #[test] + fn limits_loud_sine_to_ceiling() { + // A sine well above the ceiling settles to ~ceiling, not silenced. + let mut lim = Limiter::new(); + lim.prepare(SR, 1); + let ceiling = 1.0; + let (amp, freq) = (2.0f32, 1_000.0); + let mut out = [0.0f32]; + let mut max_tail = 0.0f32; + let total = SR as usize; + for i in 0..total { + let x = amp * (TAU * freq * i as f32 / SR).sin(); + lim.process(&[x], &mut out, ceiling, release()); + if i >= total - 4_800 { + max_tail = max_tail.max(out[0].abs()); + } + } + assert!(max_tail <= ceiling + 1e-6, "exceeded ceiling: {max_tail}"); + assert!(max_tail > 0.9, "over-attenuated: {max_tail}"); + } + + #[test] + fn transparent_below_ceiling() { + // A signal under the ceiling passes through unattenuated (just delayed). + let mut lim = Limiter::new(); + lim.prepare(SR, 1); + let ceiling = 1.0; + let (amp, freq) = (0.5f32, 1_000.0); + let mut out = [0.0f32]; + let mut max_tail = 0.0f32; + let total = SR as usize / 2; + for i in 0..total { + let x = amp * (TAU * freq * i as f32 / SR).sin(); + lim.process(&[x], &mut out, ceiling, release()); + if i >= total - 4_800 { + max_tail = max_tail.max(out[0].abs()); + } + } + assert!((max_tail - amp).abs() < 1e-3, "not transparent: {max_tail}"); + } + + #[test] + fn latency_is_the_lookahead() { + let mut lim = Limiter::new(); + lim.prepare(SR, 1); + let expected = (LOOKAHEAD_MS * 0.001 * SR).ceil() as u32; + assert_eq!(lim.latency(), expected); + assert!(expected > 0); + } +} diff --git a/src/dsp/mod.rs b/src/dsp/mod.rs index d4b87fd..c9d05e8 100644 --- a/src/dsp/mod.rs +++ b/src/dsp/mod.rs @@ -7,3 +7,4 @@ pub mod biquad; pub mod compressor; pub mod crossover; +pub mod limiter; diff --git a/src/lib.rs b/src/lib.rs index d0228eb..41b4657 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ use std::sync::Arc; mod dsp; use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS}; use dsp::crossover::Crossover; +use dsp::limiter::Limiter; /// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel. const LOW: usize = 0; @@ -34,6 +35,8 @@ struct Codename206 { crossover: Crossover, /// Compressors indexed by [`LOW`], [`MID`], [`HIGH`], [`ALL`]. comps: [Compressor; 4], + /// Output brickwall limiter (final stage). + limiter: Limiter, } #[derive(Params)] @@ -51,6 +54,13 @@ struct Codename206Params { #[id = "lookahead"] pub look_ahead_ms: FloatParam, + /// Output brickwall ceiling (the limiter never lets output exceed this). + #[id = "ceiling"] + pub output_ceiling_db: FloatParam, + /// Output limiter release time. + #[id = "lim_rel"] + pub limiter_release_ms: FloatParam, + #[nested(id_prefix = "low", group = "Low")] pub low: CompressorParams, #[nested(id_prefix = "mid", group = "Mid")] @@ -88,6 +98,7 @@ impl Default for Codename206 { sample_rate: 48_000.0, crossover: Crossover::new(), comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()], + limiter: Limiter::new(), } } } @@ -121,6 +132,22 @@ impl Default for Codename206Params { .with_unit(" ms") .with_value_to_string(formatters::v2s_f32_rounded(2)), + output_ceiling_db: FloatParam::new( + "Ceiling", + 0.0, + FloatRange::Linear { min: -24.0, max: 0.0 }, + ) + .with_unit(" dB") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + + limiter_release_ms: FloatParam::new( + "Limiter Release", + 100.0, + FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) }, + ) + .with_unit(" ms") + .with_value_to_string(formatters::v2s_f32_rounded(1)), + low: CompressorParams::default(), mid: CompressorParams::default(), high: CompressorParams::default(), @@ -271,6 +298,10 @@ impl Plugin for Codename206 { ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter)); ui.label("Look-ahead"); ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter)); + ui.label("Ceiling"); + ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter)); + ui.label("Lim Release"); + ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter)); }); ui.separator(); ui.columns(4, |cols| { @@ -305,10 +336,12 @@ impl Plugin for Codename206 { self.params.crossover_low_hz.value(), self.params.crossover_high_hz.value(), ); + self.limiter.prepare(self.sample_rate, channels); - // Two compressor stages in series (bands → 'All'), each with the same fixed look-ahead - // delay. Reported once as a constant; see the look-ahead note in the compressor module. - let total_latency = self.comps[LOW].latency() + self.comps[ALL].latency(); + // Three series stages each with a fixed look-ahead delay: the bands, the 'All' channel, + // and the output limiter. Reported once as a constant; see the compressor look-ahead note. + let total_latency = + self.comps[LOW].latency() + self.comps[ALL].latency() + self.limiter.latency(); context.set_latency_samples(total_latency); true } @@ -318,6 +351,7 @@ impl Plugin for Codename206 { for comp in &mut self.comps { comp.reset(); } + self.limiter.reset(); } fn process( @@ -344,11 +378,17 @@ impl Plugin for Codename206 { ]; let mut all_set = build_settings(&self.params.all, lookahead, self.sample_rate); + // Output limiter settings (block-rate). + let ceiling = util::db_to_gain(self.params.output_ceiling_db.value()); + let limiter_release = + Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate); + let mut in_frame = [0.0f32; 2]; let mut band_in = [[0.0f32; 2]; 3]; let mut band_out = [[0.0f32; 2]; 3]; let mut summed = [0.0f32; 2]; let mut out_frame = [0.0f32; 2]; + let mut lim_frame = [0.0f32; 2]; for mut frame in buffer.iter_samples() { let n = frame.len().min(2); @@ -378,8 +418,11 @@ impl Plugin for Codename206 { all_set.makeup_db = self.params.all.makeup_db.smoothed.next(); self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set); + // Output brickwall limiter. + self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release); + for ch in 0..n { - *frame.get_mut(ch).unwrap() = out_frame[ch]; + *frame.get_mut(ch).unwrap() = lim_frame[ch]; } }