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:
Mikkeli Matlock
2026-06-26 00:53:40 +09:00
parent 123703d34d
commit a4c542b2d9
6 changed files with 120 additions and 15 deletions
+3 -1
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)
- `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
+84 -13
View File
@@ -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. 1718) 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]
+3 -1
View File
@@ -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)));
}
+5
View File
@@ -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");
+4
View File
@@ -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.
+21
View File
@@ -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,