Files
codename-206/README.md
T
Mikkeli Matlock 411b27fcf3 docs: update README for Stage 6 metering, pre-gain, and module split
Reflect actual code: per-channel pre-gain drive in signal flow + params,
makeup range -24..+24, lib.rs split into params/editor/meters, lock-free
atomic meters (not a mutex), and Stage 6 progress (|L|GR|R| meters, latching
ceiling lamp, rolling in/out/GR plot). Overview/Goals unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:56:31 +09:00

264 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ─┐ (bypassable)
├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (bypassable)
└─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (bypassable)
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 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
- 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 (05 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
### 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
- `threshold_db`
- `ratio` — 1.0 (off) to ∞ (limiting)
- `attack_ms`
- `release_ms`
- `knee_db` — soft knee width
- `makeup_db` — makeup gain (24…+24 dB)
- `bypass` — per-channel bypass (bypassing low+mid+high = 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's meters and plot are drawn directly with egui's `Painter` in `editor.rs` rather than
as separate widget files; a `gain_curve` display and draggable crossover handles are still to come.
---
## 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-23):** Stages 14 done — the full signal chain works: 3-band LR4 crossover →
per-band pre-gain + compressors (peak/RMS) → 'All' channel → **true-peak brickwall limiter** (4×
oversampled detection). `lib.rs` has been split into `params.rs`, `editor.rs`, and `meters.rs`.
Stage 6 metering is underway: per-channel **|L | GR | R| meters**, a **latching ceiling lamp**, and
a **rolling in/out/gain-reduction plot** (per-channel tabs + flow-speed selector). **Next: gain-curve
display and draggable crossover handles, then replace the placeholder slider UI.**
### 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-band bypass toggles
- [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)
- [ ] Static gain-curve display per band (threshold/ratio/knee)
- [ ] Draggable crossover handles on a frequency display
- [ ] 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>` of **lock-free atomics** (`meters.rs`) — never a mutex on the audio path. `process()`
publishes one value per meter per block (gated on the editor being open); the editor reads them
each frame. 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)