Compare commits
21 Commits
20c5a17a61
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| abc9ea8b4f | |||
| 45735f71f7 | |||
| a4c542b2d9 | |||
| 123703d34d | |||
| b1477f7ec6 | |||
| a8be2179c3 | |||
| 42c5f7dcd2 | |||
| eee94379bc | |||
| 3aeb09957a | |||
| 514fd964f6 | |||
| 9a60b0ea72 | |||
| fe4033b772 | |||
| 0c2e1597a1 | |||
| eb2499bad3 | |||
| 3ae860188e | |||
| 411b27fcf3 | |||
| 4c5165b0bc | |||
| 9a54413bfa | |||
| f8f652f2c7 | |||
| a420d5dd39 | |||
| 8c550da92e |
@@ -34,12 +34,12 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
|
|||||||
```
|
```
|
||||||
Input
|
Input
|
||||||
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
|
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
|
||||||
├─ Band 1 (low) → look-ahead delay → compressor VCA → gain stage ─┐ (bypassable)
|
├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (dry/wet mix)
|
||||||
├─ Band 2 (mid) → look-ahead delay → compressor VCA → gain stage ─┤ (bypassable)
|
├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||||||
└─ Band 3 (high) → look-ahead delay → compressor VCA → gain stage ─┤ (bypassable)
|
└─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||||||
│
|
│
|
||||||
Sum of bands ◄──────────────────────────────────────────────------┘
|
Sum of bands ◄─────────────────────────────────────────────────────------┘
|
||||||
└─ 'All' channel → look-ahead delay → compressor VCA → gain stage
|
└─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup
|
||||||
└─ output brickwall limiter (true-peak, 4x oversampled) → output
|
└─ output brickwall limiter (true-peak, 4x oversampled) → output
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -62,15 +62,16 @@ a first-class mode, not an afterthought.
|
|||||||
- 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)
|
- 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
|
- Crossover frequencies are user-adjustable parameters
|
||||||
### Per-Band Compressor
|
### 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)
|
- Level detection: switchable peak / RMS (RMS window currently hardcoded small; can be exposed later)
|
||||||
- Gain computer: threshold, ratio, soft knee
|
- Gain computer: threshold, ratio, soft knee
|
||||||
- Attack / release envelopes (logarithmic ballistics)
|
- Attack / release envelopes (logarithmic ballistics)
|
||||||
- Makeup gain per band
|
- Makeup gain per band (−24…+24 dB — attenuates as well as boosts)
|
||||||
- Look-ahead: circular delay buffer on the audio path; detector reads ahead
|
- Look-ahead: circular delay buffer on the audio path; detector reads ahead
|
||||||
### 'All' Aggregate Channel
|
### '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
|
- 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
|
- 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
|
- 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
|
- 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
|
### 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
|
- 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
|
||||||
@@ -85,21 +86,29 @@ a first-class mode, not an afterthought.
|
|||||||
## Parameters
|
## Parameters
|
||||||
|
|
||||||
### Global
|
### Global
|
||||||
- `input_gain` — pre-gain before filterbank (dB)
|
|
||||||
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
|
- `output_ceiling` — brickwall ceiling (dBFS, default 0.0)
|
||||||
- `limiter_release_ms` — output limiter release time
|
- `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)
|
- `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_low_hz` — low/mid crossover frequency
|
||||||
- `crossover_high_hz` — mid/high 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)
|
### 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
|
- `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`
|
- `threshold_db`
|
||||||
- `ratio` — 1.0 (off) to ∞ (limiting)
|
- `ratio` — 1.0 (off) to ∞ (limiting)
|
||||||
|
- `knee_db` — soft knee width
|
||||||
- `attack_ms`
|
- `attack_ms`
|
||||||
- `release_ms`
|
- `release_ms`
|
||||||
- `knee_db` — soft knee width
|
- `makeup_db` — makeup gain (−24…+24 dB)
|
||||||
- `makeup_gain_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
|
||||||
- `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.
|
The 'all' channel uses the same struct so its UI and DSP are identical to a band; it just sits after the band sum.
|
||||||
---
|
---
|
||||||
@@ -110,23 +119,36 @@ Target layout (✅ = exists today; the rest is planned):
|
|||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
lib.rs # ✅ Plugin trait + Params + egui editor (all inline for now)
|
lib.rs # ✅ Plugin trait + DSP wiring + process()
|
||||||
params.rs # (planned) split Params out of lib.rs
|
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/
|
dsp/
|
||||||
mod.rs # ✅ module declarations
|
mod.rs # ✅ module declarations
|
||||||
compressor.rs # ✅ full-band comp: peak/RMS detector, gain computer, ballistics, look-ahead delay
|
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
|
crossover.rs # ✅ LR4 3-band filterbank with all-pass phase compensation
|
||||||
biquad.rs # ✅ generic biquad (Transposed Direct Form II)
|
biquad.rs # ✅ generic biquad (Transposed Direct Form II)
|
||||||
limiter.rs # ✅ look-ahead brickwall limiter (sample-peak; true-peak pending)
|
limiter.rs # ✅ look-ahead brickwall limiter (true-peak via oversampler)
|
||||||
delay.rs # (planned) look-ahead delay (currently inside compressor.rs / limiter.rs)
|
|
||||||
oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only)
|
oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only)
|
||||||
editor/
|
|
||||||
mod.rs # (planned) egui editor split out of lib.rs
|
|
||||||
widgets/
|
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -176,12 +198,13 @@ 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.
|
Work through these stages in order — each stage produces a loadable, audible plugin.
|
||||||
|
|
||||||
**Status (2026-06-19):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover →
|
**Status (2026-06-25):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover →
|
||||||
per-band compressors (peak/RMS) → 'All' channel → **true-peak brickwall limiter** (4× oversampled
|
per-band pre-gain + compressors (peak/RMS) → per-channel dry/wet mix → 'All' channel → **true-peak
|
||||||
detection), with a basic 4-column UI. **Next: split `params.rs`/`editor/` out of `lib.rs`, then
|
brickwall limiter** (4× oversampled detection). `lib.rs` is split into `params.rs`, `meters.rs`, and
|
||||||
Stage 6 visualisers (meters, gain curve).** DSP is in `src/dsp/` (`biquad.rs`, `crossover.rs`,
|
an `editor/` widget module. Stage 6 visualisers are essentially complete: per-channel **|L | GR | R|
|
||||||
`compressor.rs`, `limiter.rs`, `oversampler.rs`); params and the egui editor are still inline in
|
meters** + **per-channel ceiling lamps**, a **rolling in/out/GR plot** (200 Hz ring feed, flow-speed,
|
||||||
`src/lib.rs`.
|
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 ✅
|
### Stage 1 — Skeleton plugin ✅
|
||||||
- [x] NIH-plug "passthrough" compiling and loading in DAW
|
- [x] NIH-plug "passthrough" compiling and loading in DAW
|
||||||
@@ -211,13 +234,15 @@ Stage 6 visualisers (meters, gain curve).** DSP is in `src/dsp/` (`biquad.rs`, `
|
|||||||
### Stage 5 — Basic egui UI *(basic version done early)*
|
### Stage 5 — Basic egui UI *(basic version done early)*
|
||||||
- [x] Add `nih_plug_egui` editor
|
- [x] Add `nih_plug_egui` editor
|
||||||
- [x] Sliders for all current parameters (`ParamSlider` grid)
|
- [x] Sliders for all current parameters (`ParamSlider` grid)
|
||||||
- [ ] Per-band bypass toggles *(partial — single-band bypass present; per-band arrives with Stage 3)*
|
- [x] Per-channel dry/wet mix (parallel compression; replaced the bypass toggle)
|
||||||
- [x] Confirm UI controls update DSP in real time
|
- [x] Confirm UI controls update DSP in real time
|
||||||
### Stage 6 — Custom visualisations
|
### Stage 6 — Custom visualisations
|
||||||
- [ ] `level_meter.rs` — input/output RMS + peak meters
|
- [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
|
||||||
- [ ] `band_meter.rs` — per-band gain reduction meters (vertical bars)
|
- [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
|
||||||
- [ ] `gain_curve.rs` — static gain curve display per band (threshold/ratio/knee)
|
- [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector)
|
||||||
- [ ] Draggable crossover handles on a frequency display
|
- [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
|
## Key Implementation Notes
|
||||||
@@ -240,8 +265,16 @@ far below audibility. We therefore do **not** set the register ourselves or flus
|
|||||||
NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise.
|
NIH-plug provides `Smoother` — use it for all gain/threshold params to avoid zipper noise.
|
||||||
|
|
||||||
### Thread safety
|
### Thread safety
|
||||||
Params are atomics. The editor and audio thread communicate only through params and
|
Params are atomics. The editor and audio thread communicate only through params and a shared
|
||||||
`Arc<Mutex<...>>` meter data. Never pass DSP state to the UI directly.
|
`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
|
### VST3 licensing
|
||||||
You must accept Steinberg's VST3 SDK licence before distributing VST3 binaries.
|
You must accept Steinberg's VST3 SDK licence before distributing VST3 binaries.
|
||||||
|
|||||||
+18
-7
@@ -74,11 +74,22 @@ if (Test-Path -LiteralPath '$clapSrc') { Copy-Item -LiteralPath '$clapSrc' -Dest
|
|||||||
Start-Process powershell -Verb RunAs -Wait -ArgumentList '-NoProfile','-EncodedCommand',$enc
|
Start-Process powershell -Verb RunAs -Wait -ArgumentList '-NoProfile','-EncodedCommand',$enc
|
||||||
}
|
}
|
||||||
|
|
||||||
$check = Join-Path $vst3Dst "$vst3Name\Contents\x86_64-win\$vst3Name"
|
# Verify each bundle was actually refreshed. A plugin currently loaded in a DAW keeps its
|
||||||
if (Test-Path $check) {
|
# binary locked, so the copy fails silently and leaves a STALE install — re-scanning then runs
|
||||||
Write-Host "VST3 installed OK -> $check" -ForegroundColor Green
|
# old code. Compare install vs build timestamps to catch exactly that.
|
||||||
Write-Host "In FL Studio: Manage plugins -> 'Rescan previously failed plugins' -> Find installed plugins." -ForegroundColor Yellow
|
function Test-Installed($label, $src, $dst) {
|
||||||
}
|
if (-not (Test-Path $src)) { return } # nothing was built for this format
|
||||||
else {
|
if (-not (Test-Path $dst)) { throw "$label install verification failed: $dst not found" }
|
||||||
throw "Install verification failed: $check not found"
|
$srcT = (Get-Item $src).LastWriteTime
|
||||||
|
$dstT = (Get-Item $dst).LastWriteTime
|
||||||
|
if ($dstT -lt $srcT) {
|
||||||
|
Write-Warning "$label is STALE (installed $dstT < built $srcT). It's almost certainly loaded in your DAW (file locked). Close the plugin/DAW and re-run deploy."
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "$label installed OK -> $dst" -ForegroundColor Green
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Test-Installed "VST3" (Join-Path $vst3Src "Contents\x86_64-win\$vst3Name") (Join-Path $vst3Dst "$vst3Name\Contents\x86_64-win\$vst3Name")
|
||||||
|
Test-Installed "CLAP" $clapSrc (Join-Path $clapDst $clapName)
|
||||||
|
Write-Host "In FL Studio: Manage plugins -> 'Rescan previously failed plugins' -> Find installed plugins." -ForegroundColor Yellow
|
||||||
|
|||||||
+119
-36
@@ -26,6 +26,16 @@ const MAX_CHANNELS: usize = 2;
|
|||||||
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels.
|
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels.
|
||||||
const LEVEL_EPS: f32 = 1e-12;
|
const LEVEL_EPS: f32 = 1e-12;
|
||||||
|
|
||||||
|
/// Silence-floor anchor for the below-threshold shaping: at/below this level the gain change is 0
|
||||||
|
/// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches
|
||||||
|
/// the editor gain-curve's display floor.
|
||||||
|
const LOW_ANCHOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
/// Max bulge (dB) the low-shaper curvature adds at the MIDDLE of the low region, at `|low_curve|`=1.
|
||||||
|
/// Bipolar: positive bulges up (boost the quiet middle), negative bulges down (suppress). Zero at
|
||||||
|
/// both ends (silence floor and the knee), so it never moves those anchors.
|
||||||
|
const LOW_BULGE_MAX_DB: f32 = 12.0;
|
||||||
|
|
||||||
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
/// Hardcoded RMS averaging window (one-pole time constant). Deliberately small; can be
|
||||||
/// promoted to a parameter later.
|
/// promoted to a parameter later.
|
||||||
const RMS_WINDOW_MS: f32 = 5.0;
|
const RMS_WINDOW_MS: f32 = 5.0;
|
||||||
@@ -40,6 +50,11 @@ pub struct CompressorSettings {
|
|||||||
pub threshold_db: f32,
|
pub threshold_db: f32,
|
||||||
pub ratio: f32,
|
pub ratio: f32,
|
||||||
pub knee_db: f32,
|
pub knee_db: f32,
|
||||||
|
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
|
||||||
|
/// Reshapes the level the compressor sees (serial), anchored at the floor.
|
||||||
|
pub low_slope: f32,
|
||||||
|
/// Low shaper curvature, 0..1 (0 = straight line, 1 = max bend toward bounded saturation).
|
||||||
|
pub low_curve: f32,
|
||||||
/// One-pole coefficient for the attack ramp (see [`Compressor::time_to_coef`]).
|
/// One-pole coefficient for the attack ramp (see [`Compressor::time_to_coef`]).
|
||||||
pub attack_coef: f32,
|
pub attack_coef: f32,
|
||||||
/// One-pole coefficient for the release ramp.
|
/// One-pole coefficient for the release ramp.
|
||||||
@@ -50,7 +65,9 @@ pub struct CompressorSettings {
|
|||||||
pub lookahead_samples: usize,
|
pub lookahead_samples: usize,
|
||||||
/// `true` = RMS detection (running power average), `false` = naive sample peak.
|
/// `true` = RMS detection (running power average), `false` = naive sample peak.
|
||||||
pub use_rms: bool,
|
pub use_rms: bool,
|
||||||
pub bypass: bool,
|
/// Dry/wet blend, 0..=1. 1 = fully compressed (incl. makeup), 0 = dry passthrough (bypass).
|
||||||
|
/// Parallel: dry and wet share the same delayed input, so the mix is phase-aligned.
|
||||||
|
pub mix: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Compressor {
|
pub struct Compressor {
|
||||||
@@ -67,7 +84,8 @@ pub struct Compressor {
|
|||||||
mean_sq: f32,
|
mean_sq: f32,
|
||||||
rms_coef: f32,
|
rms_coef: f32,
|
||||||
|
|
||||||
/// Smooth decoupled peak-detector state, expressed as dB of **attenuation** (>= 0).
|
/// Smooth decoupled peak-detector state, in dB of attenuation (signed: usually >= 0, but can go
|
||||||
|
/// negative = boost when `low_slope < 1`). The `max()` recurrence makes cut fast / boost slow.
|
||||||
y1: f32, // release branch (peak-with-decay)
|
y1: f32, // release branch (peak-with-decay)
|
||||||
yl: f32, // attack-smoothed output
|
yl: f32, // attack-smoothed output
|
||||||
}
|
}
|
||||||
@@ -129,29 +147,68 @@ impl Compressor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Static compressor curve. Returns gain reduction in dB (<= 0) for an input `level_db`.
|
/// Pure compressor transfer (threshold / ratio / quadratic soft knee). Returns gain reduction
|
||||||
/// Quadratic soft knee of width `knee_db`, centred on `threshold_db`.
|
/// in dB (<= 0) for an input `level_db`.
|
||||||
fn gain_computer(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
|
fn comp_gain_db(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
|
||||||
let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1
|
let slope = 1.0 / ratio - 1.0; // <= 0 for ratio >= 1
|
||||||
let over = level_db - threshold_db;
|
let over = level_db - threshold_db;
|
||||||
|
|
||||||
if knee_db > 0.0 && 2.0 * over.abs() <= knee_db {
|
if knee_db > 0.0 && 2.0 * over.abs() <= knee_db {
|
||||||
// Inside the knee: a parabola joining the two regions with a continuous slope.
|
|
||||||
let x = over + knee_db * 0.5; // 0..knee
|
let x = over + knee_db * 0.5; // 0..knee
|
||||||
slope * x * x / (2.0 * knee_db)
|
slope * x * x / (2.0 * knee_db)
|
||||||
} else if over > 0.0 {
|
} else if over > 0.0 {
|
||||||
// Above the knee (also covers the hard-knee case): linear region.
|
|
||||||
slope * over
|
slope * over
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Low-level shaper gain in dB. Anchored at BOTH the silence floor ([`LOW_ANCHOR_DB`]) and the
|
||||||
|
/// knee (threshold). `low_slope` tilts the straight line between those anchors (1 = unity);
|
||||||
|
/// `low_curve` (-1..1) bulges that line in the middle without moving either endpoint — positive
|
||||||
|
/// bulges up (boost the quiet middle), negative down (suppress). Reshapes the level the
|
||||||
|
/// compressor then sees.
|
||||||
|
fn low_gain_db(level_db: f32, threshold_db: f32, low_slope: f32, low_curve: f32) -> f32 {
|
||||||
|
let d = level_db - LOW_ANCHOR_DB;
|
||||||
|
if d <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let span = (threshold_db - LOW_ANCHOR_DB).max(1.0); // floor -> threshold width
|
||||||
|
let t = (d / span).min(1.0); // normalized position, clamped at the knee
|
||||||
|
// Straight line anchored at the floor (t=0 -> 0) and the knee (t=1 -> (slope-1)*span).
|
||||||
|
let slope_line = (low_slope - 1.0) * span * t;
|
||||||
|
// Bipolar bulge: 0 at both ends, peaks (4·t·(1-t) = 1) at the middle.
|
||||||
|
let bulge = low_curve * LOW_BULGE_MAX_DB * 4.0 * t * (1.0 - t);
|
||||||
|
slope_line + bulge
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full static curve, **serial**: the low shaper reshapes the level, then the compressor's
|
||||||
|
/// threshold sees the shaped level. Returns total gain in dB (signed: negative = cut, positive
|
||||||
|
/// = boost). `gain = low + comp(level + low)`. Shared with the editor's gain-curve display —
|
||||||
|
/// single source of truth.
|
||||||
|
pub fn gain_computer(
|
||||||
|
level_db: f32,
|
||||||
|
threshold_db: f32,
|
||||||
|
ratio: f32,
|
||||||
|
knee_db: f32,
|
||||||
|
low_slope: f32,
|
||||||
|
low_curve: f32,
|
||||||
|
) -> f32 {
|
||||||
|
let low = Self::low_gain_db(level_db, threshold_db, low_slope, low_curve);
|
||||||
|
low + Self::comp_gain_db(level_db + low, threshold_db, ratio, knee_db)
|
||||||
|
}
|
||||||
|
|
||||||
/// The plugin's fixed reported latency in samples (the constant audio delay).
|
/// The plugin's fixed reported latency in samples (the constant audio delay).
|
||||||
pub fn latency(&self) -> u32 {
|
pub fn latency(&self) -> u32 {
|
||||||
self.fixed_delay as u32
|
self.fixed_delay as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current gain reduction being applied, in dB (>= 0), excluding makeup. For metering.
|
||||||
|
/// This is the smoothed detector output `yl`, so it tracks the visible needle, not the
|
||||||
|
/// instantaneous static curve.
|
||||||
|
pub fn gain_reduction_db(&self) -> f32 {
|
||||||
|
self.yl
|
||||||
|
}
|
||||||
|
|
||||||
/// Process one sample frame in place: `input[ch]` -> `output[ch]`.
|
/// Process one sample frame in place: `input[ch]` -> `output[ch]`.
|
||||||
///
|
///
|
||||||
/// `input` and `output` are short stack slices (one value per channel), so this
|
/// `input` and `output` are short stack slices (one value per channel), so this
|
||||||
@@ -181,32 +238,37 @@ impl Compressor {
|
|||||||
peak = peak.max(self.delay[ch][det_pos].abs());
|
peak = peak.max(self.delay[ch][det_pos].abs());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Gain computer + ballistics. On bypass we keep the delay aligned (so toggling
|
// 4) Gain computer + ballistics. The detector ALWAYS runs (even at mix 0) so metering
|
||||||
// bypass doesn't shift timing) but apply unity gain and no makeup.
|
// reflects the wet gain reduction regardless of the dry/wet blend.
|
||||||
let gain_lin = if set.bypass {
|
// RMS = running mean of the linked squared level over a fixed window. Updated whenever
|
||||||
1.0
|
// active (regardless of mode) so switching peak<->RMS is seamless.
|
||||||
} else {
|
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
|
||||||
// RMS = running mean of the linked squared level over a fixed window. Updated
|
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
||||||
// whenever active (regardless of mode) so switching peak<->RMS is seamless.
|
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
|
||||||
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
|
// Desired attenuation in dB, as a positive quantity.
|
||||||
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
let target = -Self::gain_computer(
|
||||||
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
|
level_db,
|
||||||
// Desired attenuation in dB, as a positive quantity.
|
set.threshold_db,
|
||||||
let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db);
|
set.ratio,
|
||||||
|
set.knee_db,
|
||||||
|
set.low_slope,
|
||||||
|
set.low_curve,
|
||||||
|
);
|
||||||
|
|
||||||
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation:
|
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation:
|
||||||
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
|
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
|
||||||
// yl = attack-smoothed y1
|
// yl = attack-smoothed y1
|
||||||
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target);
|
self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target);
|
||||||
self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
|
self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1;
|
||||||
|
|
||||||
let total_db = set.makeup_db - self.yl;
|
let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0);
|
||||||
10.0f32.powf(total_db / 20.0)
|
|
||||||
};
|
|
||||||
|
|
||||||
// 5) Output = delayed input (always `fixed_delay` old) * gain.
|
// 5) Dry/wet mix (parallel compression). Both paths use the same delayed input, so the
|
||||||
|
// blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet.
|
||||||
|
let mix = set.mix.clamp(0.0, 1.0);
|
||||||
|
let blend = (1.0 - mix) + mix * wet_gain;
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
output[ch] = self.delay[ch][out_pos] * gain_lin;
|
output[ch] = self.delay[ch][out_pos] * blend;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6) Advance the write head.
|
// 6) Advance the write head.
|
||||||
@@ -234,20 +296,41 @@ mod tests {
|
|||||||
makeup_db: 0.0,
|
makeup_db: 0.0,
|
||||||
lookahead_samples: 0,
|
lookahead_samples: 0,
|
||||||
use_rms: false,
|
use_rms: false,
|
||||||
bypass: false,
|
mix: 1.0,
|
||||||
|
low_slope: 1.0,
|
||||||
|
low_curve: 0.0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn below_threshold_is_untouched() {
|
fn below_threshold_is_untouched() {
|
||||||
// -30 dB input, -20 dB threshold -> no reduction.
|
// -30 dB input, -20 dB threshold -> no reduction.
|
||||||
assert_eq!(Compressor::gain_computer(-30.0, -20.0, 4.0, 6.0), 0.0);
|
assert_eq!(Compressor::gain_computer(-30.0, -20.0, 4.0, 6.0, 1.0, 0.0), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn low_shaper_serial_slope_and_bipolar_bulge() {
|
||||||
|
let thr = -18.0;
|
||||||
|
// Serial slope-only (no curve): boost lifts -30 by 30 dB to 0 dB -> 18 dB over threshold,
|
||||||
|
// comp pulls back (1/4 - 1)*18 = -13.5 -> net 16.5.
|
||||||
|
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
|
||||||
|
// Cut (slope 0.5) -> -15 dB; shaped to -45, still below threshold -> net -15.
|
||||||
|
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3);
|
||||||
|
|
||||||
|
// Curvature is a BIPOLAR bulge at the middle of the low region (unity slope here).
|
||||||
|
let mid = -39.0; // middle of [-60, -18]
|
||||||
|
let flat = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 0.0);
|
||||||
|
let up = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, 1.0);
|
||||||
|
let down = Compressor::gain_computer(mid, thr, 4.0, 0.0, 1.0, -1.0);
|
||||||
|
assert!(up > flat && flat > down, "bipolar bulge expected: {down} < {flat} < {up}");
|
||||||
|
// Endpoints are unaffected by curvature (silence anchored).
|
||||||
|
assert_close(Compressor::gain_computer(-60.0, thr, 4.0, 0.0, 1.0, 1.0), 0.0, 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn above_knee_follows_ratio() {
|
fn above_knee_follows_ratio() {
|
||||||
// 10 dB over threshold at 4:1 -> output only 2.5 dB over -> 7.5 dB reduction.
|
// 10 dB over threshold at 4:1 -> output only 2.5 dB over -> 7.5 dB reduction.
|
||||||
let r = Compressor::gain_computer(-10.0, -20.0, 4.0, 0.0);
|
let r = Compressor::gain_computer(-10.0, -20.0, 4.0, 0.0, 1.0, 0.0);
|
||||||
assert_close(r, -7.5, 1e-4);
|
assert_close(r, -7.5, 1e-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,11 +339,11 @@ mod tests {
|
|||||||
// At the upper knee edge the soft-knee and linear formulas must agree.
|
// At the upper knee edge the soft-knee and linear formulas must agree.
|
||||||
let (t, ratio, knee) = (0.0, 4.0, 6.0);
|
let (t, ratio, knee) = (0.0, 4.0, 6.0);
|
||||||
let edge = t + knee / 2.0;
|
let edge = t + knee / 2.0;
|
||||||
let knee_val = Compressor::gain_computer(edge, t, ratio, knee);
|
let knee_val = Compressor::gain_computer(edge, t, ratio, knee, 1.0, 0.0);
|
||||||
let linear_val = (1.0 / ratio - 1.0) * (edge - t);
|
let linear_val = (1.0 / ratio - 1.0) * (edge - t);
|
||||||
assert_close(knee_val, linear_val, 1e-4);
|
assert_close(knee_val, linear_val, 1e-4);
|
||||||
// At the lower edge there is still no reduction.
|
// At the lower edge there is still no reduction.
|
||||||
assert_close(Compressor::gain_computer(t - knee / 2.0, t, ratio, knee), 0.0, 1e-6);
|
assert_close(Compressor::gain_computer(t - knee / 2.0, t, ratio, knee, 1.0, 0.0), 0.0, 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -329,7 +412,7 @@ mod tests {
|
|||||||
for &l in &[0usize, d / 2, d] {
|
for &l in &[0usize, d / 2, d] {
|
||||||
comp.reset();
|
comp.reset();
|
||||||
let mut set = settings(0.0, 1.0, 0.0);
|
let mut set = settings(0.0, 1.0, 0.0);
|
||||||
set.bypass = true; // unity gain -> isolate the delay behaviour
|
set.mix = 0.0; // dry passthrough -> isolate the delay behaviour
|
||||||
set.lookahead_samples = l;
|
set.lookahead_samples = l;
|
||||||
|
|
||||||
let mut out = [0.0f32];
|
let mut out = [0.0f32];
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ impl Limiter {
|
|||||||
self.fixed_delay as u32
|
self.fixed_delay as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Current limiter gain reduction in dB (>= 0). `gain` is linear (<= 1); expressed here as a
|
||||||
|
/// positive dB amount for the ceiling lamp / metering.
|
||||||
|
pub fn gain_reduction_db(&self) -> f32 {
|
||||||
|
-20.0 * self.gain.max(1e-9).log10()
|
||||||
|
}
|
||||||
|
|
||||||
/// Limit one frame in place: `input[ch]` -> `output[ch]`.
|
/// Limit one frame in place: `input[ch]` -> `output[ch]`.
|
||||||
///
|
///
|
||||||
/// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a
|
/// `ceiling` is linear gain (e.g. `util::db_to_gain(ceiling_db)`); `release_coef` comes from a
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
//! DSP building blocks for Codename 206.
|
//! DSP building blocks for Codename 206.
|
||||||
//!
|
//!
|
||||||
//! Stage 2 introduces the full-band compressor (also the engine that will be reused
|
//! The signal chain: a `crossover` filterbank splits into bands, each band (plus the summed
|
||||||
//! per band and for the 'All' aggregate channel — see README.md). Later stages add the
|
//! 'All' channel) runs a `compressor`, and a `limiter` (with a true-peak `oversampler` detector)
|
||||||
//! crossover filterbank, output limiter, and oversampler alongside it.
|
//! is the final stage. `biquad` is the shared filter primitive the crossover is built from.
|
||||||
|
|
||||||
pub mod biquad;
|
pub mod biquad;
|
||||||
pub mod compressor;
|
pub mod compressor;
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
//! Draggable crossover handles — replaces the two plain crossover sliders.
|
||||||
|
//!
|
||||||
|
//! A horizontal log-frequency strip split into LOW / MID / HIGH by two draggable handles, plus a
|
||||||
|
//! number box per crossover (double-click to type an exact value). The handles enforce a dynamic
|
||||||
|
//! limit: lo/mid can never exceed mid/hi (and vice-versa), on top of each param's own range.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::egui::{
|
||||||
|
self, pos2, vec2, Align2, Color32, CornerRadius, DragValue, FontId, Painter, Rect, Sense, Stroke,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::params::Codename206Params;
|
||||||
|
|
||||||
|
/// Height of the handle strip.
|
||||||
|
const GRAPH_H: f32 = 54.0;
|
||||||
|
/// Displayed frequency axis (log), independent of the params' own ranges.
|
||||||
|
const DISP_MIN_HZ: f32 = 20.0;
|
||||||
|
const DISP_MAX_HZ: f32 = 20_000.0;
|
||||||
|
|
||||||
|
fn log_span() -> f32 {
|
||||||
|
DISP_MAX_HZ.ln() - DISP_MIN_HZ.ln()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_hz(hz: f32) -> String {
|
||||||
|
if hz >= 1000.0 {
|
||||||
|
format!("{:.2} kHz", hz / 1000.0)
|
||||||
|
} else {
|
||||||
|
format!("{:.0} Hz", hz)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, setter: &ParamSetter) {
|
||||||
|
let lo = params.crossover_low_hz.value();
|
||||||
|
let hi = params.crossover_high_hz.value();
|
||||||
|
// Each param's own min/max (normalized 0/1 map to the range ends).
|
||||||
|
let lo_min = params.crossover_low_hz.preview_plain(0.0);
|
||||||
|
let lo_max = params.crossover_low_hz.preview_plain(1.0);
|
||||||
|
let hi_min = params.crossover_high_hz.preview_plain(0.0);
|
||||||
|
let hi_max = params.crossover_high_hz.preview_plain(1.0);
|
||||||
|
// Dynamic limits so the two never cross: lo <= hi.
|
||||||
|
let lo_upper = lo_max.min(hi);
|
||||||
|
let hi_lower = hi_min.max(lo);
|
||||||
|
|
||||||
|
ui.label("Crossover");
|
||||||
|
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), GRAPH_H), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
let (left, right, top, bottom) =
|
||||||
|
(rect.left() + 2.0, rect.right() - 2.0, rect.top() + 2.0, rect.bottom() - 2.0);
|
||||||
|
let width = right - left;
|
||||||
|
let x_for = |hz: f32| left + (hz.max(1.0).ln() - DISP_MIN_HZ.ln()) / log_span() * width;
|
||||||
|
let xlo = x_for(lo);
|
||||||
|
let xhi = x_for(hi);
|
||||||
|
|
||||||
|
// Three band regions.
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(left, top), pos2(xlo, bottom)), CornerRadius::ZERO, Color32::from_rgb(28, 38, 52));
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(xlo, top), pos2(xhi, bottom)), CornerRadius::ZERO, Color32::from_rgb(30, 48, 36));
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(xhi, top), pos2(right, bottom)), CornerRadius::ZERO, Color32::from_rgb(52, 38, 30));
|
||||||
|
|
||||||
|
let band_label = |cx: f32, t: &str| {
|
||||||
|
p.text(pos2(cx, top + 2.0), Align2::CENTER_TOP, t, FontId::proportional(11.0), Color32::from_gray(160));
|
||||||
|
};
|
||||||
|
band_label((left + xlo) * 0.5, "LOW");
|
||||||
|
band_label((xlo + xhi) * 0.5, "MID");
|
||||||
|
band_label((xhi + right) * 0.5, "HIGH");
|
||||||
|
|
||||||
|
handle(ui, &p, setter, "xover_lo", xlo, lo, ¶ms.crossover_low_hz, lo_min, lo_upper, left, width, top, bottom, Color32::from_rgb(120, 170, 230));
|
||||||
|
handle(ui, &p, setter, "xover_hi", xhi, hi, ¶ms.crossover_high_hz, hi_lower, hi_max, left, width, top, bottom, Color32::from_rgb(230, 150, 120));
|
||||||
|
|
||||||
|
// Number boxes (double-click to type). Clamped to the same dynamic limits.
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Lo/Mid");
|
||||||
|
let mut v = lo as f64;
|
||||||
|
if ui
|
||||||
|
.add(DragValue::new(&mut v).range(lo_min as f64..=lo_upper as f64).speed(0.5).suffix(" Hz"))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
set_clamped(setter, ¶ms.crossover_low_hz, v as f32, lo_min, lo_upper);
|
||||||
|
}
|
||||||
|
ui.add_space(16.0);
|
||||||
|
ui.label("Mid/Hi");
|
||||||
|
let mut v = hi as f64;
|
||||||
|
if ui
|
||||||
|
.add(DragValue::new(&mut v).range(hi_lower as f64..=hi_max as f64).speed(2.0).suffix(" Hz"))
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
set_clamped(setter, ¶ms.crossover_high_hz, v as f32, hi_lower, hi_max);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One draggable vertical handle. Drives `param` from the pointer's x (log-frequency), clamped to
|
||||||
|
/// `[min, max]` (which already encodes the dynamic lo<=hi limit), with proper begin/end gestures.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle(
|
||||||
|
ui: &egui::Ui,
|
||||||
|
p: &Painter,
|
||||||
|
setter: &ParamSetter,
|
||||||
|
id_salt: &str,
|
||||||
|
x: f32,
|
||||||
|
hz: f32,
|
||||||
|
param: &FloatParam,
|
||||||
|
min: f32,
|
||||||
|
max: f32,
|
||||||
|
left: f32,
|
||||||
|
width: f32,
|
||||||
|
top: f32,
|
||||||
|
bottom: f32,
|
||||||
|
color: Color32,
|
||||||
|
) {
|
||||||
|
let hit = Rect::from_min_max(pos2(x - 5.0, top), pos2(x + 5.0, bottom));
|
||||||
|
let resp = ui
|
||||||
|
.interact(hit, ui.id().with(id_salt), Sense::drag())
|
||||||
|
.on_hover_cursor(egui::CursorIcon::ResizeHorizontal);
|
||||||
|
|
||||||
|
if resp.drag_started() {
|
||||||
|
setter.begin_set_parameter(param);
|
||||||
|
}
|
||||||
|
if resp.dragged() {
|
||||||
|
if let Some(pos) = resp.interact_pointer_pos() {
|
||||||
|
let frac = ((pos.x - left) / width).clamp(0.0, 1.0);
|
||||||
|
let new = (DISP_MIN_HZ.ln() + frac * log_span()).exp().clamp(min, max);
|
||||||
|
setter.set_parameter(param, new);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if resp.drag_stopped() {
|
||||||
|
setter.end_set_parameter(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
let col = if resp.dragged() || resp.hovered() { Color32::WHITE } else { color };
|
||||||
|
p.line_segment([pos2(x, top), pos2(x, bottom)], Stroke::new(2.0, col));
|
||||||
|
p.text(pos2(x, bottom - 1.0), Align2::CENTER_BOTTOM, fmt_hz(hz), FontId::proportional(10.0), Color32::from_gray(220));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a param to `value` clamped to `[min, max]`, wrapped in its own gesture (for the number box).
|
||||||
|
fn set_clamped(setter: &ParamSetter, param: &FloatParam, value: f32, min: f32, max: f32) {
|
||||||
|
setter.begin_set_parameter(param);
|
||||||
|
setter.set_parameter(param, value.clamp(min, max));
|
||||||
|
setter.end_set_parameter(param);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
//! Static gain-curve display: output level vs input level for the channel currently selected in
|
||||||
|
//! the plot. Plots the wet transfer `out = (in + pre_gain) + gain_reduction(...) + makeup` (mix not
|
||||||
|
//! folded in; output clamped at 0 dBFS), plus a live **operating-point fill** under the curve up to
|
||||||
|
//! the channel's current input level — its right edge rides the curve (width = input, height = out).
|
||||||
|
|
||||||
|
use nih_plug::prelude::util;
|
||||||
|
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Sense, Stroke};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use crate::dsp::compressor::Compressor;
|
||||||
|
use crate::meters::Meters;
|
||||||
|
use crate::params::Codename206Params;
|
||||||
|
|
||||||
|
/// Side length of the square plot.
|
||||||
|
const CURVE_SIZE: f32 = 150.0;
|
||||||
|
/// dB extent of both axes (bottom/left = FLOOR_DB, top/right = 0 dBFS).
|
||||||
|
const FLOOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usize, meters: &Meters) {
|
||||||
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
||||||
|
let ch = selected.min(3);
|
||||||
|
let cp = match ch {
|
||||||
|
0 => ¶ms.low,
|
||||||
|
1 => ¶ms.mid,
|
||||||
|
2 => ¶ms.high,
|
||||||
|
_ => ¶ms.all,
|
||||||
|
};
|
||||||
|
let pre = cp.pre_gain_db.value();
|
||||||
|
let threshold = cp.threshold_db.value();
|
||||||
|
let ratio = cp.ratio.value();
|
||||||
|
let knee = cp.knee_db.value();
|
||||||
|
let low_slope = cp.low_slope.value();
|
||||||
|
let low_curve = cp.low_curve.value();
|
||||||
|
let makeup = cp.makeup_db.value();
|
||||||
|
|
||||||
|
ui.label(format!("Curve: {}", labels[ch]));
|
||||||
|
let (rect, _) = ui.allocate_exact_size(vec2(CURVE_SIZE, CURVE_SIZE), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
|
||||||
|
|
||||||
|
let inset = 2.0;
|
||||||
|
let (left, right, top, bottom) =
|
||||||
|
(rect.left() + inset, rect.right() - inset, rect.top() + inset, rect.bottom() - inset);
|
||||||
|
let w = right - left;
|
||||||
|
let h = bottom - top;
|
||||||
|
let x_for = |db: f32| left + (db - FLOOR_DB) / -FLOOR_DB * w;
|
||||||
|
let y_for = |db: f32| bottom - (db - FLOOR_DB) / -FLOOR_DB * h;
|
||||||
|
|
||||||
|
// -6 dBFS reference lines on both axes.
|
||||||
|
let g6 = Color32::from_gray(38);
|
||||||
|
let x6 = x_for(-6.0);
|
||||||
|
let y6 = y_for(-6.0);
|
||||||
|
p.line_segment([pos2(x6, top), pos2(x6, bottom)], Stroke::new(1.0, g6));
|
||||||
|
p.line_segment([pos2(left, y6), pos2(right, y6)], Stroke::new(1.0, g6));
|
||||||
|
|
||||||
|
// Unity reference (out = in), bottom-left to top-right.
|
||||||
|
p.line_segment([pos2(left, bottom), pos2(right, top)], Stroke::new(1.0, Color32::from_gray(45)));
|
||||||
|
// Threshold marker on the input axis — shifted left by pre-gain (the comp sees in + pre).
|
||||||
|
let tx = x_for((threshold - pre).clamp(FLOOR_DB, 0.0));
|
||||||
|
p.line_segment([pos2(tx, top), pos2(tx, bottom)], Stroke::new(1.0, Color32::from_rgb(80, 60, 45)));
|
||||||
|
|
||||||
|
// Full wet transfer: drive into the comp, then makeup. (Mix not folded in.)
|
||||||
|
let n = 96;
|
||||||
|
let mut pts = Vec::with_capacity(n + 1);
|
||||||
|
for i in 0..=n {
|
||||||
|
let in_db = FLOOR_DB + (i as f32 / n as f32) * -FLOOR_DB; // external input, -60..0
|
||||||
|
let driven = in_db + pre;
|
||||||
|
let gr = Compressor::gain_computer(driven, threshold, ratio, knee, low_slope, low_curve); // signed dB
|
||||||
|
let out_db = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
|
||||||
|
pts.push(pos2(x_for(in_db), y_for(out_db)));
|
||||||
|
}
|
||||||
|
// Operating-point fill: shade under the curve from the floor up to the current input level.
|
||||||
|
let driven_now = util::gain_to_db(meters.input_level[ch].load(Ordering::Relaxed));
|
||||||
|
let ext_in = (driven_now - pre).clamp(FLOOR_DB, 0.0); // external input -> curve x
|
||||||
|
let x_now = x_for(ext_in);
|
||||||
|
let fill_col = Color32::from_rgba_unmultiplied(120, 200, 160, 45);
|
||||||
|
for seg in pts.windows(2) {
|
||||||
|
let a = seg[0];
|
||||||
|
let mut b = seg[1];
|
||||||
|
if a.x >= x_now {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if b.x > x_now {
|
||||||
|
let f = ((x_now - a.x) / (b.x - a.x)).clamp(0.0, 1.0); // clip the last quad at x_now
|
||||||
|
b = pos2(x_now, a.y + (b.y - a.y) * f);
|
||||||
|
}
|
||||||
|
p.add(egui::Shape::convex_polygon(
|
||||||
|
vec![pos2(a.x, bottom), a, b, pos2(b.x, bottom)],
|
||||||
|
fill_col,
|
||||||
|
Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
p.add(egui::Shape::line(pts, Stroke::new(1.6, Color32::from_rgb(120, 200, 160))));
|
||||||
|
|
||||||
|
// Operating-point dot, on the curve at the current input.
|
||||||
|
let driven = ext_in + pre;
|
||||||
|
let gr = Compressor::gain_computer(driven, threshold, ratio, knee, low_slope, low_curve);
|
||||||
|
let out_op = (driven + gr + makeup).clamp(FLOOR_DB, 0.0);
|
||||||
|
p.circle_filled(pos2(x_now, y_for(out_op)), 3.0, Color32::from_rgb(235, 240, 235));
|
||||||
|
|
||||||
|
// Corner dB ticks + the -6 dB reference.
|
||||||
|
p.text(pos2(left + 1.0, top + 1.0), Align2::LEFT_TOP, "0", FontId::proportional(9.0), Color32::from_gray(90));
|
||||||
|
p.text(pos2(left + 1.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-60", FontId::proportional(9.0), Color32::from_gray(90));
|
||||||
|
p.text(pos2(x6 + 2.0, bottom - 1.0), Align2::LEFT_BOTTOM, "-6", FontId::proportional(9.0), Color32::from_gray(80));
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Per-channel level + gain-reduction meters, each with its own latching ceiling/over lamp.
|
||||||
|
//!
|
||||||
|
//! Each channel is a `|L | GR | R|` cluster (output level left/right, mono gain reduction in the
|
||||||
|
//! middle) topped by a lamp. The lamp latches when the channel's output reaches 0 dBFS (a hot /
|
||||||
|
//! "over" warning — useful when pre-gain drives a band hard); for the ALL channel it also lights
|
||||||
|
//! when the output limiter is actually catching peaks. It holds, then clears after `LAMP_HOLD_S`
|
||||||
|
//! or on a click. Fed by the lock-free [`Meters`] state the audio thread publishes each block.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::egui::{
|
||||||
|
self, pos2, vec2, Align2, Color32, CornerRadius, CursorIcon, FontId, Painter, Rect, Sense,
|
||||||
|
};
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use super::METER_FLOOR_DB;
|
||||||
|
use crate::meters::{Meters, NUM_CHANNELS};
|
||||||
|
|
||||||
|
/// Full-scale of the gain-reduction bar (fills downward from the top).
|
||||||
|
const GR_FULL_DB: f32 = 24.0;
|
||||||
|
/// Output level (dBFS) at/above which a channel's lamp latches on.
|
||||||
|
const OVER_DB: f32 = 0.0;
|
||||||
|
/// Limiter gain reduction (dB) above which the ALL channel's lamp also latches on.
|
||||||
|
const LAMP_TRIGGER_DB: f32 = 0.1;
|
||||||
|
/// How long a lamp stays lit after the most recent trigger (seconds).
|
||||||
|
const LAMP_HOLD_S: f64 = 3.0;
|
||||||
|
/// Height of the meter panel.
|
||||||
|
const METER_PANEL_H: f32 = 140.0;
|
||||||
|
|
||||||
|
/// GUI-side state for the meter panel: one lamp latch per channel.
|
||||||
|
pub(super) struct MeterState {
|
||||||
|
/// egui time (seconds) of each channel's most recent lamp trigger, while latched on.
|
||||||
|
/// `None` = lamp off (never triggered, expired, or dismissed by a click).
|
||||||
|
ceiling_trigger: [Option<f64>; NUM_CHANNELS],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for MeterState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { ceiling_trigger: [None; NUM_CHANNELS] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the meter panel: a `|L | GR | R|` cluster + a latching over/ceiling lamp per channel.
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut MeterState) {
|
||||||
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
||||||
|
let now = ui.ctx().input(|i| i.time);
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), METER_PANEL_H), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(20, 20, 24));
|
||||||
|
|
||||||
|
let top = rect.top() + 22.0; // leave a row at the top for the lamps
|
||||||
|
let bottom = rect.bottom() - 18.0; // and a row at the bottom for the labels
|
||||||
|
let cell_w = rect.width() / NUM_CHANNELS as f32;
|
||||||
|
// Three bars per cluster, so they're narrower than a two-bar layout.
|
||||||
|
let bar_w = (cell_w * 0.17).min(14.0);
|
||||||
|
let gap = (cell_w * 0.05).min(5.0);
|
||||||
|
|
||||||
|
for i in 0..NUM_CHANNELS {
|
||||||
|
let cell_left = rect.left() + i as f32 * cell_w;
|
||||||
|
let group_w = bar_w * 3.0 + gap * 2.0;
|
||||||
|
let bx = cell_left + (cell_w - group_w) * 0.5;
|
||||||
|
|
||||||
|
// L / R output level (upward); colour warns as it nears 0 dBFS.
|
||||||
|
let l_db = util::gain_to_db(meters.level_l[i].load(Ordering::Relaxed));
|
||||||
|
let r_db = util::gain_to_db(meters.level_r[i].load(Ordering::Relaxed));
|
||||||
|
let l_frac = ((l_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
||||||
|
let r_frac = ((r_db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
// Mono gain reduction (downward from the top).
|
||||||
|
let gr_db = meters.gain_reduction_db[i].load(Ordering::Relaxed);
|
||||||
|
let gr_frac = (gr_db / GR_FULL_DB).clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
v_bar(&p, bx, bar_w, top, bottom, l_frac, level_color(l_db), false);
|
||||||
|
v_bar(&p, bx + bar_w + gap, bar_w, top, bottom, gr_frac, Color32::from_rgb(240, 150, 60), true);
|
||||||
|
v_bar(&p, bx + 2.0 * (bar_w + gap), bar_w, top, bottom, r_frac, level_color(r_db), false);
|
||||||
|
|
||||||
|
p.text(
|
||||||
|
pos2(cell_left + cell_w * 0.5, rect.bottom() - 2.0),
|
||||||
|
Align2::CENTER_BOTTOM,
|
||||||
|
labels[i],
|
||||||
|
FontId::proportional(12.0),
|
||||||
|
Color32::from_gray(200),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-channel lamp: latch on output reaching 0 dBFS; the ALL channel also latches when the
|
||||||
|
// output limiter is catching peaks (the true master-ceiling event).
|
||||||
|
let over_db = l_db.max(r_db);
|
||||||
|
let mut triggered = over_db >= OVER_DB;
|
||||||
|
if i == NUM_CHANNELS - 1 {
|
||||||
|
triggered |= meters.limiter_gr_db.load(Ordering::Relaxed) > LAMP_TRIGGER_DB;
|
||||||
|
}
|
||||||
|
if triggered {
|
||||||
|
state.ceiling_trigger[i] = Some(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
let lamp_center = pos2(cell_left + cell_w * 0.5, rect.top() + 11.0);
|
||||||
|
let lamp_rect = Rect::from_center_size(lamp_center, vec2(18.0, 18.0));
|
||||||
|
let resp = ui
|
||||||
|
.interact(lamp_rect, ui.id().with(("ceiling_lamp", i)), Sense::click())
|
||||||
|
.on_hover_cursor(CursorIcon::PointingHand)
|
||||||
|
.on_hover_text("Output reached 0 dBFS — click to clear");
|
||||||
|
if resp.clicked() {
|
||||||
|
state.ceiling_trigger[i] = None;
|
||||||
|
}
|
||||||
|
if let Some(t) = state.ceiling_trigger[i] {
|
||||||
|
if now - t >= LAMP_HOLD_S {
|
||||||
|
state.ceiling_trigger[i] = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let lamp = if state.ceiling_trigger[i].is_some() {
|
||||||
|
Color32::from_rgb(255, 40, 40)
|
||||||
|
} else {
|
||||||
|
Color32::from_rgb(40, 12, 12)
|
||||||
|
};
|
||||||
|
p.circle_filled(lamp_center, 5.0, lamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw a vertical bar within `[top, bottom]`. `frac` is 0..1; `from_top` fills downward from the
|
||||||
|
/// top (gain reduction) instead of upward from the bottom (level).
|
||||||
|
fn v_bar(p: &Painter, x: f32, w: f32, top: f32, bottom: f32, frac: f32, fill: Color32, from_top: bool) {
|
||||||
|
let track = Color32::from_rgb(34, 34, 40);
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + w, bottom)), CornerRadius::ZERO, track);
|
||||||
|
|
||||||
|
let h = (bottom - top) * frac.clamp(0.0, 1.0);
|
||||||
|
let filled = if from_top {
|
||||||
|
Rect::from_min_max(pos2(x, top), pos2(x + w, top + h))
|
||||||
|
} else {
|
||||||
|
Rect::from_min_max(pos2(x, bottom - h), pos2(x + w, bottom))
|
||||||
|
};
|
||||||
|
p.rect_filled(filled, CornerRadius::ZERO, fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Level-bar colour: green below -6 dB, yellow approaching, red near 0 dBFS.
|
||||||
|
fn level_color(db: f32) -> Color32 {
|
||||||
|
if db >= -1.0 {
|
||||||
|
Color32::from_rgb(235, 70, 60)
|
||||||
|
} else if db >= -6.0 {
|
||||||
|
Color32::from_rgb(230, 200, 70)
|
||||||
|
} else {
|
||||||
|
Color32::from_rgb(90, 200, 110)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
//! egui editor: assembly + control layout.
|
||||||
|
//!
|
||||||
|
//! The aggregator. Builds the editor window and lays out the heading, the meter panel
|
||||||
|
//! ([`meter`]), the rolling plot ([`plot`]), and the (placeholder) per-channel slider columns.
|
||||||
|
//! Each visualiser owns its GUI state and drawing in its submodule; this module wires them
|
||||||
|
//! together and holds the shared [`EditorState`]. When the UI is redesigned the slider columns
|
||||||
|
//! get replaced and the visualisers stay as self-contained widgets.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::{
|
||||||
|
create_egui_editor,
|
||||||
|
egui::{self, Vec2},
|
||||||
|
resizable_window::ResizableWindow,
|
||||||
|
widgets,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::meters::Meters;
|
||||||
|
use crate::params::{Codename206Params, CompressorParams};
|
||||||
|
use crate::Codename206;
|
||||||
|
|
||||||
|
mod crossover;
|
||||||
|
mod gain_curve;
|
||||||
|
mod meter;
|
||||||
|
mod plot;
|
||||||
|
|
||||||
|
/// Bottom of the dB scale shared by the meters and the plot (top is 0 dBFS).
|
||||||
|
const METER_FLOOR_DB: f32 = -60.0;
|
||||||
|
|
||||||
|
/// GUI-side editor state (not persisted): the per-widget state for the meter panel and the plot.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct EditorState {
|
||||||
|
meter: meter::MeterState,
|
||||||
|
plot: plot::PlotState,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the plugin editor over shared handles to the params and meter state.
|
||||||
|
pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Option<Box<dyn Editor>> {
|
||||||
|
let egui_state = params.editor_state.clone();
|
||||||
|
create_egui_editor(
|
||||||
|
params.editor_state.clone(),
|
||||||
|
EditorState::default(),
|
||||||
|
|_, _| {},
|
||||||
|
move |egui_ctx, setter, state| {
|
||||||
|
// Keep frames coming so the meters animate and the lamp can time out while open.
|
||||||
|
egui_ctx.request_repaint();
|
||||||
|
|
||||||
|
// One column of controls for a single compressor channel (placeholder layout).
|
||||||
|
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| {
|
||||||
|
// Roughly in signal order: input drive -> low shaper -> compressor -> output.
|
||||||
|
ui.strong(title);
|
||||||
|
ui.label("Pre-gain");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter));
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.detection, setter));
|
||||||
|
ui.label("Low Slope");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.low_slope, setter));
|
||||||
|
ui.label("Low Curve");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.low_curve, setter));
|
||||||
|
ui.label("Threshold");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter));
|
||||||
|
ui.label("Ratio");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.ratio, setter));
|
||||||
|
ui.label("Knee");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter));
|
||||||
|
ui.label("Attack");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter));
|
||||||
|
ui.label("Release");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter));
|
||||||
|
ui.label("Makeup");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter));
|
||||||
|
ui.label("Mix");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(&p.mix, setter));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resizable window; vertical scroll so every control stays reachable even when the
|
||||||
|
// window is small. (Placeholder layout — the redesign will replace the slider columns.)
|
||||||
|
ResizableWindow::new("editor")
|
||||||
|
.min_size(Vec2::new(480.0, 320.0))
|
||||||
|
.show(egui_ctx, egui_state.as_ref(), |ui| {
|
||||||
|
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||||
|
ui.heading(Codename206::NAME);
|
||||||
|
meter::draw(ui, &meters, &mut state.meter);
|
||||||
|
ui.separator();
|
||||||
|
// Gain curve (left, square) beside the scrolling plot (right, fills the rest).
|
||||||
|
let selected = state.plot.selected;
|
||||||
|
ui.horizontal_top(|ui| {
|
||||||
|
ui.vertical(|ui| gain_curve::draw(ui, ¶ms, selected, &meters));
|
||||||
|
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
crossover::draw(ui, ¶ms, setter);
|
||||||
|
ui.separator();
|
||||||
|
// Global controls stacked vertically so they never overflow sideways.
|
||||||
|
egui::Grid::new("globals").num_columns(2).show(ui, |ui| {
|
||||||
|
ui.label("Look-ahead");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Ceiling");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter));
|
||||||
|
ui.end_row();
|
||||||
|
ui.label("Lim Release");
|
||||||
|
ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter));
|
||||||
|
ui.end_row();
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
ui.columns(4, |cols| {
|
||||||
|
band_col(&mut cols[0], "LOW", ¶ms.low);
|
||||||
|
band_col(&mut cols[1], "MID", ¶ms.mid);
|
||||||
|
band_col(&mut cols[2], "HIGH", ¶ms.high);
|
||||||
|
band_col(&mut cols[3], "ALL", ¶ms.all);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
//! Rolling input/output/gain-reduction plot with per-channel tabs and a flow-speed selector.
|
||||||
|
//!
|
||||||
|
//! Histories for all four channels run continuously (cheap), so switching tabs shows that
|
||||||
|
//! channel's existing history. It's fed by draining the audio thread's scope ring
|
||||||
|
//! ([`Meters::scope`], clocked at `BUCKET_HZ`), so the horizontal resolution is set by the bucket
|
||||||
|
//! rate rather than the editor frame rate. Buckets are folded into `PLOT_N` columns
|
||||||
|
//! (peak-preserving); `window_s` (the flow speed) sets how many buckets span each column.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::egui::{self, pos2, vec2, Align2, Color32, CornerRadius, FontId, Rect, Sense, Stroke};
|
||||||
|
|
||||||
|
use super::METER_FLOOR_DB;
|
||||||
|
use crate::meters::{Meters, BUCKET_HZ, NUM_CHANNELS};
|
||||||
|
|
||||||
|
/// Height of the plot panel.
|
||||||
|
const PLOT_PANEL_H: f32 = 150.0;
|
||||||
|
/// Number of columns held in the history ring.
|
||||||
|
const PLOT_N: usize = 256;
|
||||||
|
|
||||||
|
const COLOR_IN: Color32 = Color32::from_rgb(90, 170, 235);
|
||||||
|
const COLOR_OUT: Color32 = Color32::from_rgb(90, 200, 110);
|
||||||
|
const COLOR_GR: Color32 = Color32::from_rgb(240, 150, 60);
|
||||||
|
|
||||||
|
/// Rolling history for all channels: a per-channel ring of (in_db, out_db, gr_db) columns.
|
||||||
|
struct PlotHistory {
|
||||||
|
in_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
|
out_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
|
gr_db: [[f32; PLOT_N]; NUM_CHANNELS],
|
||||||
|
/// Per-column flag: the output limiter hit the ceiling somewhere in this column.
|
||||||
|
hit: [bool; PLOT_N],
|
||||||
|
write: usize,
|
||||||
|
len: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlotHistory {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
in_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
|
||||||
|
out_db: [[METER_FLOOR_DB; PLOT_N]; NUM_CHANNELS],
|
||||||
|
gr_db: [[0.0; PLOT_N]; NUM_CHANNELS],
|
||||||
|
hit: [false; PLOT_N],
|
||||||
|
write: 0,
|
||||||
|
len: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotHistory {
|
||||||
|
/// Append one column of (in_db, out_db, gr_db) per channel, plus the ceiling-hit flag.
|
||||||
|
fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS], hit: bool) {
|
||||||
|
for i in 0..NUM_CHANNELS {
|
||||||
|
self.in_db[i][self.write] = samples[i].0;
|
||||||
|
self.out_db[i][self.write] = samples[i].1;
|
||||||
|
self.gr_db[i][self.write] = samples[i].2;
|
||||||
|
}
|
||||||
|
self.hit[self.write] = hit;
|
||||||
|
self.write = (self.write + 1) % PLOT_N;
|
||||||
|
self.len = (self.len + 1).min(PLOT_N);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the
|
||||||
|
/// column being assembled from drained buckets.
|
||||||
|
pub(super) struct PlotState {
|
||||||
|
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). Also drives the gain curve.
|
||||||
|
pub(super) selected: usize,
|
||||||
|
history: PlotHistory,
|
||||||
|
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
|
||||||
|
window_s: f64,
|
||||||
|
/// Read position into the scope ring; `None` until the first frame (then starts at "now").
|
||||||
|
cursor: Option<u64>,
|
||||||
|
/// Per-channel max accumulator (in_db, out_db, gr_db) for the column currently being built.
|
||||||
|
col_acc: [(f32, f32, f32); NUM_CHANNELS],
|
||||||
|
/// Ceiling-hit flag accumulated for the column currently being built.
|
||||||
|
col_hit: bool,
|
||||||
|
/// Buckets folded into the current column so far (fractional — a column may span <1 bucket).
|
||||||
|
col_fill: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PlotState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: 0,
|
||||||
|
history: PlotHistory::default(),
|
||||||
|
window_s: 5.0,
|
||||||
|
cursor: None,
|
||||||
|
col_acc: [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS],
|
||||||
|
col_hit: false,
|
||||||
|
col_fill: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw the scrolling in/out/gain-reduction plot for the selected channel, plus the channel tabs
|
||||||
|
/// and flow-speed selector. History for all channels advances every frame regardless of the tab.
|
||||||
|
pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||||
|
// Buckets that make up one column at the current flow speed (may be fractional).
|
||||||
|
let buckets_per_col = (state.window_s * BUCKET_HZ as f64 / PLOT_N as f64).max(1e-6);
|
||||||
|
|
||||||
|
// Drain every bucket produced since the last frame (audio-clocked), folding them into columns.
|
||||||
|
// A fresh cursor starts at "now" so we don't replay stale buckets.
|
||||||
|
{
|
||||||
|
let w0 = meters.scope.write_index();
|
||||||
|
let cursor = state.cursor.get_or_insert(w0);
|
||||||
|
let history = &mut state.history;
|
||||||
|
let col_acc = &mut state.col_acc;
|
||||||
|
let col_hit = &mut state.col_hit;
|
||||||
|
let col_fill = &mut state.col_fill;
|
||||||
|
meters.scope.drain(cursor, |in_lin, out_lin, gr_db, hit| {
|
||||||
|
for ch in 0..NUM_CHANNELS {
|
||||||
|
col_acc[ch].0 = col_acc[ch].0.max(util::gain_to_db(in_lin[ch]));
|
||||||
|
col_acc[ch].1 = col_acc[ch].1.max(util::gain_to_db(out_lin[ch]));
|
||||||
|
col_acc[ch].2 = col_acc[ch].2.max(gr_db[ch]);
|
||||||
|
}
|
||||||
|
*col_hit |= hit > 0.5;
|
||||||
|
*col_fill += 1.0;
|
||||||
|
while *col_fill >= buckets_per_col {
|
||||||
|
history.push(col_acc, *col_hit);
|
||||||
|
*col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
||||||
|
*col_hit = false;
|
||||||
|
*col_fill -= buckets_per_col;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel tabs + flow-speed selector + legend.
|
||||||
|
let labels = ["LOW", "MID", "HIGH", "ALL"];
|
||||||
|
let speeds = [2.0f64, 5.0, 15.0, 45.0];
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Plot:");
|
||||||
|
for (i, l) in labels.iter().enumerate() {
|
||||||
|
ui.selectable_value(&mut state.selected, i, *l);
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.label("Speed:");
|
||||||
|
let mut speed_changed = false;
|
||||||
|
for &w in &speeds {
|
||||||
|
if ui.selectable_value(&mut state.window_s, w, format!("{w:.0}s")).changed() {
|
||||||
|
speed_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if speed_changed {
|
||||||
|
// Cadence changed: start the history fresh so the time axis is consistent.
|
||||||
|
state.history = PlotHistory::default();
|
||||||
|
state.col_acc = [(METER_FLOOR_DB, METER_FLOOR_DB, 0.0); NUM_CHANNELS];
|
||||||
|
state.col_hit = false;
|
||||||
|
state.col_fill = 0.0;
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
ui.colored_label(COLOR_IN, "in");
|
||||||
|
ui.colored_label(COLOR_OUT, "out");
|
||||||
|
ui.colored_label(COLOR_GR, "GR");
|
||||||
|
});
|
||||||
|
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), PLOT_PANEL_H), Sense::hover());
|
||||||
|
let p = ui.painter_at(rect);
|
||||||
|
p.rect_filled(rect, CornerRadius::ZERO, Color32::from_rgb(16, 16, 20));
|
||||||
|
|
||||||
|
let (top, bottom, left, right) =
|
||||||
|
(rect.top() + 4.0, rect.bottom() - 4.0, rect.left() + 4.0, rect.right() - 4.0);
|
||||||
|
let width = right - left;
|
||||||
|
let y_for_db = |db: f32| -> f32 {
|
||||||
|
let frac = ((db - METER_FLOOR_DB) / -METER_FLOOR_DB).clamp(0.0, 1.0);
|
||||||
|
bottom - frac * (bottom - top)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gridlines (dB).
|
||||||
|
for &g in &[0.0f32, -12.0, -24.0, -48.0] {
|
||||||
|
let y = y_for_db(g);
|
||||||
|
p.line_segment([pos2(left, y), pos2(right, y)], Stroke::new(1.0, Color32::from_gray(40)));
|
||||||
|
p.text(
|
||||||
|
pos2(left + 2.0, y),
|
||||||
|
Align2::LEFT_BOTTOM,
|
||||||
|
format!("{g:.0}"),
|
||||||
|
FontId::proportional(9.0),
|
||||||
|
Color32::from_gray(90),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let c = state.selected.min(NUM_CHANNELS - 1);
|
||||||
|
let (write, len) = (state.history.write, state.history.len);
|
||||||
|
if len >= 2 {
|
||||||
|
let draw_series = |series: &[f32; PLOT_N], to_db: &dyn Fn(f32) -> f32, color: Color32, fill: bool| {
|
||||||
|
let mut pts = Vec::with_capacity(len);
|
||||||
|
for k in 0..len {
|
||||||
|
let idx = (write + PLOT_N - len + k) % PLOT_N;
|
||||||
|
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32; // newest hugs the right
|
||||||
|
pts.push(pos2(left + pos * width, y_for_db(to_db(series[idx]))));
|
||||||
|
}
|
||||||
|
if fill {
|
||||||
|
// Fill as a strip of per-segment convex quads down to the baseline. A single
|
||||||
|
// concave polygon mis-tessellates in egui (it fans from one corner, leaving stray
|
||||||
|
// triangles), so build convex pieces — one box per time unit — instead.
|
||||||
|
let fill_col = Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 40);
|
||||||
|
for seg in pts.windows(2) {
|
||||||
|
let (a, b) = (seg[0], seg[1]);
|
||||||
|
p.add(egui::Shape::convex_polygon(
|
||||||
|
vec![pos2(a.x, bottom), pos2(a.x, a.y), pos2(b.x, b.y), pos2(b.x, bottom)],
|
||||||
|
fill_col,
|
||||||
|
Stroke::NONE,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.add(egui::Shape::line(pts, Stroke::new(1.5, color)));
|
||||||
|
};
|
||||||
|
draw_series(&state.history.in_db[c], &|db| db, COLOR_IN, true);
|
||||||
|
draw_series(&state.history.out_db[c], &|db| db, COLOR_OUT, true);
|
||||||
|
// GR hangs from the 0 dB line: a reduction of X dB is drawn at the -X gridline.
|
||||||
|
draw_series(&state.history.gr_db[c], &|gr| -gr, COLOR_GR, false);
|
||||||
|
|
||||||
|
// Ceiling-hit markers: a short red tick at the TOP for any column where the output limiter
|
||||||
|
// hit the ceiling (global — shown on every channel's view). One column wide, so runs of
|
||||||
|
// hits merge into a continuous segment and a lone hit is just a dot. Nothing otherwise.
|
||||||
|
let dx = width / (PLOT_N - 1) as f32;
|
||||||
|
let marker = Color32::from_rgb(235, 45, 45);
|
||||||
|
for k in 0..len {
|
||||||
|
let idx = (write + PLOT_N - len + k) % PLOT_N;
|
||||||
|
if state.history.hit[idx] {
|
||||||
|
let pos = (PLOT_N - len + k) as f32 / (PLOT_N - 1) as f32;
|
||||||
|
let x = left + pos * width;
|
||||||
|
p.rect_filled(Rect::from_min_max(pos2(x, top), pos2(x + dx, top + 3.0)), CornerRadius::ZERO, marker);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-255
@@ -1,16 +1,20 @@
|
|||||||
use nih_plug::prelude::*;
|
use nih_plug::prelude::*;
|
||||||
use nih_plug_egui::{
|
|
||||||
create_egui_editor,
|
|
||||||
egui::{self, Vec2},
|
|
||||||
resizable_window::ResizableWindow,
|
|
||||||
widgets, EguiState,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
mod dsp;
|
mod dsp;
|
||||||
use dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
mod editor;
|
||||||
|
mod meters;
|
||||||
|
mod params;
|
||||||
|
|
||||||
|
use dsp::compressor::{Compressor, MAX_LOOKAHEAD_MS};
|
||||||
use dsp::crossover::Crossover;
|
use dsp::crossover::Crossover;
|
||||||
use dsp::limiter::Limiter;
|
use dsp::limiter::Limiter;
|
||||||
|
use meters::Meters;
|
||||||
|
use params::{build_settings, Codename206Params};
|
||||||
|
|
||||||
|
/// Peak-meter fall: after this long of silence the bars decay by 12 dB. (Matches nih-plug's
|
||||||
|
/// gain-gui example feel.)
|
||||||
|
const METER_DECAY_MS: f64 = 150.0;
|
||||||
|
|
||||||
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
/// Band indices into the compressor array: low, mid, high, then the 'All' aggregate channel.
|
||||||
const LOW: usize = 0;
|
const LOW: usize = 0;
|
||||||
@@ -18,17 +22,6 @@ const MID: usize = 1;
|
|||||||
const HIGH: usize = 2;
|
const HIGH: usize = 2;
|
||||||
const ALL: usize = 3;
|
const ALL: usize = 3;
|
||||||
|
|
||||||
/// Level-detection mode for a compressor's detector.
|
|
||||||
#[derive(Enum, PartialEq, Clone, Copy)]
|
|
||||||
enum DetectionMode {
|
|
||||||
#[id = "peak"]
|
|
||||||
#[name = "Peak"]
|
|
||||||
Peak,
|
|
||||||
#[id = "rms"]
|
|
||||||
#[name = "RMS"]
|
|
||||||
Rms,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
/// Codename 206 — Stage 3: 3-band crossover + per-band compressors summed into an 'All' channel.
|
||||||
///
|
///
|
||||||
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
/// Signal: input → LR4 crossover → {low, mid, high} each through their own compressor → sum →
|
||||||
@@ -42,59 +35,26 @@ struct Codename206 {
|
|||||||
comps: [Compressor; 4],
|
comps: [Compressor; 4],
|
||||||
/// Output brickwall limiter (final stage).
|
/// Output brickwall limiter (final stage).
|
||||||
limiter: Limiter,
|
limiter: Limiter,
|
||||||
|
/// Lock-free meter state shared with the editor.
|
||||||
|
meters: Arc<Meters>,
|
||||||
|
/// Per-sample decay factor for the meter peak-hold (computed from the sample rate; raised to
|
||||||
|
/// the block length when applied once per block in `process`).
|
||||||
|
meter_decay_weight: f32,
|
||||||
|
|
||||||
|
/// Per-channel max accumulators for the plot bucket currently being built (in/out linear, GR
|
||||||
|
/// dB). Persist across blocks since a bucket spans many samples.
|
||||||
|
scope_in: [f32; 4],
|
||||||
|
scope_out: [f32; 4],
|
||||||
|
scope_gr: [f32; 4],
|
||||||
|
/// Max output-limiter gain reduction seen in the current bucket (for the ceiling-hit marker).
|
||||||
|
scope_hit: f32,
|
||||||
|
/// Samples accumulated into the current bucket, and the bucket length (= sample_rate / BUCKET_HZ).
|
||||||
|
scope_samples: usize,
|
||||||
|
scope_bucket_len: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Params)]
|
/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling.
|
||||||
struct Codename206Params {
|
const CEILING_HIT_GR_DB: f32 = 0.1;
|
||||||
#[persist = "editor-state"]
|
|
||||||
editor_state: Arc<EguiState>,
|
|
||||||
|
|
||||||
/// Low/Mid crossover frequency.
|
|
||||||
#[id = "xover_lo"]
|
|
||||||
pub crossover_low_hz: FloatParam,
|
|
||||||
/// Mid/High crossover frequency.
|
|
||||||
#[id = "xover_hi"]
|
|
||||||
pub crossover_high_hz: FloatParam,
|
|
||||||
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
|
|
||||||
#[id = "lookahead"]
|
|
||||||
pub look_ahead_ms: FloatParam,
|
|
||||||
|
|
||||||
/// Output brickwall ceiling (the limiter never lets output exceed this).
|
|
||||||
#[id = "ceiling"]
|
|
||||||
pub output_ceiling_db: FloatParam,
|
|
||||||
/// Output limiter release time.
|
|
||||||
#[id = "lim_rel"]
|
|
||||||
pub limiter_release_ms: FloatParam,
|
|
||||||
|
|
||||||
#[nested(id_prefix = "low", group = "Low")]
|
|
||||||
pub low: CompressorParams,
|
|
||||||
#[nested(id_prefix = "mid", group = "Mid")]
|
|
||||||
pub mid: CompressorParams,
|
|
||||||
#[nested(id_prefix = "high", group = "High")]
|
|
||||||
pub high: CompressorParams,
|
|
||||||
#[nested(id_prefix = "all", group = "All")]
|
|
||||||
pub all: CompressorParams,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Params)]
|
|
||||||
struct CompressorParams {
|
|
||||||
#[id = "detect"]
|
|
||||||
pub detection: EnumParam<DetectionMode>,
|
|
||||||
#[id = "thresh"]
|
|
||||||
pub threshold_db: FloatParam,
|
|
||||||
#[id = "ratio"]
|
|
||||||
pub ratio: FloatParam,
|
|
||||||
#[id = "knee"]
|
|
||||||
pub knee_db: FloatParam,
|
|
||||||
#[id = "attack"]
|
|
||||||
pub attack_ms: FloatParam,
|
|
||||||
#[id = "release"]
|
|
||||||
pub release_ms: FloatParam,
|
|
||||||
#[id = "makeup"]
|
|
||||||
pub makeup_db: FloatParam,
|
|
||||||
#[id = "bypass"]
|
|
||||||
pub bypass: BoolParam,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Codename206 {
|
impl Default for Codename206 {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
@@ -104,131 +64,18 @@ impl Default for Codename206 {
|
|||||||
crossover: Crossover::new(),
|
crossover: Crossover::new(),
|
||||||
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
comps: [Compressor::new(), Compressor::new(), Compressor::new(), Compressor::new()],
|
||||||
limiter: Limiter::new(),
|
limiter: Limiter::new(),
|
||||||
|
meters: Arc::new(Meters::default()),
|
||||||
|
meter_decay_weight: 1.0,
|
||||||
|
scope_in: [0.0; 4],
|
||||||
|
scope_out: [0.0; 4],
|
||||||
|
scope_gr: [0.0; 4],
|
||||||
|
scope_hit: 0.0,
|
||||||
|
scope_samples: 0,
|
||||||
|
scope_bucket_len: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Codename206Params {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
editor_state: EguiState::from_size(760, 520),
|
|
||||||
|
|
||||||
crossover_low_hz: FloatParam::new(
|
|
||||||
"Crossover Lo/Mid",
|
|
||||||
200.0,
|
|
||||||
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
|
|
||||||
)
|
|
||||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
|
||||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
|
||||||
|
|
||||||
crossover_high_hz: FloatParam::new(
|
|
||||||
"Crossover Mid/Hi",
|
|
||||||
2_500.0,
|
|
||||||
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
|
|
||||||
)
|
|
||||||
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
|
||||||
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
|
||||||
|
|
||||||
look_ahead_ms: FloatParam::new(
|
|
||||||
"Look-ahead",
|
|
||||||
2.0,
|
|
||||||
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
|
||||||
)
|
|
||||||
.with_unit(" ms")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
|
||||||
|
|
||||||
output_ceiling_db: FloatParam::new(
|
|
||||||
"Ceiling",
|
|
||||||
0.0,
|
|
||||||
FloatRange::Linear { min: -24.0, max: 0.0 },
|
|
||||||
)
|
|
||||||
.with_unit(" dB")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
|
||||||
|
|
||||||
limiter_release_ms: FloatParam::new(
|
|
||||||
"Limiter Release",
|
|
||||||
100.0,
|
|
||||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
|
||||||
)
|
|
||||||
.with_unit(" ms")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
|
||||||
|
|
||||||
low: CompressorParams::default(),
|
|
||||||
mid: CompressorParams::default(),
|
|
||||||
high: CompressorParams::default(),
|
|
||||||
all: CompressorParams::default(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for CompressorParams {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
detection: EnumParam::new("Detection", DetectionMode::Peak),
|
|
||||||
|
|
||||||
threshold_db: FloatParam::new(
|
|
||||||
"Threshold",
|
|
||||||
-18.0,
|
|
||||||
FloatRange::Linear { min: -60.0, max: 0.0 },
|
|
||||||
)
|
|
||||||
.with_unit(" dB")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
|
||||||
|
|
||||||
ratio: FloatParam::new(
|
|
||||||
"Ratio",
|
|
||||||
2.0,
|
|
||||||
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
|
|
||||||
)
|
|
||||||
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
|
|
||||||
.with_string_to_value(Arc::new(|s| {
|
|
||||||
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
|
||||||
})),
|
|
||||||
|
|
||||||
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 24.0 })
|
|
||||||
.with_unit(" dB")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
|
||||||
|
|
||||||
attack_ms: FloatParam::new(
|
|
||||||
"Attack",
|
|
||||||
10.0,
|
|
||||||
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
|
|
||||||
)
|
|
||||||
.with_unit(" ms")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
|
||||||
|
|
||||||
release_ms: FloatParam::new(
|
|
||||||
"Release",
|
|
||||||
100.0,
|
|
||||||
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
|
||||||
)
|
|
||||||
.with_unit(" ms")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
|
||||||
|
|
||||||
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -12.0, max: 24.0 })
|
|
||||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
|
||||||
.with_unit(" dB")
|
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
|
||||||
|
|
||||||
bypass: BoolParam::new("Bypass", false),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
|
|
||||||
fn build_settings(p: &CompressorParams, lookahead: usize, sample_rate: f32) -> CompressorSettings {
|
|
||||||
CompressorSettings {
|
|
||||||
threshold_db: p.threshold_db.value(),
|
|
||||||
ratio: p.ratio.value(),
|
|
||||||
knee_db: p.knee_db.value(),
|
|
||||||
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
|
|
||||||
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
|
|
||||||
makeup_db: 0.0,
|
|
||||||
lookahead_samples: lookahead,
|
|
||||||
use_rms: p.detection.value() == DetectionMode::Rms,
|
|
||||||
bypass: p.bypass.value(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Codename206 {
|
impl Codename206 {
|
||||||
fn lookahead_samples(&self) -> usize {
|
fn lookahead_samples(&self) -> usize {
|
||||||
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
(self.params.look_ahead_ms.value() * 0.001 * self.sample_rate).round() as usize
|
||||||
@@ -269,68 +116,7 @@ impl Plugin for Codename206 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
|
||||||
let params = self.params.clone();
|
editor::create(self.params.clone(), self.meters.clone())
|
||||||
let egui_state = self.params.editor_state.clone();
|
|
||||||
create_egui_editor(
|
|
||||||
self.params.editor_state.clone(),
|
|
||||||
(),
|
|
||||||
|_, _| {},
|
|
||||||
move |egui_ctx, setter, _state| {
|
|
||||||
// One column of controls for a single compressor channel.
|
|
||||||
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| {
|
|
||||||
ui.strong(title);
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.detection, setter));
|
|
||||||
ui.label("Threshold");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter));
|
|
||||||
ui.label("Ratio");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.ratio, setter));
|
|
||||||
ui.label("Knee");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.knee_db, setter));
|
|
||||||
ui.label("Attack");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.attack_ms, setter));
|
|
||||||
ui.label("Release");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.release_ms, setter));
|
|
||||||
ui.label("Makeup");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.makeup_db, setter));
|
|
||||||
ui.add(widgets::ParamSlider::for_param(&p.bypass, setter));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Resizable window; vertical scroll so every control stays reachable even when the
|
|
||||||
// window is small. (Placeholder layout — Stage 6 will replace it.)
|
|
||||||
ResizableWindow::new("editor")
|
|
||||||
.min_size(Vec2::new(480.0, 320.0))
|
|
||||||
.show(egui_ctx, egui_state.as_ref(), |ui| {
|
|
||||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
|
||||||
ui.heading(Self::NAME);
|
|
||||||
// Global controls stacked vertically so they never overflow sideways.
|
|
||||||
egui::Grid::new("globals").num_columns(2).show(ui, |ui| {
|
|
||||||
ui.label("Xover Lo/Mid");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.crossover_low_hz, setter));
|
|
||||||
ui.end_row();
|
|
||||||
ui.label("Xover Mid/Hi");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.crossover_high_hz, setter));
|
|
||||||
ui.end_row();
|
|
||||||
ui.label("Look-ahead");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.look_ahead_ms, setter));
|
|
||||||
ui.end_row();
|
|
||||||
ui.label("Ceiling");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.output_ceiling_db, setter));
|
|
||||||
ui.end_row();
|
|
||||||
ui.label("Lim Release");
|
|
||||||
ui.add(widgets::ParamSlider::for_param(¶ms.limiter_release_ms, setter));
|
|
||||||
ui.end_row();
|
|
||||||
});
|
|
||||||
ui.separator();
|
|
||||||
ui.columns(4, |cols| {
|
|
||||||
band_col(&mut cols[0], "LOW", ¶ms.low);
|
|
||||||
band_col(&mut cols[1], "MID", ¶ms.mid);
|
|
||||||
band_col(&mut cols[2], "HIGH", ¶ms.high);
|
|
||||||
band_col(&mut cols[3], "ALL", ¶ms.all);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn initialize(
|
fn initialize(
|
||||||
@@ -345,6 +131,14 @@ impl Plugin for Codename206 {
|
|||||||
.map(NonZeroU32::get)
|
.map(NonZeroU32::get)
|
||||||
.unwrap_or(2) as usize;
|
.unwrap_or(2) as usize;
|
||||||
|
|
||||||
|
// Per-block decay so the meters fall ~12 dB over METER_DECAY_MS of silence.
|
||||||
|
self.meter_decay_weight =
|
||||||
|
0.25f64.powf((self.sample_rate as f64 * METER_DECAY_MS / 1000.0).recip()) as f32;
|
||||||
|
|
||||||
|
// Plot bucket length: emit a scope bucket every ~1/BUCKET_HZ seconds.
|
||||||
|
self.scope_bucket_len =
|
||||||
|
((self.sample_rate / meters::BUCKET_HZ as f32).round() as usize).max(1);
|
||||||
|
|
||||||
for comp in &mut self.comps {
|
for comp in &mut self.comps {
|
||||||
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
comp.prepare(self.sample_rate, channels, MAX_LOOKAHEAD_MS);
|
||||||
}
|
}
|
||||||
@@ -370,13 +164,21 @@ impl Plugin for Codename206 {
|
|||||||
comp.reset();
|
comp.reset();
|
||||||
}
|
}
|
||||||
self.limiter.reset();
|
self.limiter.reset();
|
||||||
|
// Transport restart / sample-rate change: drop stale meter values to silence and discard
|
||||||
|
// the in-flight plot bucket.
|
||||||
|
self.meters.clear();
|
||||||
|
self.scope_in = [0.0; 4];
|
||||||
|
self.scope_out = [0.0; 4];
|
||||||
|
self.scope_gr = [0.0; 4];
|
||||||
|
self.scope_hit = 0.0;
|
||||||
|
self.scope_samples = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn process(
|
fn process(
|
||||||
&mut self,
|
&mut self,
|
||||||
buffer: &mut Buffer,
|
buffer: &mut Buffer,
|
||||||
_aux: &mut AuxiliaryBuffers,
|
_aux: &mut AuxiliaryBuffers,
|
||||||
_context: &mut impl ProcessContext<Self>,
|
context: &mut impl ProcessContext<Self>,
|
||||||
) -> ProcessStatus {
|
) -> ProcessStatus {
|
||||||
let lookahead = self.lookahead_samples();
|
let lookahead = self.lookahead_samples();
|
||||||
|
|
||||||
@@ -401,6 +203,18 @@ impl Plugin for Codename206 {
|
|||||||
let limiter_release =
|
let limiter_release =
|
||||||
Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate);
|
Compressor::time_to_coef(self.params.limiter_release_ms.value(), self.sample_rate);
|
||||||
|
|
||||||
|
// Only do the (cheap) metering work when the editor is actually open.
|
||||||
|
let metering = self.params.editor_state.is_open();
|
||||||
|
// The host keeps calling process() with silence while stopped/paused (FL does), so the
|
||||||
|
// scope is gated on the transport actually playing — otherwise it would scroll silence.
|
||||||
|
let playing = context.transport().playing;
|
||||||
|
let num_samples = buffer.samples();
|
||||||
|
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
|
||||||
|
let mut lvl_r = [0.0f32; meters::NUM_CHANNELS];
|
||||||
|
let mut inp = [0.0f32; meters::NUM_CHANNELS]; // mono input level (detector / gain-curve x)
|
||||||
|
let mut gr = [0.0f32; meters::NUM_CHANNELS];
|
||||||
|
let mut lim_gr = 0.0f32;
|
||||||
|
|
||||||
let mut in_frame = [0.0f32; 2];
|
let mut in_frame = [0.0f32; 2];
|
||||||
let mut band_in = [[0.0f32; 2]; 3];
|
let mut band_in = [[0.0f32; 2]; 3];
|
||||||
let mut band_out = [[0.0f32; 2]; 3];
|
let mut band_out = [[0.0f32; 2]; 3];
|
||||||
@@ -410,6 +224,7 @@ impl Plugin for Codename206 {
|
|||||||
|
|
||||||
for mut frame in buffer.iter_samples() {
|
for mut frame in buffer.iter_samples() {
|
||||||
let n = frame.len().min(2);
|
let n = frame.len().min(2);
|
||||||
|
let r = (n - 1).min(1); // right-channel index (== left when mono)
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
in_frame[ch] = *frame.get_mut(ch).unwrap();
|
||||||
}
|
}
|
||||||
@@ -422,28 +237,105 @@ impl Plugin for Codename206 {
|
|||||||
band_in[HIGH][ch] = hi;
|
band_in[HIGH][ch] = hi;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compress each band (per-sample smoothed makeup), then sum.
|
// Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum.
|
||||||
summed[..n].fill(0.0);
|
summed[..n].fill(0.0);
|
||||||
for b in 0..3 {
|
for b in 0..3 {
|
||||||
|
let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next());
|
||||||
|
for ch in 0..n {
|
||||||
|
band_in[b][ch] *= pre;
|
||||||
|
}
|
||||||
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
|
||||||
|
band_set[b].mix = band_params[b].mix.smoothed.next();
|
||||||
|
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
|
||||||
|
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
|
||||||
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
|
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
summed[ch] += band_out[b][ch];
|
summed[ch] += band_out[b][ch];
|
||||||
}
|
}
|
||||||
|
if metering {
|
||||||
|
let in_mono = band_in[b][0].abs().max(band_in[b][r].abs());
|
||||||
|
let out_l = band_out[b][0].abs();
|
||||||
|
let out_r = band_out[b][r].abs();
|
||||||
|
// Wet gain reduction (what the comp computes), independent of the mix.
|
||||||
|
let g = self.comps[b].gain_reduction_db();
|
||||||
|
inp[b] = inp[b].max(in_mono);
|
||||||
|
lvl_l[b] = lvl_l[b].max(out_l);
|
||||||
|
lvl_r[b] = lvl_r[b].max(out_r);
|
||||||
|
gr[b] = gr[b].max(g);
|
||||||
|
if playing {
|
||||||
|
self.scope_in[b] = self.scope_in[b].max(in_mono);
|
||||||
|
self.scope_out[b] = self.scope_out[b].max(out_l.max(out_r));
|
||||||
|
self.scope_gr[b] = self.scope_gr[b].max(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'All' aggregate channel over the summed bands.
|
// 'All' aggregate channel over the summed bands (driven before its compressor).
|
||||||
|
let all_pre = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next());
|
||||||
|
for ch in 0..n {
|
||||||
|
summed[ch] *= all_pre;
|
||||||
|
}
|
||||||
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
|
||||||
|
all_set.mix = self.params.all.mix.smoothed.next();
|
||||||
|
all_set.low_slope = self.params.all.low_slope.smoothed.next();
|
||||||
|
all_set.low_curve = self.params.all.low_curve.smoothed.next();
|
||||||
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
|
||||||
|
|
||||||
// Output brickwall limiter.
|
// Output brickwall limiter.
|
||||||
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
|
self.limiter.process(&out_frame[..n], &mut lim_frame[..n], ceiling, limiter_release);
|
||||||
|
|
||||||
|
if metering {
|
||||||
|
let in_mono = summed[0].abs().max(summed[r].abs());
|
||||||
|
let out_l = out_frame[0].abs();
|
||||||
|
let out_r = out_frame[r].abs();
|
||||||
|
let g = self.comps[ALL].gain_reduction_db();
|
||||||
|
inp[ALL] = inp[ALL].max(in_mono);
|
||||||
|
lvl_l[ALL] = lvl_l[ALL].max(out_l);
|
||||||
|
lvl_r[ALL] = lvl_r[ALL].max(out_r);
|
||||||
|
gr[ALL] = gr[ALL].max(g);
|
||||||
|
lim_gr = lim_gr.max(self.limiter.gain_reduction_db());
|
||||||
|
|
||||||
|
// Only advance the scope while the transport is playing, so it freezes (rather than
|
||||||
|
// scrolling silence) when the host is paused/stopped but still calling process().
|
||||||
|
if playing {
|
||||||
|
self.scope_in[ALL] = self.scope_in[ALL].max(in_mono);
|
||||||
|
self.scope_out[ALL] = self.scope_out[ALL].max(out_l.max(out_r));
|
||||||
|
self.scope_gr[ALL] = self.scope_gr[ALL].max(g);
|
||||||
|
self.scope_hit = self.scope_hit.max(self.limiter.gain_reduction_db());
|
||||||
|
|
||||||
|
// Emit a plot bucket every scope_bucket_len samples (~BUCKET_HZ).
|
||||||
|
self.scope_samples += 1;
|
||||||
|
if self.scope_samples >= self.scope_bucket_len {
|
||||||
|
let hit = if self.scope_hit > CEILING_HIT_GR_DB { 1.0 } else { 0.0 };
|
||||||
|
self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr, hit);
|
||||||
|
self.scope_in = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_out = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_gr = [0.0; meters::NUM_CHANNELS];
|
||||||
|
self.scope_hit = 0.0;
|
||||||
|
self.scope_samples = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for ch in 0..n {
|
for ch in 0..n {
|
||||||
*frame.get_mut(ch).unwrap() = lim_frame[ch];
|
*frame.get_mut(ch).unwrap() = lim_frame[ch];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish one decimated value per meter for this block. The decay weight is per-sample,
|
||||||
|
// so raise it to the block length to keep the fall time constant independent of buffer size
|
||||||
|
// (we apply it once per block, not once per sample).
|
||||||
|
if metering {
|
||||||
|
let w = self.meter_decay_weight.powi(num_samples as i32);
|
||||||
|
for i in 0..meters::NUM_CHANNELS {
|
||||||
|
meters::decay_store(&self.meters.level_l[i], lvl_l[i], w);
|
||||||
|
meters::decay_store(&self.meters.level_r[i], lvl_r[i], w);
|
||||||
|
meters::decay_store(&self.meters.input_level[i], inp[i], w);
|
||||||
|
meters::decay_store(&self.meters.gain_reduction_db[i], gr[i], w);
|
||||||
|
}
|
||||||
|
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
|
||||||
|
}
|
||||||
|
|
||||||
ProcessStatus::Normal
|
ProcessStatus::Normal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+173
@@ -0,0 +1,173 @@
|
|||||||
|
//! Lock-free meter state shared from the audio thread to the editor.
|
||||||
|
//!
|
||||||
|
//! Two feeds, both written by `process()` (single producer) and read by the editor (single
|
||||||
|
//! consumer), all wait-free:
|
||||||
|
//!
|
||||||
|
//! * **Bar meters** — decayed scalars per channel ([`Meters::level_l`] etc.), one store per block.
|
||||||
|
//! * **Scrolling plot** — a [`ScopeRing`] of raw buckets clocked at [`BUCKET_HZ`] (independent of
|
||||||
|
//! the GUI frame rate), so the plot's horizontal resolution isn't capped by the ~60 fps repaint.
|
||||||
|
|
||||||
|
use nih_plug::prelude::AtomicF32;
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
|
||||||
|
/// Metered channels: low, mid, high, then the 'All' aggregate — same order as the compressors.
|
||||||
|
pub const NUM_CHANNELS: usize = 4;
|
||||||
|
|
||||||
|
/// Rate the audio thread emits plot buckets at (Hz). Sets the plot's max horizontal resolution,
|
||||||
|
/// decoupled from the editor frame rate. ~5 ms per bucket.
|
||||||
|
pub const BUCKET_HZ: u32 = 200;
|
||||||
|
|
||||||
|
/// Buckets buffered between GUI drains. At [`BUCKET_HZ`] this is ~2.5 s of slack — far more than
|
||||||
|
/// the frame interval needs; if the GUI ever stalls longer, the oldest buckets are dropped.
|
||||||
|
const RING_N: usize = 512;
|
||||||
|
|
||||||
|
pub struct Meters {
|
||||||
|
/// Left output level per channel as a **linear** peak. Peak-with-decay.
|
||||||
|
pub level_l: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Right output level per channel (== left for mono signals).
|
||||||
|
pub level_r: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Mono **input** level per channel (post pre-gain = what the compressor detects). Drives the
|
||||||
|
/// gain-curve operating-point fill. Peak-with-decay.
|
||||||
|
pub input_level: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
|
||||||
|
/// stereo-linked, so the same gain applies to both channels.
|
||||||
|
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
|
||||||
|
/// Output limiter gain reduction in **dB (>= 0)** — feeds the ALL channel's ceiling lamp.
|
||||||
|
pub limiter_gr_db: AtomicF32,
|
||||||
|
/// Bucket stream feeding the scrolling in/out/GR plot.
|
||||||
|
pub scope: ScopeRing,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Meters {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
level_l: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
level_r: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
input_level: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
|
||||||
|
limiter_gr_db: AtomicF32::new(0.0),
|
||||||
|
scope: ScopeRing::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Meters {
|
||||||
|
/// Zero the bar meters. Called from the plugin's `reset()` (transport restart / sample-rate
|
||||||
|
/// change) so the bars start from silence. The plot ring is left alone — it's continuous and
|
||||||
|
/// reflects the new (silent) buckets as they arrive. Real-time safe.
|
||||||
|
pub fn clear(&self) {
|
||||||
|
for i in 0..NUM_CHANNELS {
|
||||||
|
self.level_l[i].store(0.0, Ordering::Relaxed);
|
||||||
|
self.level_r[i].store(0.0, Ordering::Relaxed);
|
||||||
|
self.input_level[i].store(0.0, Ordering::Relaxed);
|
||||||
|
self.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
self.limiter_gr_db.store(0.0, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update a meter atomic with a new block value using peak-hold-with-decay: jump instantly to a
|
||||||
|
/// louder value, ease back down by `decay_weight` (0..1, closer to 1 = slower fall). Keeps meters
|
||||||
|
/// from flickering while staying responsive to transients.
|
||||||
|
pub fn decay_store(meter: &AtomicF32, block_value: f32, decay_weight: f32) {
|
||||||
|
let current = meter.load(Ordering::Relaxed);
|
||||||
|
let next = if block_value > current {
|
||||||
|
block_value
|
||||||
|
} else {
|
||||||
|
current * decay_weight + block_value * (1.0 - decay_weight)
|
||||||
|
};
|
||||||
|
meter.store(next, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lock-free single-producer/single-consumer ring of plot buckets. Each bucket holds a per-channel
|
||||||
|
/// (input level, output level, gain reduction) triple. The producer (audio thread) appends with
|
||||||
|
/// [`push`](ScopeRing::push); the consumer (GUI) reads new buckets with [`drain`](ScopeRing::drain),
|
||||||
|
/// tracking its own cursor. Per-field atomics avoid tearing; the consumer leaves one slot of margin
|
||||||
|
/// from the slot being written, so it never races the producer. If the consumer falls more than the
|
||||||
|
/// ring behind, the oldest buckets are silently dropped (a visual gap at worst).
|
||||||
|
pub struct ScopeRing {
|
||||||
|
/// `slot * NUM_CHANNELS + ch`, indexed by `bucket_index % RING_N`.
|
||||||
|
in_lin: Vec<AtomicF32>,
|
||||||
|
out_lin: Vec<AtomicF32>,
|
||||||
|
gr_db: Vec<AtomicF32>,
|
||||||
|
/// Per-bucket (not per-channel) flag: `1.0` if the output limiter hit the ceiling in this
|
||||||
|
/// bucket, else `0.0`. Indexed by `bucket_index % RING_N`.
|
||||||
|
hit: Vec<AtomicF32>,
|
||||||
|
/// Monotonic count of buckets ever written.
|
||||||
|
write: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ScopeRing {
|
||||||
|
fn default() -> Self {
|
||||||
|
let make = || (0..RING_N * NUM_CHANNELS).map(|_| AtomicF32::new(0.0)).collect();
|
||||||
|
Self {
|
||||||
|
in_lin: make(),
|
||||||
|
out_lin: make(),
|
||||||
|
gr_db: make(),
|
||||||
|
hit: (0..RING_N).map(|_| AtomicF32::new(0.0)).collect(),
|
||||||
|
write: AtomicU64::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScopeRing {
|
||||||
|
/// Producer (audio thread): append one bucket of per-channel (in_lin, out_lin, gr_db).
|
||||||
|
pub fn push(
|
||||||
|
&self,
|
||||||
|
in_lin: &[f32; NUM_CHANNELS],
|
||||||
|
out_lin: &[f32; NUM_CHANNELS],
|
||||||
|
gr_db: &[f32; NUM_CHANNELS],
|
||||||
|
hit: f32,
|
||||||
|
) {
|
||||||
|
let w = self.write.load(Ordering::Relaxed); // producer is the sole writer of `write`
|
||||||
|
let slot = w as usize % RING_N;
|
||||||
|
let base = slot * NUM_CHANNELS;
|
||||||
|
for ch in 0..NUM_CHANNELS {
|
||||||
|
self.in_lin[base + ch].store(in_lin[ch], Ordering::Relaxed);
|
||||||
|
self.out_lin[base + ch].store(out_lin[ch], Ordering::Relaxed);
|
||||||
|
self.gr_db[base + ch].store(gr_db[ch], Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
self.hit[slot].store(hit, Ordering::Relaxed);
|
||||||
|
// Publish the bucket: the Release pairs with the consumer's Acquire so the stores above are
|
||||||
|
// visible before the new count.
|
||||||
|
self.write.store(w + 1, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Consumer (GUI): call `on_bucket` for each bucket in `*cursor..write`, advancing `cursor`.
|
||||||
|
/// Skips ahead (dropping oldest) if the consumer fell more than the ring behind.
|
||||||
|
pub fn drain(
|
||||||
|
&self,
|
||||||
|
cursor: &mut u64,
|
||||||
|
mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], f32),
|
||||||
|
) {
|
||||||
|
let w = self.write.load(Ordering::Acquire);
|
||||||
|
if *cursor > w {
|
||||||
|
*cursor = w; // counter went backwards (shouldn't happen) — resync
|
||||||
|
}
|
||||||
|
// Stay one slot clear of the slot currently being written.
|
||||||
|
let oldest = w.saturating_sub((RING_N - 1) as u64);
|
||||||
|
if *cursor < oldest {
|
||||||
|
*cursor = oldest;
|
||||||
|
}
|
||||||
|
let mut in_buf = [0.0f32; NUM_CHANNELS];
|
||||||
|
let mut out_buf = [0.0f32; NUM_CHANNELS];
|
||||||
|
let mut gr_buf = [0.0f32; NUM_CHANNELS];
|
||||||
|
while *cursor < w {
|
||||||
|
let slot = *cursor as usize % RING_N;
|
||||||
|
let base = slot * NUM_CHANNELS;
|
||||||
|
for ch in 0..NUM_CHANNELS {
|
||||||
|
in_buf[ch] = self.in_lin[base + ch].load(Ordering::Relaxed);
|
||||||
|
out_buf[ch] = self.out_lin[base + ch].load(Ordering::Relaxed);
|
||||||
|
gr_buf[ch] = self.gr_db[base + ch].load(Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
let hit = self.hit[slot].load(Ordering::Relaxed);
|
||||||
|
on_bucket(&in_buf, &out_buf, &gr_buf, hit);
|
||||||
|
*cursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current write high-water mark (for a fresh consumer to start from "now").
|
||||||
|
pub fn write_index(&self) -> u64 {
|
||||||
|
self.write.load(Ordering::Acquire)
|
||||||
|
}
|
||||||
|
}
|
||||||
+239
@@ -0,0 +1,239 @@
|
|||||||
|
//! Plugin parameters and their layout.
|
||||||
|
//!
|
||||||
|
//! Holds the global controls plus four `CompressorParams` blocks (low/mid/high + the 'All'
|
||||||
|
//! aggregate channel). `build_settings` translates a channel's params into the per-block
|
||||||
|
//! [`CompressorSettings`] the DSP consumes.
|
||||||
|
|
||||||
|
use nih_plug::prelude::*;
|
||||||
|
use nih_plug_egui::EguiState;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use crate::dsp::compressor::{Compressor, CompressorSettings, MAX_LOOKAHEAD_MS};
|
||||||
|
|
||||||
|
/// Level-detection mode for a compressor's detector.
|
||||||
|
#[derive(Enum, PartialEq, Clone, Copy)]
|
||||||
|
pub enum DetectionMode {
|
||||||
|
#[id = "peak"]
|
||||||
|
#[name = "Peak"]
|
||||||
|
Peak,
|
||||||
|
#[id = "rms"]
|
||||||
|
#[name = "RMS"]
|
||||||
|
Rms,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Params)]
|
||||||
|
pub struct Codename206Params {
|
||||||
|
#[persist = "editor-state"]
|
||||||
|
pub editor_state: Arc<EguiState>,
|
||||||
|
|
||||||
|
/// Low/Mid crossover frequency.
|
||||||
|
#[id = "xover_lo"]
|
||||||
|
pub crossover_low_hz: FloatParam,
|
||||||
|
/// Mid/High crossover frequency.
|
||||||
|
#[id = "xover_hi"]
|
||||||
|
pub crossover_high_hz: FloatParam,
|
||||||
|
/// Global look-ahead time (constant reported latency — safe to adjust during playback).
|
||||||
|
#[id = "lookahead"]
|
||||||
|
pub look_ahead_ms: FloatParam,
|
||||||
|
|
||||||
|
/// Output brickwall ceiling (the limiter never lets output exceed this).
|
||||||
|
#[id = "ceiling"]
|
||||||
|
pub output_ceiling_db: FloatParam,
|
||||||
|
/// Output limiter release time.
|
||||||
|
#[id = "lim_rel"]
|
||||||
|
pub limiter_release_ms: FloatParam,
|
||||||
|
|
||||||
|
#[nested(id_prefix = "low", group = "Low")]
|
||||||
|
pub low: CompressorParams,
|
||||||
|
#[nested(id_prefix = "mid", group = "Mid")]
|
||||||
|
pub mid: CompressorParams,
|
||||||
|
#[nested(id_prefix = "high", group = "High")]
|
||||||
|
pub high: CompressorParams,
|
||||||
|
#[nested(id_prefix = "all", group = "All")]
|
||||||
|
pub all: CompressorParams,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Params)]
|
||||||
|
pub struct CompressorParams {
|
||||||
|
/// Drive into the compressor: scales the signal **before** detection, so it both pushes the
|
||||||
|
/// channel further into compression and feeds the downstream sum/limiter harder. Combined with
|
||||||
|
/// makeup (post-comp), this gives full per-channel input/output gain-staging.
|
||||||
|
#[id = "pregain"]
|
||||||
|
pub pre_gain_db: FloatParam,
|
||||||
|
#[id = "detect"]
|
||||||
|
pub detection: EnumParam<DetectionMode>,
|
||||||
|
#[id = "thresh"]
|
||||||
|
pub threshold_db: FloatParam,
|
||||||
|
#[id = "ratio"]
|
||||||
|
pub ratio: FloatParam,
|
||||||
|
#[id = "knee"]
|
||||||
|
pub knee_db: FloatParam,
|
||||||
|
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
|
||||||
|
#[id = "lowslope"]
|
||||||
|
pub low_slope: FloatParam,
|
||||||
|
/// Low shaper curvature (−1..1): bipolar mid-bulge, 0 = straight. +bulges up (boost quiet
|
||||||
|
/// middle), − bulges down (suppress). Endpoints (silence + knee) stay fixed.
|
||||||
|
#[id = "lowcurve"]
|
||||||
|
pub low_curve: FloatParam,
|
||||||
|
#[id = "attack"]
|
||||||
|
pub attack_ms: FloatParam,
|
||||||
|
#[id = "release"]
|
||||||
|
pub release_ms: FloatParam,
|
||||||
|
#[id = "makeup"]
|
||||||
|
pub makeup_db: FloatParam,
|
||||||
|
/// Dry/wet mix (parallel compression). 100% = fully processed, 0% = dry (a clean bypass).
|
||||||
|
#[id = "mix"]
|
||||||
|
pub mix: FloatParam,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Codename206Params {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
editor_state: EguiState::from_size(760, 520),
|
||||||
|
|
||||||
|
crossover_low_hz: FloatParam::new(
|
||||||
|
"Crossover Lo/Mid",
|
||||||
|
200.0,
|
||||||
|
FloatRange::Skewed { min: 30.0, max: 1_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||||
|
|
||||||
|
crossover_high_hz: FloatParam::new(
|
||||||
|
"Crossover Mid/Hi",
|
||||||
|
2_500.0,
|
||||||
|
FloatRange::Skewed { min: 500.0, max: 18_000.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_value_to_string(formatters::v2s_f32_hz_then_khz(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_hz_then_khz()),
|
||||||
|
|
||||||
|
look_ahead_ms: FloatParam::new(
|
||||||
|
"Look-ahead",
|
||||||
|
2.0,
|
||||||
|
FloatRange::Linear { min: 0.0, max: MAX_LOOKAHEAD_MS },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
|
output_ceiling_db: FloatParam::new(
|
||||||
|
"Ceiling",
|
||||||
|
0.0,
|
||||||
|
FloatRange::Linear { min: -24.0, max: 0.0 },
|
||||||
|
)
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
limiter_release_ms: FloatParam::new(
|
||||||
|
"Limiter Release",
|
||||||
|
100.0,
|
||||||
|
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
low: CompressorParams::default(),
|
||||||
|
mid: CompressorParams::default(),
|
||||||
|
high: CompressorParams::default(),
|
||||||
|
all: CompressorParams::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CompressorParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
pre_gain_db: FloatParam::new(
|
||||||
|
"Pre-gain",
|
||||||
|
0.0,
|
||||||
|
FloatRange::Linear { min: -24.0, max: 36.0 },
|
||||||
|
)
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
detection: EnumParam::new("Detection", DetectionMode::Peak),
|
||||||
|
|
||||||
|
threshold_db: FloatParam::new(
|
||||||
|
"Threshold",
|
||||||
|
-18.0,
|
||||||
|
FloatRange::Linear { min: -60.0, max: 0.0 },
|
||||||
|
)
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
ratio: FloatParam::new(
|
||||||
|
"Ratio",
|
||||||
|
2.0,
|
||||||
|
FloatRange::Skewed { min: 1.0, max: 20.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_value_to_string(Arc::new(|v| format!("{v:.2} : 1")))
|
||||||
|
.with_string_to_value(Arc::new(|s| {
|
||||||
|
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok())
|
||||||
|
})),
|
||||||
|
|
||||||
|
knee_db: FloatParam::new("Knee", 6.0, FloatRange::Linear { min: 0.0, max: 30.0 })
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
low_slope: FloatParam::new(
|
||||||
|
"Low Slope",
|
||||||
|
1.0,
|
||||||
|
FloatRange::Skewed { min: 0.5, max: 3.0, factor: FloatRange::skew_factor(-1.0) },
|
||||||
|
)
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
|
low_curve: FloatParam::new("Low Curve", 0.0, FloatRange::Linear { min: -1.0, max: 1.0 })
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_value_to_string(formatters::v2s_f32_percentage(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_percentage()),
|
||||||
|
|
||||||
|
attack_ms: FloatParam::new(
|
||||||
|
"Attack",
|
||||||
|
10.0,
|
||||||
|
FloatRange::Skewed { min: 0.0, max: 100.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
||||||
|
|
||||||
|
release_ms: FloatParam::new(
|
||||||
|
"Release",
|
||||||
|
100.0,
|
||||||
|
FloatRange::Skewed { min: 1.0, max: 1_000.0, factor: FloatRange::skew_factor(-2.0) },
|
||||||
|
)
|
||||||
|
.with_unit(" ms")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
makeup_db: FloatParam::new("Makeup", 0.0, FloatRange::Linear { min: -24.0, max: 24.0 })
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_unit(" dB")
|
||||||
|
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||||
|
|
||||||
|
mix: FloatParam::new("Mix", 1.0, FloatRange::Linear { min: 0.0, max: 1.0 })
|
||||||
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
|
.with_value_to_string(formatters::v2s_f32_percentage(0))
|
||||||
|
.with_string_to_value(formatters::s2v_f32_percentage()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the per-block compressor settings for one channel's params (makeup filled per sample).
|
||||||
|
pub fn build_settings(
|
||||||
|
p: &CompressorParams,
|
||||||
|
lookahead: usize,
|
||||||
|
sample_rate: f32,
|
||||||
|
) -> CompressorSettings {
|
||||||
|
CompressorSettings {
|
||||||
|
threshold_db: p.threshold_db.value(),
|
||||||
|
ratio: p.ratio.value(),
|
||||||
|
knee_db: p.knee_db.value(),
|
||||||
|
low_slope: p.low_slope.value(),
|
||||||
|
low_curve: p.low_curve.value(),
|
||||||
|
attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
|
||||||
|
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
|
||||||
|
makeup_db: 0.0,
|
||||||
|
lookahead_samples: lookahead,
|
||||||
|
use_rms: p.detection.value() == DetectionMode::Rms,
|
||||||
|
mix: p.mix.value(),
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user