Mikkeli Matlock fe4033b772 feat: translucent area fill under the in/out plot traces
Draw a translucent area from each level line down to the plot baseline so the
input and output traces read more clearly. The gain-reduction trace stays a
plain line (it hangs from the 0 dB line, where a fill would read oddly).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 05:03:30 +09:00

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
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 currently drawn directly with egui's Painter inline in editor.rs. When the UI is redesigned (gain curve, draggable crossover, real layout), the plan is to split it into a widget module so each visualiser is self-contained and reusable:

src/
  editor/
    mod.rs            # editor assembly + layout (replaces editor.rs)
    widgets/
      meter.rs        # |L | GR | R| level + gain-reduction cluster (extract from editor.rs)
      plot.rs         # rolling in/out/GR scope (extract from editor.rs)
      gain_curve.rs   # static gain-curve display per channel (threshold/ratio/knee)  — planned
      crossover.rs    # frequency display with draggable crossover handles            — planned

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 (rustupwinget install Rustlang.Rustup)
  • Visual Studio 2022 with the "Desktop development with C++" workload (provides the MSVC linker)
# 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):

.\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

  • 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)
  • process() passes audio through untouched (since superseded by the compressor)
  • 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)
  • Level detector — switchable peak / RMS (RMS window hardcoded for now)
  • Implement gain computer (threshold, ratio, soft knee)
  • Implement attack/release envelope (smooth decoupled peak detector)
  • Wire into process(); covered by unit tests (static curve, knee continuity, steady state, RMS, constant latency)

Stage 3 — Crossover filterbank

  • Implement LR4 LP/HP biquad chains in crossover.rs (+ generic biquad.rs, Transposed Direct Form II)
  • 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
  • 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
  • Apply per-band compressor to each band
  • Sum bands back together
  • Run the summed signal through the 'All' channel compressor before output

Stage 4 — Output brickwall limiter + oversampler

  • Look-ahead delay (circular buffer) — inside compressor.rs and limiter.rs, no separate delay.rs
  • Wire look-ahead: detector reads N samples ahead of the VCA
  • Report latency — context.set_latency_samples() once; constant three-stage total (bands + 'All' + limiter)
  • Brickwall output limiter (limiter.rs): look-ahead + sliding-max + ceiling clamp guarantee
  • oversampler.rs — 4× polyphase windowed-sinc, detection-only (returns the inter-sample max)
  • 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)

  • Add nih_plug_egui editor
  • Sliders for all current parameters (ParamSlider grid)
  • Per-band bypass toggles
  • Confirm UI controls update DSP in real time

Stage 6 — Custom visualisations

  • Per-channel level meters (output level, |L | GR | R| cluster)
  • Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
  • 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 — read the plugins/ examples first
  • NIH-plug docs
  • Cookiecutter template
  • egui docs
  • 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)
S
Description
No description provided
Readme GPL-3.0 410 KiB
Languages
Rust 95.8%
PowerShell 4%
Batchfile 0.2%