Compare commits

...

2 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
6 changed files with 91 additions and 38 deletions
+33 -29
View File
@@ -31,9 +31,10 @@ const LEVEL_EPS: f32 = 1e-12;
/// the editor gain-curve's display floor.
const LOW_ANCHOR_DB: f32 = -60.0;
/// Exponential curvature (1/dB) for the low shaper at `low_curve` = 1. Bends the low gain toward a
/// bounded saturation so the serial composition doesn't blow up. 0 = straight line.
const LOW_CURVE_K_MAX: f32 = 0.1;
/// 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.
@@ -161,21 +162,23 @@ impl Compressor {
}
}
/// Low-level shaper gain in dB, anchored at the silence floor ([`LOW_ANCHOR_DB`]): 0 at/below
/// the floor, rising with slope `low_slope - 1` and bending toward a bounded saturation set by
/// `low_curve` (0..1; 0 = straight line). This reshapes the level the compressor then sees.
fn low_gain_db(level_db: f32, low_slope: f32, low_curve: f32) -> f32 {
/// 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 m = low_slope - 1.0;
let k = low_curve * LOW_CURVE_K_MAX;
if k <= 1e-6 {
m * d // straight line (curvature off)
} else {
(m / k) * (1.0 - (-k * d).exp()) // saturates to m/k (bounded)
}
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
@@ -190,7 +193,7 @@ impl Compressor {
low_slope: f32,
low_curve: f32,
) -> f32 {
let low = Self::low_gain_db(level_db, low_slope, low_curve);
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)
}
@@ -306,21 +309,22 @@ mod tests {
}
#[test]
fn low_shaper_is_serial_into_threshold() {
// Serial: the low shaper reshapes the level, then the threshold sees the shaped level.
// 30 dB above the -60 floor, 12 dB below threshold (hard knee, no curve).
let (lvl, thr) = (-30.0, -18.0);
// Unity slope -> just the compressor (below threshold here -> 0).
assert_close(Compressor::gain_computer(lvl, thr, 4.0, 0.0, 1.0, 0.0), 0.0, 1e-6);
// Boost (slope 2, straight) lifts -30 by 30 dB to 0 dB -> 18 dB over threshold, comp pulls
// back (1/4 - 1) * 18 = -13.5 -> net 30 - 13.5 = 16.5.
assert_close(Compressor::gain_computer(lvl, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
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(lvl, thr, 4.0, 0.0, 0.5, 0.0), -15.0, 1e-3);
// Curvature bounds the low gain: slope 2 + full curve saturates the boost (~9.5 dB) so it
// no longer crosses the threshold.
let g = Compressor::gain_computer(lvl, thr, 4.0, 0.0, 2.0, 1.0);
assert!((8.0..11.0).contains(&g), "expected bounded low boost ~9.5, got {g}");
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]
+45 -6
View File
@@ -1,11 +1,14 @@
//! Static gain-curve display (visualisation only): output level vs input level for the channel
//! currently selected in the plot. Plots the full channel transfer for the wet path:
//! `out = (in + pre_gain) + gain_reduction(in + pre_gain) + makeup`. Mix is not folded in (the
//! curve shows the wet/100% path, matching the GR-meter convention); output is clamped at 0 dBFS.
//! 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.
@@ -13,7 +16,7 @@ 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) {
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 {
@@ -43,6 +46,13 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz
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).
@@ -59,9 +69,38 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz
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))));
// Corner dB ticks.
// 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));
}
+1 -1
View File
@@ -84,7 +84,7 @@ pub(crate) fn create(params: Arc<Codename206Params>, meters: Arc<Meters>) -> Opt
// 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));
ui.vertical(|ui| gain_curve::draw(ui, &params, selected, &meters));
ui.vertical(|ui| plot::draw(ui, &meters, &mut state.plot));
});
ui.separator();
+4
View File
@@ -211,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;
@@ -257,6 +258,7 @@ impl Plugin for Codename206 {
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);
@@ -287,6 +289,7 @@ impl Plugin for Codename206 {
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);
@@ -327,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);
+5
View File
@@ -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);
+3 -2
View File
@@ -71,7 +71,8 @@ pub struct CompressorParams {
/// 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 (0 = straight line, 1 = max bend toward a bounded saturation).
/// 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"]
@@ -182,7 +183,7 @@ impl Default for CompressorParams {
.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: 0.0, max: 1.0 })
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()),