From a4c542b2d9485d45c0432a233450133d78e0f6f2 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Fri, 26 Jun 2026 00:53:40 +0900 Subject: [PATCH] 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 --- README.md | 4 +- src/dsp/compressor.rs | 97 ++++++++++++++++++++++++++++++++++------ src/editor/gain_curve.rs | 4 +- src/editor/mod.rs | 5 +++ src/lib.rs | 4 ++ src/params.rs | 21 +++++++++ 6 files changed, 120 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index e2b9588..153c689 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,13 @@ 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) - `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 diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index 15f3a01..fc1b066 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -26,6 +26,15 @@ 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; + +/// 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; + /// 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 +49,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. @@ -69,7 +83,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 } @@ -131,25 +146,54 @@ 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`. Also used by the editor's - /// gain-curve display, so it stays the single source of truth for the transfer shape. - pub 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 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 { + 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) + } + } + + /// 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, 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 @@ -199,7 +243,14 @@ impl Compressor { 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); + 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") @@ -243,19 +294,39 @@ mod tests { lookahead_samples: 0, use_rms: 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_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); + // 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}"); } #[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); } @@ -264,11 +335,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] diff --git a/src/editor/gain_curve.rs b/src/editor/gain_curve.rs index 76b93d0..a07fc61 100644 --- a/src/editor/gain_curve.rs +++ b/src/editor/gain_curve.rs @@ -26,6 +26,8 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz 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])); @@ -53,7 +55,7 @@ pub(super) fn draw(ui: &mut egui::Ui, params: &Codename206Params, selected: usiz 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); // <= 0 dB + 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))); } diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 8e029d1..9407f2a 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -47,10 +47,15 @@ pub(crate) fn create(params: Arc, meters: Arc) -> 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"); diff --git a/src/lib.rs b/src/lib.rs index 2d66c7e..3b9178f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -245,6 +245,8 @@ impl Plugin for Codename206 { } 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]; @@ -273,6 +275,8 @@ impl Plugin for Codename206 { } 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. diff --git a/src/params.rs b/src/params.rs index a8b9db2..7a2c2ea 100644 --- a/src/params.rs +++ b/src/params.rs @@ -68,6 +68,12 @@ 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 (0 = straight line, 1 = max bend toward a bounded saturation). + #[id = "lowcurve"] + pub low_curve: FloatParam, #[id = "attack"] pub attack_ms: FloatParam, #[id = "release"] @@ -168,6 +174,19 @@ impl Default for CompressorParams { .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: 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()), + attack_ms: FloatParam::new( "Attack", 10.0, @@ -207,6 +226,8 @@ 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,