a4c542b2d9
Add a per-channel below-threshold shaper composed in series ahead of the comp: gain = low_shape(level) + comp(level + low_shape(level)). The compressor's threshold now sees the shaped level, so a Low Slope boost lifts quiet material up into compression (and a cut pulls it out). Anchored at the -60 dB silence floor. Low Curve bends the shaper toward a bounded saturation so the serial composition doesn't blow up (0 = straight line). gain_computer split into comp_gain_db + low_gain_db and composed; shared with the editor gain-curve display. Slider order rearranged to read in signal order (pre-gain -> low shaper -> compressor -> output). Defaults (slope 1, curve 0) reproduce the plain compressor; 17 tests pass. Known: the bipolar behaviour isn't final yet (milestone commit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
294 lines
17 KiB
Markdown
294 lines
17 KiB
Markdown
# Codename 206
|
||
|
||
*Called 206 because the Peugeot 206 has a 'maxi' variant. You'll know this is a Maximizer knockoff if you can follow that trail of thoughts.*
|
||
Multiband Compressor / Limiter VST3 — Project Plan
|
||
|
||
## Overview
|
||
|
||
A VST3 multiband compressor/limiter with a custom gain curve display, inspired by FL Studio's Maximizer.
|
||
Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
|
||
|
||
**Goals:**
|
||
- 3-band (configurable crossover points) compressor/limiter
|
||
- An 'All' aggregate channel: a 4th comp/lim stack on the summed bands, so bypassing all bands turns the plugin into a simple full-band compressor (mirrors FL's Maximizer)
|
||
- Look-ahead brickwall output limiter with true-peak detection
|
||
- Real-time gain reduction metering per band
|
||
- Custom gain curve visualiser
|
||
- Fully resizable vector UI
|
||
---
|
||
|
||
## Tech Stack
|
||
|
||
| Layer | Choice |
|
||
|---|---|
|
||
| Language | Rust (stable) |
|
||
| Plugin framework | [NIH-plug](https://github.com/robbert-vdh/nih-plug) |
|
||
| Plugin formats | VST3, CLAP |
|
||
| UI framework | egui (via `nih_plug_egui`) |
|
||
| Build tooling | `cargo xtask bundle` |
|
||
|
||
---
|
||
|
||
## Signal Flow
|
||
|
||
```
|
||
Input
|
||
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
|
||
├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (dry/wet mix)
|
||
├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||
└─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||
│
|
||
Sum of bands ◄─────────────────────────────────────────────────────------┘
|
||
└─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup
|
||
└─ output brickwall limiter (true-peak, 4x oversampled) → output
|
||
```
|
||
|
||
The detector for each band reads `look_ahead_ms` ahead of the VCA, so gain reduction is already ramping when the transient arrives.
|
||
|
||
**The 'All' aggregate channel** (mirrors FL's Maximizer): the three bands are summed and the
|
||
result passes through a *fourth*, full-band compressor/limiter stack before the output limiter.
|
||
Because the LR4 filterbank sums phase-coherently flat, **bypassing all three bands leaves the
|
||
summed signal identical to the input** — so the plugin collapses into a plain single-band
|
||
compressor/limiter driven entirely by the 'All' channel. That makes "multiband off = simple comp"
|
||
a first-class mode, not an afterthought.
|
||
|
||
---
|
||
|
||
## DSP Architecture
|
||
|
||
### Crossover Filterbank
|
||
- Linkwitz-Riley 4th-order (LR4) filters at each crossover frequency
|
||
- LR4 = two cascaded biquads (Butterworth LP or HP)
|
||
- Bands sum phase-coherently to flat **magnitude** (the sum is an all-pass; lower bands get an all-pass at each later crossover to match phase — not a bit-exact time-domain null)
|
||
- Crossover frequencies are user-adjustable parameters
|
||
### Per-Band Compressor
|
||
- **Pre-gain (drive)**: scales the band *before* the detector, so it pushes harder into compression and feeds the sum/limiter hotter — a mild "compressed semi-distortion" without a dedicated saturator. Applied in the wiring (the compressor itself is untouched). Pairs with makeup for full input/output gain-staging
|
||
- 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 (−24…+24 dB — attenuates as well as boosts)
|
||
- Look-ahead: circular delay buffer on the audio path; detector reads ahead
|
||
### 'All' Aggregate Channel
|
||
- 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 have a per-channel dry/wet **mix** (parallel compression); at 0% (or all three dry) 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
|
||
- 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)
|
||
- **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
|
||
- All bands use equal delay to preserve phase alignment
|
||
---
|
||
|
||
## Parameters
|
||
|
||
### Global
|
||
- `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
|
||
|
||
> **Crossover automation caveat:** the lo ≤ hi limit is enforced in the **editor only** (the two
|
||
> are independent params). Host automation writes them directly, so it can drive lo past hi and
|
||
> momentarily invert the mid band. The DSP clamps to a monotonic split so it won't break audio,
|
||
> but FL's automation can misbehave once inverted. Not fixed by design — just don't automate the
|
||
> two across each other.
|
||
### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused)
|
||
- `pre_gain_db` — drive into the compressor (−24…+36 dB, smoothed)
|
||
- `detection` — peak / RMS level detection
|
||
- `low_slope` — low-level shaper slope at the silence floor (1 = unity, >1 fans up/boost, <1 fans down/cut). **Serial**: reshapes the level *before* the threshold, so a boost can lift quiet material up into compression
|
||
- `low_curve` — bends the low shaper toward a bounded saturation (0% = straight line) so the serial composition doesn't run away
|
||
- `threshold_db`
|
||
- `ratio` — 1.0 (off) to ∞ (limiting)
|
||
- `knee_db` — soft knee width
|
||
- `attack_ms`
|
||
- `release_ms`
|
||
- `makeup_db` — makeup gain (−24…+24 dB)
|
||
- `mix` — per-channel dry/wet mix (parallel compression); 0% = dry (a clean bypass), 100% = fully processed. Bands at 0% → simple full-band comp via the 'all' channel
|
||
|
||
The 'all' channel uses the same struct so its UI and DSP are identical to a band; it just sits after the band sum.
|
||
---
|
||
|
||
## Project Structure
|
||
|
||
Target layout (✅ = exists today; the rest is planned):
|
||
|
||
```
|
||
src/
|
||
lib.rs # ✅ Plugin trait + DSP wiring + process()
|
||
params.rs # ✅ Params structs, defaults, build_settings()
|
||
editor.rs # ✅ egui editor: meter panel + rolling plot (drawn via Painter) + slider columns
|
||
meters.rs # ✅ lock-free Meters (atomics): decayed bar values + raw plot feed
|
||
dsp/
|
||
mod.rs # ✅ module declarations
|
||
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 # ✅ look-ahead brickwall limiter (true-peak via oversampler)
|
||
oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only)
|
||
```
|
||
|
||
The editor lives in an `editor/` module — one file per visualiser widget (each owns its GUI
|
||
state), with `mod.rs` as the aggregator/layout. Drawn directly with egui's `Painter`.
|
||
|
||
```
|
||
src/
|
||
editor/
|
||
mod.rs # aggregator: create(), EditorState, layout, placeholder slider columns
|
||
meter.rs # |L | GR | R| level + gain-reduction bars + per-channel ceiling lamp
|
||
plot.rs # rolling in/out/GR scope (200 Hz ring feed) + ceiling-hit markers
|
||
crossover.rs # log-freq strip with draggable crossover handles + number boxes
|
||
gain_curve.rs # static gain-curve display (out vs in) for the selected channel
|
||
```
|
||
|
||
Remaining UI work: replace the placeholder per-channel slider columns in `mod.rs` with the real
|
||
layout.
|
||
|
||
Deferred until the redesign — no need to split prematurely while the layout is still a placeholder.
|
||
|
||
---
|
||
|
||
## Build Steps
|
||
|
||
The project is already scaffolded (NIH-plug + nih_plug_egui, pinned to a fixed git rev in
|
||
`Cargo.toml`). You do **not** need the Steinberg VST3 SDK — NIH-plug bundles its own bindings.
|
||
|
||
**Prerequisites (Windows):**
|
||
- Rust stable (`rustup` — `winget install Rustlang.Rustup`)
|
||
- Visual Studio 2022 with the "Desktop development with C++" workload (provides the MSVC linker)
|
||
|
||
```powershell
|
||
# Build + bundle the VST3 and CLAP
|
||
cargo xtask bundle codename_206 --release
|
||
# Output: target\bundled\Codename 206.vst3 and Codename 206.clap
|
||
```
|
||
|
||
### Deployment
|
||
|
||
FL Studio scans `C:\Program Files\Common Files\VST3` by default, **ignores directory junctions**
|
||
(so a symlinked bundle is invisible to its scanner), and caches failed scans. So deployment must
|
||
copy a *real* bundle into a folder FL scans, then FL must be told to rescan failed plugins.
|
||
|
||
Use the provided script (no need to remember the details):
|
||
|
||
```powershell
|
||
.\deploy.ps1 # build, then copy to the global VST3/CLAP folders (one UAC prompt)
|
||
.\deploy.ps1 -SkipBuild # reinstall the last build without rebuilding
|
||
.\deploy.ps1 -User # copy to %LOCALAPPDATA%\Programs\Common\VST3 instead (no admin) — best for a dev loop
|
||
```
|
||
|
||
`deploy.bat` is a double-click wrapper around the same script.
|
||
|
||
After deploying, in FL Studio: **Options → Manage plugins → tick "Rescan previously failed
|
||
plugins" → Find installed plugins**, then search for **Codename 206**. (The rescan-failed step
|
||
is essential — without it FL silently skips a plugin it has seen before.)
|
||
|
||
> **Known issue (deferred):** the **CLAP** build shows its name/vendor/type correctly in FL, but
|
||
> the **VST3** still displays stale/missing metadata there. Suspected cause is FL caching the VST3
|
||
> by its unchanged `VST3_CLASS_ID`. Likely fix is to regenerate that class ID (and/or clear FL's
|
||
> plugin DB); low priority for now — use the CLAP build meanwhile.
|
||
|
||
---
|
||
|
||
## Implementation Order
|
||
|
||
Work through these stages in order — each stage produces a loadable, audible plugin.
|
||
|
||
**Status (2026-06-25):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover →
|
||
per-band pre-gain + compressors (peak/RMS) → per-channel dry/wet mix → 'All' channel → **true-peak
|
||
brickwall limiter** (4× oversampled detection). `lib.rs` is split into `params.rs`, `meters.rs`, and
|
||
an `editor/` widget module. Stage 6 visualisers are essentially complete: per-channel **|L | GR | R|
|
||
meters** + **per-channel ceiling lamps**, a **rolling in/out/GR plot** (200 Hz ring feed, flow-speed,
|
||
ceiling-hit markers), **draggable crossover handles**, and a **static gain-curve display**. **Next:
|
||
replace the placeholder slider columns with the real UI layout.**
|
||
|
||
### 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 ✅
|
||
- [x] Implement LR4 LP/HP biquad chains in `crossover.rs` (+ generic `biquad.rs`, Transposed Direct Form II)
|
||
- [x] Verify bands sum flat — for IIR LR4 the sum is an **all-pass** (flat *magnitude*, phase-shifted), not a bit-exact null; lower bands get an all-pass at each later crossover to phase-match. Tested via `bands_sum_to_flat_magnitude`
|
||
- [x] Per-band bypass — a bypassed band passes its delayed dry band; with all three bypassed the 'All' channel sees the flat-magnitude reconstruction = the simple-comp mode
|
||
- [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 ✅
|
||
- [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; 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)
|
||
- [x] Per-channel dry/wet mix (parallel compression; replaced the bypass toggle)
|
||
- [x] Confirm UI controls update DSP in real time
|
||
### Stage 6 — Custom visualisations
|
||
- [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
|
||
- [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
|
||
- [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector)
|
||
- [x] Static gain-curve display (out vs in; includes pre-gain + makeup) for the selected channel
|
||
- [x] Draggable crossover handles on a log-frequency display (with number boxes)
|
||
- [ ] Replace the placeholder slider columns with the real UI
|
||
---
|
||
|
||
## Key Implementation Notes
|
||
|
||
### No allocations in `process()`
|
||
Rust's borrow checker will help, but be explicit. All buffers (delay lines, filter states)
|
||
must be pre-allocated in `initialize()`. Use `assert_process_allocs` feature flag during
|
||
development to catch violations.
|
||
|
||
### Denormal flushing
|
||
Handled by the framework — no plugin code needed. NIH-plug wraps `process()` and `reset()` in
|
||
`process_wrapper`, which enables the CPU's **Flush-To-Zero** mode for the duration via its
|
||
`ScopedFtz` guard (x86 `MXCSR` bit 15 / AArch64 `FPCR` bit 24, set with inline asm and restored
|
||
on drop). FTZ has a fixed threshold at the normal/subnormal boundary (~−759 dB for f32), so the
|
||
decaying envelope/RMS tails and all the IIR filter state are flushed to zero automatically,
|
||
far below audibility. We therefore do **not** set the register ourselves or flush values in code.
|
||
(Note: NIH-plug sets FTZ but not DAZ; for our feed-forward IIR work FTZ on results is sufficient.)
|
||
|
||
### Parameter smoothing
|
||
NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise.
|
||
|
||
### Thread safety
|
||
Params are atomics. The editor and audio thread communicate only through params and a shared
|
||
`Arc<Meters>` (`meters.rs`) — never a mutex on the audio path. Two lock-free feeds, both gated on
|
||
the editor being open:
|
||
- **Bar meters** — decayed atomic scalars, one store per block; the editor reads them each frame.
|
||
- **Scrolling plot** — a single-producer/single-consumer `ScopeRing` of buckets clocked at
|
||
~200 Hz, so the plot's horizontal resolution is decoupled from the ~60 fps repaint. The editor
|
||
drains all new buckets each frame. The scope is **transport-gated** (advances only while playing)
|
||
so it freezes rather than scrolling silence when the host is stopped/paused.
|
||
|
||
Never pass DSP state to the UI directly.
|
||
|
||
### VST3 licensing
|
||
You must accept Steinberg's VST3 SDK licence before distributing VST3 binaries.
|
||
NIH-plug's VST3 bindings are GPLv3; if you distribute, the plugin must also be GPLv3
|
||
(or you need a commercial Steinberg licence). CLAP has no such restriction.
|
||
|
||
---
|
||
|
||
## Reference Material
|
||
|
||
- [NIH-plug repo](https://github.com/robbert-vdh/nih-plug) — read the `plugins/` examples first
|
||
- [NIH-plug docs](https://nih-plug.robbertvanderhelm.nl/)
|
||
- [Cookiecutter template](https://github.com/robbert-vdh/nih-plug-template)
|
||
- [egui docs](https://docs.rs/egui)
|
||
- Zölzer, *DAFX: Digital Audio Effects* — biquad filter cookbook
|
||
- Giannoulis et al., "Digital Dynamic Range Compressor Design" (JAES 2012) — compressor ballistics reference
|
||
- AES paper on true-peak limiting / inter-sample peaks (ITU-R BS.1770) |