diff --git a/README.md b/README.md index fe97773..2d413ac 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ a first-class mode, not an afterthought. - Bands sum phase-coherently back to flat - Crossover frequencies are user-adjustable parameters ### Per-Band Compressor -- Level detection: switchable RMS / peak, with configurable window +- Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later) - Gain computer: threshold, ratio, soft knee - Attack / release envelopes (logarithmic ballistics) - Makeup gain per band @@ -71,13 +71,14 @@ a first-class mode, not an afterthought. - Structurally **identical to a per-band compressor** — reuse the same comp/lim code/params, just fed the summed signal instead of a filtered band - Runs after the three bands are summed, before the output brickwall limiter - 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 delay; total reported latency = max(band look-ahead) + 'All' look-ahead +- 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 ### Latency -- Look-ahead duration must be reported via `Plugin::latency()` for DAW compensation +- 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 - All bands use equal delay to preserve phase alignment --- @@ -90,6 +91,7 @@ a first-class mode, not an afterthought. - `crossover_low_hz` — low/mid crossover frequency - `crossover_high_hz` — mid/high crossover frequency ### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused) +- `detection` — peak / RMS level detection - `threshold_db` - `ratio` — 1.0 (off) to ∞ (limiting) - `attack_ms` @@ -103,24 +105,26 @@ The 'all' channel uses the same struct so its UI and DSP are identical to a band ## Project Structure +Target layout (✅ = exists today; the rest is planned): + ``` src/ - lib.rs # Plugin entry point, implements Plugin trait - params.rs # Params struct with NIH-plug #[id] attributes + lib.rs # ✅ Plugin trait + Params + egui editor (all inline for now) + params.rs # (planned) split Params out of lib.rs dsp/ - mod.rs - crossover.rs # LR4 filterbank (biquad chains) - compressor.rs # Per-band compressor + look-ahead - limiter.rs # Output true-peak brickwall limiter - biquad.rs # Generic biquad filter (Direct Form II transposed) - delay.rs # Circular buffer for look-ahead delay lines - oversampler.rs # 4x oversampler for true-peak detection + mod.rs # ✅ module declarations + compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay + crossover.rs # (planned) LR4 filterbank (biquad chains) + limiter.rs # (planned) output true-peak brickwall limiter + biquad.rs # (planned) generic biquad (Direct Form II transposed) + delay.rs # (planned) look-ahead delay (currently lives inside compressor.rs) + oversampler.rs # (planned) 4x oversampler for true-peak detection editor/ - mod.rs # egui editor setup via nih_plug_egui + mod.rs # (planned) egui editor split out of lib.rs widgets/ - gain_curve.rs # Custom egui Widget: gain curve display - band_meter.rs # Per-band gain reduction meter - level_meter.rs# Input/output level meter + gain_curve.rs # (planned) custom egui Widget: gain curve display + band_meter.rs # (planned) per-band gain reduction meter + level_meter.rs# (planned) input/output level meter ``` --- @@ -165,36 +169,41 @@ is essential — without it FL silently skips a plugin it has seen before.) ## Implementation Order Work through these stages in order — each stage produces a loadable, audible plugin. + +**Status (2026-06-15):** Stages 1–2 are complete. Look-ahead + latency reporting (from Stage 4) +and a basic slider UI (from Stage 5) were pulled forward and already work. **Next: Stage 3 — +crossover filterbank.** DSP currently lives in `src/dsp/compressor.rs`; params and the egui +editor are still inline in `src/lib.rs` (not yet split into `params.rs` / `editor/`). -### Stage 1 — Skeleton plugin -- [ ] NIH-plug "passthrough" compiling and loading in DAW -- [ ] `Params` struct with all parameters declared (no DSP yet) -- [ ] `process()` passes audio through untouched -- [ ] Verify plugin loads and parameters appear in DAW -### Stage 2 — Single-band compressor (no look-ahead, no UI) -- [ ] Implement `biquad.rs` — generic biquad, Direct Form II transposed -- [ ] Implement basic RMS level detector -- [ ] Implement gain computer (threshold, ratio, knee) -- [ ] Implement attack/release envelope on gain reduction -- [ ] Wire into `process()`, test with a sine sweep -### Stage 3 — Crossover filterbank +### Stage 1 — Skeleton plugin ✅ +- [x] NIH-plug "passthrough" compiling and loading in DAW +- [ ] `Params` struct with all parameters declared *(partial — compressor + look-ahead params done; global `input_gain`/`output_ceiling` and crossover params pending)* +- [x] `process()` passes audio through untouched *(since superseded by the compressor)* +- [x] Verify plugin loads and parameters appear in DAW *(verified in FL Studio)* +### Stage 2 — Single-band (full-band) compressor ✅ +- [ ] Implement `biquad.rs` — generic biquad, Direct Form II transposed *(deferred to Stage 3 — not needed for the full-band comp)* +- [x] Level detector — switchable **peak / RMS** (RMS window hardcoded for now) +- [x] Implement gain computer (threshold, ratio, soft knee) +- [x] Implement attack/release envelope (smooth decoupled peak detector) +- [x] Wire into `process()`; covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency) +### Stage 3 — Crossover filterbank ⬅ next - [ ] Implement LR4 LP and HP biquad chains in `crossover.rs` - [ ] Verify bands sum flat (null test: sum vs dry should be silence) - [ ] Add per-band bypass; with all bands bypassed, output must null against dry (proves the "simple comp" mode path) - [ ] Apply per-band compressor to each band - [ ] Sum bands back together - [ ] Run the summed signal through the 'All' channel comp/lim (reuse the per-band compressor) before output -### Stage 4 — Look-ahead + brickwall limiter -- [ ] Implement `delay.rs` circular buffer -- [ ] Wire look-ahead: detector reads N samples ahead of VCA -- [ ] Report latency via `Plugin::latency()` +### Stage 4 — Look-ahead + brickwall limiter *(look-ahead + latency done early)* +- [x] Look-ahead delay (circular buffer) — currently inside `compressor.rs`, no separate `delay.rs` yet +- [x] Wire look-ahead: detector reads N samples ahead of the VCA +- [x] Report latency — via `context.set_latency_samples()`, reported once as a constant (see Latency note) - [ ] Implement `oversampler.rs` (4x, use a polyphase FIR or windowed sinc) - [ ] Implement brickwall output limiter with true-peak detection -### Stage 5 — Basic egui UI -- [ ] Add `nih_plug_egui` editor -- [ ] Knobs / sliders for all parameters -- [ ] Per-band bypass toggles -- [ ] Confirm UI controls update DSP in real time +### Stage 5 — Basic egui UI *(basic version done early)* +- [x] Add `nih_plug_egui` editor +- [x] Sliders for all current parameters (`ParamSlider` grid) +- [ ] Per-band bypass toggles *(partial — single-band bypass present; per-band arrives with Stage 3)* +- [x] Confirm UI controls update DSP in real time ### Stage 6 — Custom visualisations - [ ] `level_meter.rs` — input/output RMS + peak meters - [ ] `band_meter.rs` — per-band gain reduction meters (vertical bars) @@ -210,8 +219,11 @@ must be pre-allocated in `initialize()`. Use `assert_process_allocs` feature fla development to catch violations. ### Denormal flushing -Add `#[cfg(target_arch = "x86_64")] std::arch::x86_64::_MM_SET_FLUSH_ZERO_MODE(...)` in -`initialize()`, or add a small DC offset (1e-25) to filter inputs. +The compressor flushes its envelope/RMS state to zero in code once it decays below audibility +(`flush_denormal` in `compressor.rs`). The hardware `_MM_SET_FLUSH_ZERO_MODE` intrinsic is now +deprecated and the matching DAZ helper isn't exposed by `std::arch`, so a global hardware FTZ/DAZ +(via inline asm on the audio thread) is deferred until the IIR crossover/limiter filters land, +where it matters more. ### Parameter smoothing NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise. diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index c044644..515c13d 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -26,6 +26,10 @@ const MAX_CHANNELS: usize = 2; /// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels. const LEVEL_EPS: f32 = 1e-12; +/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be +/// promoted to a parameter later. +const RMS_WINDOW_MS: f32 = 5.0; + /// Flush a decaying envelope value to zero once it is far below audibility, so the /// exponential tail can't drift into denormal range (which causes CPU spikes). #[inline] @@ -51,6 +55,8 @@ pub struct CompressorSettings { /// How far (in samples) the detector reads *ahead* of the output, 0..=`fixed_delay`. /// This does NOT change the reported latency — the audio delay is always `fixed_delay`. pub lookahead_samples: usize, + /// `true` = RMS detection (running power average), `false` = naive sample peak. + pub use_rms: bool, pub bypass: bool, } @@ -64,6 +70,10 @@ pub struct Compressor { /// Constant audio delay applied to every sample == the reported plugin latency. fixed_delay: usize, + /// RMS detector state: running mean of the squared (linked) level, plus its coefficient. + mean_sq: f32, + rms_coef: f32, + /// Smooth decoupled peak-detector state, expressed as dB of **attenuation** (>= 0). y1: f32, // release branch (peak-with-decay) yl: f32, // attack-smoothed output @@ -77,6 +87,8 @@ impl Default for Compressor { capacity: 0, write_pos: 0, fixed_delay: 0, + mean_sq: 0.0, + rms_coef: 0.0, y1: 0.0, yl: 0.0, } @@ -95,6 +107,7 @@ impl Compressor { self.fixed_delay = (max_lookahead_ms * 0.001 * sample_rate).ceil() as usize; // +1 so the oldest (output) sample and the newest (write) sample never alias. self.capacity = self.fixed_delay + 1; + self.rms_coef = Self::time_to_coef(RMS_WINDOW_MS, sample_rate); let channels = num_channels.clamp(1, MAX_CHANNELS); self.delay = vec![vec![0.0; self.capacity]; channels]; self.reset(); @@ -106,6 +119,7 @@ impl Compressor { ch.iter_mut().for_each(|s| *s = 0.0); } self.write_pos = 0; + self.mean_sq = 0.0; self.y1 = 0.0; self.yl = 0.0; } @@ -179,7 +193,13 @@ impl Compressor { let gain_lin = if set.bypass { 1.0 } else { - let level_db = 20.0 * (peak + LEVEL_EPS).log10(); + // RMS = running mean of the linked squared level over a fixed window. Updated + // whenever active (regardless of mode) so switching peak<->RMS is seamless. + self.mean_sq = flush_denormal( + self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak, + ); + let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak }; + let level_db = 20.0 * (detector + LEVEL_EPS).log10(); // Desired attenuation in dB, as a positive quantity. let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db); @@ -224,6 +244,7 @@ mod tests { release_coef: Compressor::time_to_coef(1.0, SR), makeup_db: 0.0, lookahead_samples: 0, + use_rms: false, bypass: false, } } @@ -270,6 +291,42 @@ mod tests { assert_close(out[0], expected, 1e-3); } + #[test] + fn rms_compresses_a_sine_less_than_peak() { + // For a sine, RMS level is ~3 dB below the peak (A/√2), so RMS detection sees a lower + // level and applies less gain reduction -> louder output than peak detection. + use std::f32::consts::PI; + + fn output_rms(use_rms: bool) -> f32 { + let mut comp = Compressor::new(); + comp.prepare(SR, 1, MAX_LOOKAHEAD_MS); + let mut set = settings(-30.0, 4.0, 0.0); + set.use_rms = use_rms; + set.attack_coef = Compressor::time_to_coef(1.0, SR); + set.release_coef = Compressor::time_to_coef(50.0, SR); + + let (amp, freq) = (0.5f32, 2000.0f32); + let total = SR as usize; + let mut out = [0.0f32]; + let (mut acc, mut cnt) = (0.0f64, 0u32); + for i in 0..total { + let x = amp * (2.0 * PI * freq * i as f32 / SR).sin(); + comp.process(&[x], &mut out, &set); + if i >= total - 4800 { + // measure RMS over the last 0.1 s, after settling + acc += (out[0] * out[0]) as f64; + cnt += 1; + } + } + (acc / cnt as f64).sqrt() as f32 + } + + assert!( + output_rms(true) > output_rms(false), + "RMS detection should compress a sine less than peak" + ); + } + #[test] fn latency_is_constant_regardless_of_lookahead() { // Audio is always delayed by `fixed_delay` (== reported latency); the look-ahead diff --git a/src/lib.rs b/src/lib.rs index 36f0d5b..ef473cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,17 @@ use std::sync::Arc; mod dsp; use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS}; +/// Level-detection mode for the compressor's detector. +#[derive(Enum, PartialEq, Clone, Copy)] +enum DetectionMode { + #[id = "peak"] + #[name = "Peak"] + Peak, + #[id = "rms"] + #[name = "RMS"] + Rms, +} + /// Codename 206 — Stage 2: a single full-band compressor with look-ahead. /// /// The `CompressorParams` struct is `#[nested]` so the exact same controls + DSP can be @@ -33,6 +44,8 @@ struct Codename206Params { #[derive(Params)] struct CompressorParams { + #[id = "detect"] + pub detection: EnumParam, #[id = "thresh"] pub threshold_db: FloatParam, #[id = "ratio"] @@ -80,6 +93,8 @@ impl Default for Codename206Params { impl Default for CompressorParams { fn default() -> Self { Self { + detection: EnumParam::new("Detection", DetectionMode::Peak), + threshold_db: FloatParam::new( "Threshold", -18.0, @@ -188,6 +203,9 @@ impl Plugin for Codename206 { ui.heading(Self::NAME); ui.separator(); egui::Grid::new("params").num_columns(2).show(ui, |ui| { + ui.label("Detection"); + ui.add(widgets::ParamSlider::for_param(¶ms.comp.detection, setter)); + ui.end_row(); ui.label("Threshold"); ui.add(widgets::ParamSlider::for_param(¶ms.comp.threshold_db, setter)); ui.end_row(); @@ -260,6 +278,7 @@ impl Plugin for Codename206 { release_coef: Compressor::time_to_coef(c.release_ms.value(), self.sample_rate), makeup_db: 0.0, lookahead_samples: lookahead, + use_rms: c.detection.value() == DetectionMode::Rms, bypass: c.bypass.value(), };