Compare commits

...

5 Commits

Author SHA1 Message Date
Mikkeli Matlock abc9ea8b4f feat: live operating-point fill + -6 dB refs on the gain curve
Add a per-channel input_level meter (post pre-gain, peak-with-decay) and use it
to shade the gain curve: a translucent fill under the curve from the floor up to
the current input, its right edge riding the curve (width = input, height =
output), plus a dot at the operating point. Add -6 dBFS reference lines on both
axes with a tick label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:19:16 +09:00
Mikkeli Matlock 45735f71f7 feat: bipolar mid-bulge low-curve anchored at silence and the knee
Rework Low Curve from a one-sided saturating bend into a bipolar mid-bulge: the
low region is anchored at BOTH the silence floor and the knee, Low Slope tilts
the straight line between them, and Low Curve (-1..1) bows that line in the
middle (4*t*(1-t), peak +/-12 dB) without moving either endpoint. Positive
bulges up (boost the quiet middle), negative down (suppress). Still serial.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:19:04 +09:00
Mikkeli Matlock a4c542b2d9 feat: serial low-level shaper (Low Slope + Low Curve) before the compressor
Add a per-channel below-threshold shaper composed in series ahead of the comp:
gain = low_shape(level) + comp(level + low_shape(level)). The compressor's
threshold now sees the shaped level, so a Low Slope boost lifts quiet material
up into compression (and a cut pulls it out). Anchored at the -60 dB silence
floor. Low Curve bends the shaper toward a bounded saturation so the serial
composition doesn't blow up (0 = straight line).

gain_computer split into comp_gain_db + low_gain_db and composed; shared with
the editor gain-curve display. Slider order rearranged to read in signal order
(pre-gain -> low shaper -> compressor -> output). Defaults (slope 1, curve 0)
reproduce the plain compressor; 17 tests pass.

Known: the bipolar behaviour isn't final yet (milestone commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 00:53:40 +09:00
Mikkeli Matlock 123703d34d feat: raise max knee width to 30 dB
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:17 +09:00
Mikkeli Matlock b1477f7ec6 feat: static gain-curve display for the selected channel
Add editor/gain_curve.rs: a square panel beside the scrolling plot showing the
channel transfer (out vs in, -60..0 dB) for whichever channel the plot tab
selects. Plots the full wet path — out = (in + pre_gain) + gain_reduction + makeup
— using the shared Compressor::gain_computer (now pub) so it matches the DSP and
the GR meter. Unity-reference diagonal + threshold marker; mix not folded in.

Update README structure/status to reflect the editor/ widget module and the
completed Stage 6 visualisers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 15:24:08 +09:00
8 changed files with 266 additions and 34 deletions
+22 -18
View File
@@ -100,11 +100,13 @@ a first-class mode, not an afterthought.
### 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) - `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_db` — makeup gain (24…+24 dB)
- `mix` — per-channel dry/wet mix (parallel compression); 0% = dry (a clean bypass), 100% = fully processed. Bands at 0% → simple full-band comp via the 'all' channel - `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
@@ -130,21 +132,22 @@ src/
oversampler.rs # ✅ 4x polyphase oversampler for true-peak detection (detection-only) 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 The editor lives in an `editor/` module — one file per visualiser widget (each owns its GUI
`editor.rs`. When the UI is redesigned (gain curve, draggable crossover, real layout), the plan is state), with `mod.rs` as the aggregator/layout. Drawn directly with egui's `Painter`.
to split it into a widget module so each visualiser is self-contained and reusable:
``` ```
src/ src/
editor/ editor/
mod.rs # editor assembly + layout (replaces editor.rs) mod.rs # aggregator: create(), EditorState, layout, placeholder slider columns
widgets/ meter.rs # |L | GR | R| level + gain-reduction bars + per-channel ceiling lamp
meter.rs # |L | GR | R| level + gain-reduction cluster (extract from editor.rs) plot.rs # rolling in/out/GR scope (200 Hz ring feed) + ceiling-hit markers
plot.rs # rolling in/out/GR scope (extract from editor.rs) crossover.rs # log-freq strip with draggable crossover handles + number boxes
gain_curve.rs # static gain-curve display per channel (threshold/ratio/knee) — planned gain_curve.rs # static gain-curve display (out vs in) for the selected channel
crossover.rs # frequency display with draggable crossover handles — planned
``` ```
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. 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. Work through these stages in order — each stage produces a loadable, audible plugin.
**Status (2026-06-23):** Stages 14 done — the full signal chain works: 3-band LR4 crossover → **Status (2026-06-25):** Stages 14 done — the full signal chain works: 3-band LR4 crossover →
per-band pre-gain + compressors (peak/RMS) → 'All' channel → **true-peak brickwall limiter** (4× per-band pre-gain + compressors (peak/RMS) → per-channel dry/wet mix → 'All' channel → **true-peak
oversampled detection). `lib.rs` has been split into `params.rs`, `editor.rs`, and `meters.rs`. brickwall limiter** (4× oversampled detection). `lib.rs` is split into `params.rs`, `meters.rs`, and
Stage 6 metering is underway: per-channel **|L | GR | R| meters**, a **latching ceiling lamp**, and an `editor/` widget module. Stage 6 visualisers are essentially complete: per-channel **|L | GR | R|
a **rolling in/out/gain-reduction plot** (per-channel tabs + flow-speed selector). **Next: gain-curve meters** + **per-channel ceiling lamps**, a **rolling in/out/GR plot** (200 Hz ring feed, flow-speed,
display and draggable crossover handles, then replace the placeholder slider UI.** 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
@@ -236,8 +240,8 @@ display and draggable crossover handles, then replace the placeholder slider UI.
- [x] Per-channel level meters (output level, `|L | GR | R|` cluster) - [x] Per-channel level meters (output level, `|L | GR | R|` cluster)
- [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp - [x] Per-channel gain-reduction meters (vertical bars) + latching ceiling lamp
- [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector) - [x] Rolling in/out/gain-reduction plot (per-channel tabs, flow-speed selector)
- [ ] Static gain-curve display per band (threshold/ratio/knee) - [x] Static gain-curve display (out vs in; includes pre-gain + makeup) for the selected channel
- [ ] Draggable crossover handles on a frequency display - [x] Draggable crossover handles on a log-frequency display (with number boxes)
- [ ] Replace the placeholder slider columns with the real UI - [ ] Replace the placeholder slider columns with the real UI
--- ---
+88 -12
View File
@@ -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.
@@ -69,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
} }
@@ -131,24 +147,56 @@ 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
@@ -198,7 +246,14 @@ impl Compressor {
let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak }; let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak };
let level_db = 20.0 * (detector + LEVEL_EPS).log10(); let level_db = 20.0 * (detector + LEVEL_EPS).log10();
// Desired attenuation in dB, as a positive quantity. // Desired attenuation in dB, as a positive quantity.
let target = -Self::gain_computer(level_db, set.threshold_db, set.ratio, set.knee_db); 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. 1718) on the attenuation: // Smooth, decoupled peak detector (Giannoulis eq. 1718) 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")
@@ -242,19 +297,40 @@ mod tests {
lookahead_samples: 0, lookahead_samples: 0,
use_rms: false, use_rms: false,
mix: 1.0, 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);
} }
@@ -263,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]
+106
View File
@@ -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 => &params.low,
1 => &params.mid,
2 => &params.high,
_ => &params.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));
}
+12 -1
View File
@@ -20,6 +20,7 @@ use crate::params::{Codename206Params, CompressorParams};
use crate::Codename206; use crate::Codename206;
mod crossover; mod crossover;
mod gain_curve;
mod meter; mod meter;
mod plot; 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). // One column of controls for a single compressor channel (placeholder layout).
let band_col = |ui: &mut egui::Ui, title: &str, p: &CompressorParams| { 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.strong(title);
ui.label("Pre-gain"); ui.label("Pre-gain");
ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter)); ui.add(widgets::ParamSlider::for_param(&p.pre_gain_db, setter));
ui.add(widgets::ParamSlider::for_param(&p.detection, 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.label("Threshold");
ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter)); ui.add(widgets::ParamSlider::for_param(&p.threshold_db, setter));
ui.label("Ratio"); ui.label("Ratio");
@@ -75,7 +81,12 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
ui.heading(Codename206::NAME); ui.heading(Codename206::NAME);
meter::draw(ui, &meters, &mut state.meter); meter::draw(ui, &meters, &mut state.meter);
ui.separator(); 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, &params, selected, &meters));
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
});
ui.separator(); ui.separator();
crossover::draw(ui, &params, setter); crossover::draw(ui, &params, setter);
ui.separator(); ui.separator();
+2 -2
View File
@@ -62,8 +62,8 @@ impl PlotHistory {
/// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the /// GUI-side state for the plot: selected channel, history ring, ring-drain cursor, and the
/// column being assembled from drained buckets. /// column being assembled from drained buckets.
pub(super) struct PlotState { pub(super) struct PlotState {
/// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). /// Channel shown in the plot (0..NUM_CHANNELS: low/mid/high/all). Also drives the gain curve.
selected: usize, pub(super) selected: usize,
history: PlotHistory, history: PlotHistory,
/// Seconds of history shown across the full plot width — the flow speed (smaller = faster). /// Seconds of history shown across the full plot width — the flow speed (smaller = faster).
window_s: f64, window_s: f64,
+8
View File
@@ -211,6 +211,7 @@ impl Plugin for Codename206 {
let num_samples = buffer.samples(); let num_samples = buffer.samples();
let mut lvl_l = [0.0f32; meters::NUM_CHANNELS]; let mut lvl_l = [0.0f32; meters::NUM_CHANNELS];
let mut lvl_r = [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 gr = [0.0f32; meters::NUM_CHANNELS];
let mut lim_gr = 0.0f32; let mut lim_gr = 0.0f32;
@@ -245,6 +246,8 @@ impl Plugin for Codename206 {
} }
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].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];
@@ -255,6 +258,7 @@ impl Plugin for Codename206 {
let out_r = band_out[b][r].abs(); let out_r = band_out[b][r].abs();
// Wet gain reduction (what the comp computes), independent of the mix. // Wet gain reduction (what the comp computes), independent of the mix.
let g = self.comps[b].gain_reduction_db(); 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_l[b] = lvl_l[b].max(out_l);
lvl_r[b] = lvl_r[b].max(out_r); lvl_r[b] = lvl_r[b].max(out_r);
gr[b] = gr[b].max(g); gr[b] = gr[b].max(g);
@@ -273,6 +277,8 @@ impl Plugin for Codename206 {
} }
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.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.
@@ -283,6 +289,7 @@ impl Plugin for Codename206 {
let out_l = out_frame[0].abs(); let out_l = out_frame[0].abs();
let out_r = out_frame[r].abs(); let out_r = out_frame[r].abs();
let g = 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_l[ALL] = lvl_l[ALL].max(out_l);
lvl_r[ALL] = lvl_r[ALL].max(out_r); lvl_r[ALL] = lvl_r[ALL].max(out_r);
gr[ALL] = gr[ALL].max(g); gr[ALL] = gr[ALL].max(g);
@@ -323,6 +330,7 @@ impl Plugin for Codename206 {
for i in 0..meters::NUM_CHANNELS { 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_l[i], lvl_l[i], w);
meters::decay_store(&self.meters.level_r[i], lvl_r[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.gain_reduction_db[i], gr[i], w);
} }
meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w); meters::decay_store(&self.meters.limiter_gr_db, lim_gr, w);
+5
View File
@@ -26,6 +26,9 @@ pub struct Meters {
pub level_l: [AtomicF32; NUM_CHANNELS], pub level_l: [AtomicF32; NUM_CHANNELS],
/// Right output level per channel (== left for mono signals). /// Right output level per channel (== left for mono signals).
pub level_r: [AtomicF32; NUM_CHANNELS], 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 /// Compressor gain reduction per channel in **dB (>= 0)**. Mono by design — detection is
/// stereo-linked, so the same gain applies to both channels. /// stereo-linked, so the same gain applies to both channels.
pub gain_reduction_db: [AtomicF32; NUM_CHANNELS], pub gain_reduction_db: [AtomicF32; NUM_CHANNELS],
@@ -40,6 +43,7 @@ impl Default for Meters {
Self { Self {
level_l: std::array::from_fn(|_| AtomicF32::new(0.0)), level_l: std::array::from_fn(|_| AtomicF32::new(0.0)),
level_r: 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)), gain_reduction_db: std::array::from_fn(|_| AtomicF32::new(0.0)),
limiter_gr_db: AtomicF32::new(0.0), limiter_gr_db: AtomicF32::new(0.0),
scope: ScopeRing::default(), scope: ScopeRing::default(),
@@ -55,6 +59,7 @@ impl Meters {
for i in 0..NUM_CHANNELS { for i in 0..NUM_CHANNELS {
self.level_l[i].store(0.0, Ordering::Relaxed); self.level_l[i].store(0.0, Ordering::Relaxed);
self.level_r[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.gain_reduction_db[i].store(0.0, Ordering::Relaxed);
} }
self.limiter_gr_db.store(0.0, Ordering::Relaxed); self.limiter_gr_db.store(0.0, Ordering::Relaxed);
+23 -1
View File
@@ -68,6 +68,13 @@ pub struct CompressorParams {
pub ratio: FloatParam, pub ratio: FloatParam,
#[id = "knee"] #[id = "knee"]
pub knee_db: FloatParam, 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"] #[id = "attack"]
pub attack_ms: FloatParam, pub attack_ms: FloatParam,
#[id = "release"] #[id = "release"]
@@ -164,10 +171,23 @@ impl Default for CompressorParams {
s.split(':').next().and_then(|x| x.trim().parse::<f32>().ok()) 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_unit(" dB")
.with_value_to_string(formatters::v2s_f32_rounded(1)), .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_ms: FloatParam::new(
"Attack", "Attack",
10.0, 10.0,
@@ -207,6 +227,8 @@ pub fn build_settings(
threshold_db: p.threshold_db.value(), threshold_db: p.threshold_db.value(),
ratio: p.ratio.value(), ratio: p.ratio.value(),
knee_db: p.knee_db.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), attack_coef: Compressor::time_to_coef(p.attack_ms.value(), sample_rate),
release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate), release_coef: Compressor::time_to_coef(p.release_ms.value(), sample_rate),
makeup_db: 0.0, makeup_db: 0.0,