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>
This commit is contained in:
+33
-29
@@ -31,9 +31,10 @@ const LEVEL_EPS: f32 = 1e-12;
|
|||||||
/// the editor gain-curve's display floor.
|
/// the editor gain-curve's display floor.
|
||||||
const LOW_ANCHOR_DB: f32 = -60.0;
|
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
|
/// Max bulge (dB) the low-shaper curvature adds at the MIDDLE of the low region, at `|low_curve|`=1.
|
||||||
/// bounded saturation so the serial composition doesn't blow up. 0 = straight line.
|
/// Bipolar: positive bulges up (boost the quiet middle), negative bulges down (suppress). Zero at
|
||||||
const LOW_CURVE_K_MAX: f32 = 0.1;
|
/// 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.
|
||||||
@@ -161,21 +162,23 @@ impl Compressor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Low-level shaper gain in dB, anchored at the silence floor ([`LOW_ANCHOR_DB`]): 0 at/below
|
/// Low-level shaper gain in dB. Anchored at BOTH the silence floor ([`LOW_ANCHOR_DB`]) and the
|
||||||
/// the floor, rising with slope `low_slope - 1` and bending toward a bounded saturation set by
|
/// knee (threshold). `low_slope` tilts the straight line between those anchors (1 = unity);
|
||||||
/// `low_curve` (0..1; 0 = straight line). This reshapes the level the compressor then sees.
|
/// `low_curve` (-1..1) bulges that line in the middle without moving either endpoint — positive
|
||||||
fn low_gain_db(level_db: f32, low_slope: f32, low_curve: f32) -> f32 {
|
/// 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;
|
let d = level_db - LOW_ANCHOR_DB;
|
||||||
if d <= 0.0 {
|
if d <= 0.0 {
|
||||||
return 0.0;
|
return 0.0;
|
||||||
}
|
}
|
||||||
let m = low_slope - 1.0;
|
let span = (threshold_db - LOW_ANCHOR_DB).max(1.0); // floor -> threshold width
|
||||||
let k = low_curve * LOW_CURVE_K_MAX;
|
let t = (d / span).min(1.0); // normalized position, clamped at the knee
|
||||||
if k <= 1e-6 {
|
// Straight line anchored at the floor (t=0 -> 0) and the knee (t=1 -> (slope-1)*span).
|
||||||
m * d // straight line (curvature off)
|
let slope_line = (low_slope - 1.0) * span * t;
|
||||||
} else {
|
// Bipolar bulge: 0 at both ends, peaks (4·t·(1-t) = 1) at the middle.
|
||||||
(m / k) * (1.0 - (-k * d).exp()) // saturates to m/k (bounded)
|
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
|
/// Full static curve, **serial**: the low shaper reshapes the level, then the compressor's
|
||||||
@@ -190,7 +193,7 @@ impl Compressor {
|
|||||||
low_slope: f32,
|
low_slope: f32,
|
||||||
low_curve: f32,
|
low_curve: f32,
|
||||||
) -> 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)
|
low + Self::comp_gain_db(level_db + low, threshold_db, ratio, knee_db)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,21 +309,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn low_shaper_is_serial_into_threshold() {
|
fn low_shaper_serial_slope_and_bipolar_bulge() {
|
||||||
// Serial: the low shaper reshapes the level, then the threshold sees the shaped level.
|
let thr = -18.0;
|
||||||
// 30 dB above the -60 floor, 12 dB below threshold (hard knee, no curve).
|
// Serial slope-only (no curve): boost lifts -30 by 30 dB to 0 dB -> 18 dB over threshold,
|
||||||
let (lvl, thr) = (-30.0, -18.0);
|
// comp pulls back (1/4 - 1)*18 = -13.5 -> net 16.5.
|
||||||
// Unity slope -> just the compressor (below threshold here -> 0).
|
assert_close(Compressor::gain_computer(-30.0, thr, 4.0, 0.0, 2.0, 0.0), 16.5, 1e-3);
|
||||||
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.
|
// 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);
|
assert_close(Compressor::gain_computer(-30.0, 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.
|
// Curvature is a BIPOLAR bulge at the middle of the low region (unity slope here).
|
||||||
let g = Compressor::gain_computer(lvl, thr, 4.0, 0.0, 2.0, 1.0);
|
let mid = -39.0; // middle of [-60, -18]
|
||||||
assert!((8.0..11.0).contains(&g), "expected bounded low boost ~9.5, got {g}");
|
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]
|
||||||
|
|||||||
+3
-2
@@ -71,7 +71,8 @@ pub struct CompressorParams {
|
|||||||
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
|
/// Low shaper slope at the silence floor (1 = unity; >1 fans up/boost, <1 fans down/cut).
|
||||||
#[id = "lowslope"]
|
#[id = "lowslope"]
|
||||||
pub low_slope: FloatParam,
|
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"]
|
#[id = "lowcurve"]
|
||||||
pub low_curve: FloatParam,
|
pub low_curve: FloatParam,
|
||||||
#[id = "attack"]
|
#[id = "attack"]
|
||||||
@@ -182,7 +183,7 @@ impl Default for CompressorParams {
|
|||||||
.with_smoother(SmoothingStyle::Linear(20.0))
|
.with_smoother(SmoothingStyle::Linear(20.0))
|
||||||
.with_value_to_string(formatters::v2s_f32_rounded(2)),
|
.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_smoother(SmoothingStyle::Linear(20.0))
|
||||||
.with_value_to_string(formatters::v2s_f32_percentage(0))
|
.with_value_to_string(formatters::v2s_f32_percentage(0))
|
||||||
.with_string_to_value(formatters::s2v_f32_percentage()),
|
.with_string_to_value(formatters::s2v_f32_percentage()),
|
||||||
|
|||||||
Reference in New Issue
Block a user