Compare commits
7 Commits
eee94379bc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| abc9ea8b4f | |||
| 45735f71f7 | |||
| a4c542b2d9 | |||
| 123703d34d | |||
| b1477f7ec6 | |||
| a8be2179c3 | |||
| 42c5f7dcd2 |
@@ -34,9 +34,9 @@ Built with **Rust** + **NIH-plug** (VST3 + CLAP output) + **egui** for the UI.
|
||||
```
|
||||
Input
|
||||
└─ Crossover filterbank (Linkwitz-Riley LR4 @ each crossover freq)
|
||||
├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (bypassable)
|
||||
├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (bypassable)
|
||||
└─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (bypassable)
|
||||
├─ Band 1 (low) → pre-gain → look-ahead delay → compressor VCA → makeup ─┐ (dry/wet mix)
|
||||
├─ Band 2 (mid) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||||
└─ Band 3 (high) → pre-gain → look-ahead delay → compressor VCA → makeup ─┤ (dry/wet mix)
|
||||
│
|
||||
Sum of bands ◄─────────────────────────────────────────────────────------┘
|
||||
└─ 'All' channel → pre-gain → look-ahead delay → compressor VCA → makeup
|
||||
@@ -71,7 +71,7 @@ a first-class mode, not an afterthought.
|
||||
### 'All' Aggregate Channel
|
||||
- Structurally **identical to a per-band compressor** — reuse the same comp/lim code/params, just fed the summed signal instead of a filtered band
|
||||
- Runs after the three bands are summed, before the output brickwall limiter
|
||||
- Bands are individually bypassable; with all three bypassed the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim
|
||||
- Bands have a per-channel dry/wet **mix** (parallel compression); at 0% (or all three dry) the (phase-coherent) crossover sum equals the dry input, so the 'All' channel alone acts as a full-band comp/lim
|
||||
- Has its own look-ahead; the plugin reports a single **constant** total latency (the fixed band + 'All' look-ahead), set once — see Latency below
|
||||
### Output Limiter
|
||||
- Brickwall, ceiling = 0 dBFS or user-defined (`output_ceiling`). Look-ahead + sliding-max peak detection + a ceiling clamp guarantee the output never exceeds the ceiling
|
||||
@@ -100,13 +100,15 @@ a first-class mode, not an afterthought.
|
||||
### Per-Channel Compressor (× 4: low, mid, high, **all** — one `#[nested]` params struct reused)
|
||||
- `pre_gain_db` — drive into the compressor (−24…+36 dB, smoothed)
|
||||
- `detection` — peak / RMS level detection
|
||||
- `low_slope` — low-level shaper slope at the silence floor (1 = unity, >1 fans up/boost, <1 fans down/cut). **Serial**: reshapes the level *before* the threshold, so a boost can lift quiet material up into compression
|
||||
- `low_curve` — bends the low shaper toward a bounded saturation (0% = straight line) so the serial composition doesn't run away
|
||||
- `threshold_db`
|
||||
- `ratio` — 1.0 (off) to ∞ (limiting)
|
||||
- `knee_db` — soft knee width
|
||||
- `attack_ms`
|
||||
- `release_ms`
|
||||
- `knee_db` — soft knee width
|
||||
- `makeup_db` — makeup gain (−24…+24 dB)
|
||||
- `bypass` — per-channel bypass (bypassing low+mid+high = simple full-band comp via the 'all' channel)
|
||||
- `mix` — per-channel dry/wet mix (parallel compression); 0% = dry (a clean bypass), 100% = fully processed. Bands at 0% → simple full-band comp via the 'all' channel
|
||||
|
||||
The 'all' channel uses the same struct so its UI and DSP are identical to a band; it just sits after the band sum.
|
||||
---
|
||||
@@ -130,21 +132,22 @@ src/
|
||||
oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only)
|
||||
```
|
||||
|
||||
The editor's meters and plot are currently drawn directly with egui's `Painter` inline in
|
||||
`editor.rs`. When the UI is redesigned (gain curve, draggable crossover, real layout), the plan is
|
||||
to split it into a widget module so each visualiser is self-contained and reusable:
|
||||
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 # editor assembly + layout (replaces editor.rs)
|
||||
widgets/
|
||||
meter.rs # |L | GR | R| level + gain-reduction cluster (extract from editor.rs)
|
||||
plot.rs # rolling in/out/GR scope (extract from editor.rs)
|
||||
gain_curve.rs # static gain-curve display per channel (threshold/ratio/knee) — planned
|
||||
crossover.rs # frequency display with draggable crossover handles — planned
|
||||
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.
|
||||
|
||||
---
|
||||
@@ -195,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.
|
||||
|
||||
**Status (2026-06-23):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover →
|
||||
per-band pre-gain + compressors (peak/RMS) → 'All' channel → **true-peak brickwall limiter** (4×
|
||||
oversampled detection). `lib.rs` has been split into `params.rs`, `editor.rs`, and `meters.rs`.
|
||||
Stage 6 metering is underway: per-channel **|L | GR | R| meters**, a **latching ceiling lamp**, and
|
||||
a **rolling in/out/gain-reduction plot** (per-channel tabs + flow-speed selector). **Next: gain-curve
|
||||
display and draggable crossover handles, then replace the placeholder slider UI.**
|
||||
**Status (2026-06-25):** Stages 1–4 done — the full signal chain works: 3-band LR4 crossover →
|
||||
per-band pre-gain + compressors (peak/RMS) → per-channel dry/wet mix → 'All' channel → **true-peak
|
||||
brickwall limiter** (4× oversampled detection). `lib.rs` is split into `params.rs`, `meters.rs`, and
|
||||
an `editor/` widget module. Stage 6 visualisers are essentially complete: per-channel **|L | GR | R|
|
||||
meters** + **per-channel ceiling lamps**, a **rolling in/out/GR plot** (200 Hz ring feed, flow-speed,
|
||||
ceiling-hit markers), **draggable crossover handles**, and a **static gain-curve display**. **Next:
|
||||
replace the placeholder slider columns with the real UI layout.**
|
||||
|
||||
### Stage 1 — Skeleton plugin ✅
|
||||
- [x] NIH-plug "passthrough" compiling and loading in DAW
|
||||
@@ -230,14 +234,14 @@ display and draggable crossover handles, then replace the placeholder slider UI.
|
||||
### Stage 5 — Basic egui UI *(basic version done early)*
|
||||
- [x] Add `nih_plug_egui` editor
|
||||
- [x] Sliders for all current parameters (`ParamSlider` grid)
|
||||
- [x] Per-band bypass toggles
|
||||
- [x] Per-channel dry/wet mix (parallel compression; replaced the bypass toggle)
|
||||
- [x] Confirm UI controls update DSP in real time
|
||||
### Stage 6 — Custom visualisations
|
||||
- [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
|
||||
- [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
|
||||
- [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector)
|
||||
- [ ] Static gain-curve display per band (threshold/ratio/knee)
|
||||
- [ ] 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
|
||||
---
|
||||
|
||||
|
||||
+112
-36
@@ -26,6 +26,16 @@ const MAX_CHANNELS: usize = 2;
|
||||
/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels.
|
||||
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
|
||||
/// promoted to a parameter later.
|
||||
const RMS_WINDOW_MS: f32 = 5.0;
|
||||
@@ -40,6 +50,11 @@ pub struct CompressorSettings {
|
||||
pub threshold_db: f32,
|
||||
pub ratio: 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`]).
|
||||
pub attack_coef: f32,
|
||||
/// One-pole coefficient for the release ramp.
|
||||
@@ -50,7 +65,9 @@ pub struct CompressorSettings {
|
||||
pub lookahead_samples: usize,
|
||||
/// `true` = RMS detection (running power average), `false` = naive sample peak.
|
||||
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 {
|
||||
@@ -67,7 +84,8 @@ pub struct Compressor {
|
||||
mean_sq: 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)
|
||||
yl: f32, // attack-smoothed output
|
||||
}
|
||||
@@ -129,24 +147,56 @@ impl Compressor {
|
||||
}
|
||||
}
|
||||
|
||||
/// Static compressor curve. Returns gain reduction in dB (<= 0) for an input `level_db`.
|
||||
/// Quadratic soft knee of width `knee_db`, centred on `threshold_db`.
|
||||
fn gain_computer(level_db: f32, threshold_db: f32, ratio: f32, knee_db: f32) -> f32 {
|
||||
/// Pure compressor transfer (threshold / ratio / quadratic soft knee). Returns gain reduction
|
||||
/// in dB (<= 0) for an input `level_db`.
|
||||
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 over = level_db - threshold_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
|
||||
slope * x * x / (2.0 * knee_db)
|
||||
} else if over > 0.0 {
|
||||
// Above the knee (also covers the hard-knee case): linear region.
|
||||
slope * over
|
||||
} else {
|
||||
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).
|
||||
pub fn latency(&self) -> u32 {
|
||||
self.fixed_delay as u32
|
||||
@@ -188,32 +238,37 @@ impl Compressor {
|
||||
peak = peak.max(self.delay[ch][det_pos].abs());
|
||||
}
|
||||
|
||||
// 4) Gain computer + ballistics. On bypass we keep the delay aligned (so toggling
|
||||
// bypass doesn't shift timing) but apply unity gain and no makeup.
|
||||
let gain_lin = if set.bypass {
|
||||
1.0
|
||||
} else {
|
||||
// RMS = running mean of the linked squared level over a fixed window. Updated
|
||||
// whenever active (regardless of mode) so switching peak<->RMS is seamless.
|
||||
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
|
||||
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
||||
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
|
||||
// Desired attenuation in dB, as a positive quantity.
|
||||
let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db);
|
||||
// 4) Gain computer + ballistics. The detector ALWAYS runs (even at mix 0) so metering
|
||||
// reflects the wet gain reduction regardless of the dry/wet blend.
|
||||
// RMS = running mean of the linked squared level over a fixed window. Updated whenever
|
||||
// active (regardless of mode) so switching peak<->RMS is seamless.
|
||||
self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak;
|
||||
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
|
||||
let level_db = 20.0 * (detector + LEVEL_EPS).log10();
|
||||
// Desired attenuation in dB, as a positive quantity.
|
||||
let target = -Self::gain_computer(
|
||||
level_db,
|
||||
set.threshold_db,
|
||||
set.ratio,
|
||||
set.knee_db,
|
||||
set.low_slope,
|
||||
set.low_curve,
|
||||
);
|
||||
|
||||
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation:
|
||||
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
|
||||
// yl = attack-smoothed y1
|
||||
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;
|
||||
// Smooth, decoupled peak detector (Giannoulis eq. 17–18) on the attenuation:
|
||||
// y1 = max(target, release-smoothed y1) (fast up / slow down "peak hold")
|
||||
// yl = attack-smoothed y1
|
||||
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;
|
||||
|
||||
let total_db = set.makeup_db - self.yl;
|
||||
10.0f32.powf(total_db / 20.0)
|
||||
};
|
||||
let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 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 {
|
||||
output[ch] = self.delay[ch][out_pos] * gain_lin;
|
||||
output[ch] = self.delay[ch][out_pos] * blend;
|
||||
}
|
||||
|
||||
// 6) Advance the write head.
|
||||
@@ -241,20 +296,41 @@ mod tests {
|
||||
makeup_db: 0.0,
|
||||
lookahead_samples: 0,
|
||||
use_rms: false,
|
||||
bypass: false,
|
||||
mix: 1.0,
|
||||
low_slope: 1.0,
|
||||
low_curve: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_threshold_is_untouched() {
|
||||
// -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]
|
||||
fn above_knee_follows_ratio() {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -263,11 +339,11 @@ mod tests {
|
||||
// At the upper knee edge the soft-knee and linear formulas must agree.
|
||||
let (t, ratio, knee) = (0.0, 4.0, 6.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);
|
||||
assert_close(knee_val, linear_val, 1e-4);
|
||||
// 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]
|
||||
@@ -336,7 +412,7 @@ mod tests {
|
||||
for &l in &[0usize, d / 2, d] {
|
||||
comp.reset();
|
||||
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;
|
||||
|
||||
let mut out = [0.0f32];
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
+14
-2
@@ -20,6 +20,7 @@ use crate::params::{Codename206Params, CompressorParams};
|
||||
use crate::Codename206;
|
||||
|
||||
mod crossover;
|
||||
mod gain_curve;
|
||||
mod meter;
|
||||
mod plot;
|
||||
|
||||
@@ -46,10 +47,15 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
|
||||
|
||||
// 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");
|
||||
@@ -62,7 +68,8 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
|
||||
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));
|
||||
ui.label("Mix");
|
||||
ui.add(widgets::ParamSlider::for_param(&p.mix, setter));
|
||||
};
|
||||
|
||||
// Resizable window; vertical scroll so every control stays reachable even when the
|
||||
@@ -74,7 +81,12 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
|
||||
ui.heading(Codename206::NAME);
|
||||
meter::draw(ui, &meters, &mut state.meter);
|
||||
ui.separator();
|
||||
plot::draw(ui, &meters, &mut state.plot);
|
||||
// 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();
|
||||
|
||||
+32
-7
@@ -7,7 +7,7 @@
|
||||
//! (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, Sense, Stroke};
|
||||
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};
|
||||
@@ -26,6 +26,8 @@ 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,
|
||||
}
|
||||
@@ -36,6 +38,7 @@ impl Default for PlotHistory {
|
||||
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,
|
||||
}
|
||||
@@ -43,13 +46,14 @@ impl Default for PlotHistory {
|
||||
}
|
||||
|
||||
impl PlotHistory {
|
||||
/// Append one column of (in_db, out_db, gr_db) per channel.
|
||||
fn push(&mut self, samples: &[(f32, f32, f32); NUM_CHANNELS]) {
|
||||
/// 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);
|
||||
}
|
||||
@@ -58,8 +62,8 @@ impl PlotHistory {
|
||||
/// 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).
|
||||
selected: usize,
|
||||
/// 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,
|
||||
@@ -67,6 +71,8 @@ pub(super) struct PlotState {
|
||||
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,
|
||||
}
|
||||
@@ -79,6 +85,7 @@ impl Default for PlotState {
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -97,17 +104,20 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
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| {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
});
|
||||
@@ -133,6 +143,7 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
// 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();
|
||||
@@ -197,5 +208,19 @@ pub(super) fn draw(ui: &mut egui::Ui, meters: &Meters, state: &mut PlotState) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-3
@@ -46,11 +46,16 @@ struct Codename206 {
|
||||
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,
|
||||
}
|
||||
|
||||
/// Limiter gain reduction (dB) above which a plot bucket is flagged as hitting the ceiling.
|
||||
const CEILING_HIT_GR_DB: f32 = 0.1;
|
||||
|
||||
impl Default for Codename206 {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -64,6 +69,7 @@ impl Default for Codename206 {
|
||||
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,
|
||||
}
|
||||
@@ -164,6 +170,7 @@ impl Plugin for Codename206 {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -204,6 +211,7 @@ impl Plugin for Codename206 {
|
||||
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;
|
||||
|
||||
@@ -237,6 +245,9 @@ impl Plugin for Codename206 {
|
||||
band_in[b][ch] *= pre;
|
||||
}
|
||||
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]);
|
||||
for ch in 0..n {
|
||||
summed[ch] += band_out[b][ch];
|
||||
@@ -245,7 +256,9 @@ impl Plugin for Codename206 {
|
||||
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();
|
||||
let g = if band_set[b].bypass { 0.0 } else { self.comps[b].gain_reduction_db() };
|
||||
// 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);
|
||||
@@ -263,6 +276,9 @@ impl Plugin for Codename206 {
|
||||
summed[ch] *= all_pre;
|
||||
}
|
||||
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);
|
||||
|
||||
// Output brickwall limiter.
|
||||
@@ -272,7 +288,8 @@ impl Plugin for Codename206 {
|
||||
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 = if all_set.bypass { 0.0 } else { self.comps[ALL].gain_reduction_db() };
|
||||
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);
|
||||
@@ -284,14 +301,17 @@ impl Plugin for Codename206 {
|
||||
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 {
|
||||
self.meters.scope.push(&self.scope_in, &self.scope_out, &self.scope_gr);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -310,6 +330,7 @@ impl Plugin for Codename206 {
|
||||
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);
|
||||
|
||||
+24
-5
@@ -26,6 +26,9 @@ pub struct Meters {
|
||||
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],
|
||||
@@ -40,6 +43,7 @@ impl Default for Meters {
|
||||
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(),
|
||||
@@ -55,6 +59,7 @@ impl Meters {
|
||||
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);
|
||||
@@ -85,6 +90,9 @@ pub struct ScopeRing {
|
||||
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,
|
||||
}
|
||||
@@ -92,7 +100,13 @@ pub struct ScopeRing {
|
||||
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(), write: AtomicU64::new(0) }
|
||||
Self {
|
||||
in_lin: make(),
|
||||
out_lin: make(),
|
||||
gr_db: make(),
|
||||
hit: (0..RING_N).map(|_| AtomicF32::new(0.0)).collect(),
|
||||
write: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,14 +117,17 @@ impl ScopeRing {
|
||||
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 base = (w as usize % RING_N) * NUM_CHANNELS;
|
||||
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);
|
||||
@@ -121,7 +138,7 @@ impl ScopeRing {
|
||||
pub fn drain(
|
||||
&self,
|
||||
cursor: &mut u64,
|
||||
mut on_bucket: impl FnMut(&[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS], &[f32; NUM_CHANNELS]),
|
||||
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 {
|
||||
@@ -136,13 +153,15 @@ impl ScopeRing {
|
||||
let mut out_buf = [0.0f32; NUM_CHANNELS];
|
||||
let mut gr_buf = [0.0f32; NUM_CHANNELS];
|
||||
while *cursor < w {
|
||||
let base = (*cursor as usize % RING_N) * NUM_CHANNELS;
|
||||
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);
|
||||
}
|
||||
on_bucket(&in_buf, &out_buf, &gr_buf);
|
||||
let hit = self.hit[slot].load(Ordering::Relaxed);
|
||||
on_bucket(&in_buf, &out_buf, &gr_buf, hit);
|
||||
*cursor += 1;
|
||||
}
|
||||
}
|
||||
|
||||
+31
-5
@@ -68,14 +68,22 @@ pub struct CompressorParams {
|
||||
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,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
/// Dry/wet mix (parallel compression). 100% = fully processed, 0% = dry (a clean bypass).
|
||||
#[id = "mix"]
|
||||
pub mix: FloatParam,
|
||||
}
|
||||
|
||||
impl Default for Codename206Params {
|
||||
@@ -163,10 +171,23 @@ impl Default for CompressorParams {
|
||||
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 })
|
||||
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,
|
||||
@@ -188,7 +209,10 @@ impl Default for CompressorParams {
|
||||
.with_unit(" dB")
|
||||
.with_value_to_string(formatters::v2s_f32_rounded(1)),
|
||||
|
||||
bypass: BoolParam::new("Bypass", false),
|
||||
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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -203,11 +227,13 @@ pub fn build_settings(
|
||||
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,
|
||||
bypass: p.bypass.value(),
|
||||
mix: p.mix.value(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user