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:
Mikkeli Matlock
2026-06-15 20:02:23 +09:00
parent 8d9eaeaffc
commit 0f66e9638c
3 changed files with 128 additions and 40 deletions
+51 -39
View File
@@ -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 12 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.