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>
This commit is contained in:
+84
-13
@@ -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]
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -47,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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user