Add peak/RMS detection switch; sweep docs to match code
- Compressor: switchable peak / RMS detection (EnumParam<DetectionMode> in lib.rs -> use_rms bool in CompressorSettings; DSP stays framework-agnostic). RMS is a one-pole running mean of the linked squared level with a hardcoded 5 ms window, updated whenever active so peak<->RMS switching is seamless. New unit test (RMS compresses a sine less than peak); 6 tests total. - README: reconciled Implementation Order checklists with actual progress (Stages 1-2 done; look-ahead/latency + basic UI pulled forward), annotated the project structure (implemented vs planned), and corrected the Latency and Denormal-flushing notes to match the code (set_latency_samples once / constant latency; in-code denormal flush). Overview and goals left unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
```
|
||||
|
||||
---
|
||||
@@ -166,35 +170,40 @@ 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.
|
||||
|
||||
### 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
|
||||
**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 ✅
|
||||
- [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.
|
||||
|
||||
+58
-1
@@ -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
|
||||
|
||||
+19
@@ -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<DetectionMode>,
|
||||
#[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(),
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user