M9.11: Audio effects (Compressor, Distortion, Gate)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 20:39:38 -04:00
co-authored by Claude Opus 5.5
parent 4bcf1f447c
commit 97cf7e2c02
2 changed files with 249 additions and 4 deletions
+238 -3
View File
@@ -1,5 +1,5 @@
//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Delay, Flange & Chorus,
//! High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone.
//! Audio effects (Effect > Audio): Backwards, Bass & Treble, Compressor, Delay, Distortion,
//! Flange & Chorus, Gate, High-Low Pass, Modulator, Parametric EQ, Reverb, Stereo Mixer and Tone.
//!
//! They are ordinary effect instances on a layer (so their parameters animate and show in
//! Effect Controls), pass video through unchanged, and are applied to the layer's audio by the
@@ -12,7 +12,10 @@
//! DSP is textbook: second-order sections use the public RBJ "Audio EQ Cookbook" formulas;
//! Reverb is a Schroeder/Moorer network (parallel low-pass feedback combs into series
//! all-passes, as in the published Freeverb description); chorus/flange/vibrato are modulated
//! fractional delay lines.
//! fractional delay lines. Compressor and Gate are feed-forward dynamics processors on a
//! stereo-linked peak envelope (one-pole attack/release smoothing, the textbook quadratic soft-knee
//! gain computer); Distortion is a static waveshaper (soft clip, hard clip, fold-back, bit crush)
//! followed by a one-pole tone filter.
use std::f64::consts::PI;
@@ -60,6 +63,20 @@ pub fn specs() -> Vec<EffectSpec> {
p("treble", "Treble", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
],
),
spec(
"ec.audio.compressor",
"Compressor",
vec![
p("threshold", "Threshold (dB)", num(-20.0), slider(-60.0, 0.0, -60.0, 0.0, 2)),
p("ratio", "Ratio", num(4.0), slider(1.0, 30.0, 1.0, 20.0, 2)),
p("attack", "Attack (ms)", num(10.0), slider(0.0, 500.0, 0.0, 200.0, 2)),
p("release", "Release (ms)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
p("knee", "Knee (dB)", num(0.0), slider(0.0, 24.0, 0.0, 24.0, 2)),
p("makeupGain", "Makeup Gain (dB)", num(0.0), slider(-24.0, 36.0, 0.0, 24.0, 2)),
pp("dryOut", "Dry Out", pct(0.0)),
pp("wetOut", "Wet Out", pct(100.0)),
],
),
spec(
"ec.audio.delay",
"Delay",
@@ -71,6 +88,19 @@ pub fn specs() -> Vec<EffectSpec> {
pp("wetOut", "Wet Out", pct(75.0)),
],
),
spec(
"ec.audio.distortion",
"Distortion",
vec![
p("distortionType", "Distortion Type", Value::Enum(0), popup(&["Soft Clip", "Hard Clip", "Fold Back", "Bit Crush"])),
p("drive", "Drive (dB)", num(12.0), slider(0.0, 48.0, 0.0, 48.0, 2)),
p("bitDepth", "Bit Depth", num(8.0), slider(1.0, 16.0, 1.0, 16.0, 0)),
p("tone", "Tone (Hz)", num(8000.0), slider(200.0, 20000.0, 200.0, 20000.0, 1)),
p("outputGain", "Output Gain (dB)", num(-6.0), slider(-48.0, 12.0, -24.0, 12.0, 2)),
pp("dryOut", "Dry Out", pct(0.0)),
pp("wetOut", "Wet Out", pct(100.0)),
],
),
spec(
"ec.audio.flangechorus",
"Flange & Chorus",
@@ -86,6 +116,17 @@ pub fn specs() -> Vec<EffectSpec> {
pp("wetOut", "Wet Out", pct(50.0)),
],
),
spec(
"ec.audio.gate",
"Gate",
vec![
p("threshold", "Threshold (dB)", num(-40.0), slider(-90.0, 0.0, -80.0, 0.0, 2)),
p("attack", "Attack (ms)", num(1.0), slider(0.0, 500.0, 0.0, 100.0, 2)),
p("hold", "Hold (ms)", num(50.0), slider(0.0, 5000.0, 0.0, 500.0, 2)),
p("release", "Release (ms)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
p("range", "Range (dB)", num(-80.0), slider(-120.0, 0.0, -90.0, 0.0, 2)),
],
),
spec(
"ec.audio.highlowpass",
"High-Low Pass",
@@ -167,6 +208,14 @@ pub fn preroll(id: &str, params: &Params) -> f64 {
match id {
"ec.audio.basstreble" | "ec.audio.highlowpass" | "ec.audio.parametriceq" => 0.05,
"ec.audio.flangechorus" | "ec.audio.modulator" => 0.06,
"ec.audio.distortion" => 0.01,
// The envelope settles within a few attack/release time constants.
"ec.audio.compressor" | "ec.audio.gate" => {
let rel = params.f("release").max(1.0) / 1000.0;
let att = params.f("attack").max(0.0) / 1000.0;
let hold = if id == "ec.audio.gate" { params.f("hold").max(0.0) / 1000.0 } else { 0.0 };
(9.0 * rel + 9.0 * att + hold + 0.01).min(10.0)
}
"ec.audio.delay" => {
let d = params.f("delayTime").max(1.0) / 1000.0;
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99);
@@ -231,6 +280,9 @@ pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64
}
}
"ec.audio.delay" => delay(params, buf, sr),
"ec.audio.compressor" => compressor(params, buf, sr),
"ec.audio.distortion" => distortion(params, buf, sr),
"ec.audio.gate" => gate(params, buf, sr),
"ec.audio.flangechorus" => flange_chorus(params, buf, sr, start),
"ec.audio.modulator" => modulator(params, buf, sr, start),
"ec.audio.reverb" => reverb(params, buf, sr),
@@ -362,6 +414,126 @@ fn delay(params: &Params, buf: &mut [f32], sr: f64) {
}
}
#[inline]
fn db_to_lin(db: f64) -> f64 {
10f64.powf(db / 20.0)
}
#[inline]
fn lin_to_db(v: f64) -> f64 {
20.0 * v.max(1e-12).log10()
}
/// One-pole smoothing coefficient for a time constant of `ms` milliseconds.
fn smoothing(ms: f64, sr: f64) -> f64 {
if ms <= 0.0 { 0.0 } else { (-1.0 / (ms / 1000.0 * sr)).exp() }
}
/// Static compressor curve: gain change (dB, ≤ 0) for an input level `x` dB (quadratic soft
/// knee of width `knee` around the threshold).
pub fn compressor_gain_db(x: f64, threshold: f64, ratio: f64, knee: f64) -> f64 {
let r = ratio.max(1.0);
let over = x - threshold;
let y = if knee > 0.0 && 2.0 * over.abs() <= knee {
x + (1.0 / r - 1.0) * (over + knee / 2.0).powi(2) / (2.0 * knee)
} else if over > 0.0 {
threshold + over / r
} else {
x
};
y - x
}
fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
let th = params.f("threshold");
let ratio = params.f("ratio").max(1.0);
let knee = params.f("knee").max(0.0);
let att = smoothing(params.f("attack"), sr);
let rel = smoothing(params.f("release").max(0.001), sr);
let makeup = params.f("makeupGain");
let dry = (params.f("dryOut") / 100.0) as f32;
let wet = (params.f("wetOut") / 100.0) as f32;
// Smoothed gain change in dB (≤ 0): attack while the reduction grows, release as it shrinks.
let mut g = 0.0f64;
for f in buf.chunks_exact_mut(2) {
let peak = f[0].abs().max(f[1].abs()) as f64;
let target = compressor_gain_db(lin_to_db(peak), th, ratio, knee);
let k = if target < g { att } else { rel };
g = target + (g - target) * k;
let lin = db_to_lin(g + makeup) as f32;
for v in f.iter_mut() {
*v = *v * dry + *v * lin * wet;
}
}
}
fn gate(params: &Params, buf: &mut [f32], sr: f64) {
let th = db_to_lin(params.f("threshold"));
let att = smoothing(params.f("attack"), sr);
let rel = smoothing(params.f("release").max(0.001), sr);
let hold = (params.f("hold").max(0.0) / 1000.0 * sr) as usize;
let floor = db_to_lin(params.f("range").min(0.0));
// Openness 0..1 (closed..open), mapped to floor..1.
let mut g = 0.0f64;
let mut held = 0usize;
for f in buf.chunks_exact_mut(2) {
let peak = f[0].abs().max(f[1].abs()) as f64;
let open = if peak >= th {
held = hold;
true
} else if held > 0 {
held -= 1;
true
} else {
false
};
let target = if open { 1.0 } else { 0.0 };
let k = if target > g { att } else { rel };
g = target + (g - target) * k;
let lin = (floor + (1.0 - floor) * g) as f32;
f[0] *= lin;
f[1] *= lin;
}
}
/// The Distortion effect's waveshaper for one sample (`drive` linear gain, `bits` for Bit Crush).
pub fn distort_sample(kind: u32, x: f32, drive: f32, bits: f32) -> f32 {
let v = x * drive;
match kind {
1 => v.clamp(-1.0, 1.0),
2 => {
// Fold back: reflect at ±1 (a triangle function of the input).
let t = (v + 1.0).rem_euclid(4.0);
if t < 2.0 { t - 1.0 } else { 3.0 - t }
}
3 => {
let q = 2f32.powf(bits.clamp(1.0, 16.0) - 1.0);
(v.clamp(-1.0, 1.0) * q).round() / q
}
_ => v.tanh(),
}
}
fn distortion(params: &Params, buf: &mut [f32], sr: f64) {
let kind = params.e("distortionType");
let drive = db_to_lin(params.f("drive")) as f32;
let bits = params.f("bitDepth") as f32;
let out = db_to_lin(params.f("outputGain")) as f32;
let dry = (params.f("dryOut") / 100.0) as f32;
let wet = (params.f("wetOut") / 100.0) as f32;
let fc = params.f("tone").clamp(20.0, sr * 0.49);
let a = (-2.0 * PI * fc / sr).exp() as f32;
let mut lp = [0.0f32; 2];
for f in buf.chunks_exact_mut(2) {
for ch in 0..2 {
let x = f[ch];
let y = distort_sample(kind, x, drive, bits);
lp[ch] = y + (lp[ch] - y) * a;
f[ch] = x * dry + lp[ch] * out * wet;
}
}
}
fn lfo(kind: u32, phase: f64) -> f64 {
let ph = phase.rem_euclid(1.0);
match kind {
@@ -546,7 +718,10 @@ mod tests {
for id in [
"ec.audio.backwards",
"ec.audio.basstreble",
"ec.audio.compressor",
"ec.audio.delay",
"ec.audio.distortion",
"ec.audio.gate",
"ec.audio.flangechorus",
"ec.audio.highlowpass",
"ec.audio.modulator",
@@ -679,4 +854,64 @@ mod tests {
assert_eq!(b, vec![2.0, 1.0]);
assert!(reverses("ec.audio.backwards"));
}
#[test]
fn compressor_reduces_gain_above_threshold() {
// Static curve: 10 dB over a −20 dB threshold at 4:1 comes out 7.5 dB lower.
assert!((compressor_gain_db(-10.0, -20.0, 4.0, 0.0) + 7.5).abs() < 1e-9);
assert_eq!(compressor_gain_db(-30.0, -20.0, 4.0, 0.0), 0.0);
// The soft knee meets the hard curve at its edges.
let k = 6.0;
assert!(compressor_gain_db(-23.0, -20.0, 4.0, k).abs() < 1e-9);
assert!((compressor_gain_db(-17.0, -20.0, 4.0, k) - compressor_gain_db(-17.0, -20.0, 4.0, 0.0)).abs() < 1e-9);
let sr = 48000.0;
let mut p = defaults("ec.audio.compressor");
p.values.insert("attack".into(), num(1.0));
let mut loud = sine(1000.0, sr, 9600);
process("ec.audio.compressor", &p, &mut loud, 48000, 0.0);
// A full-scale sine (peak 0 dB) is pulled down by ~15 dB once the envelope settles.
let peak = loud[18000..].iter().fold(0.0f32, |m, v| m.max(v.abs()));
let want = db_to_lin(compressor_gain_db(0.0, -20.0, 4.0, 0.0)) as f32;
assert!((peak - want).abs() < 0.05, "{peak} vs {want}");
// Quiet input passes untouched.
let mut quiet: Vec<f32> = sine(1000.0, sr, 4800).iter().map(|v| v * 0.01).collect();
let orig = quiet.clone();
process("ec.audio.compressor", &p, &mut quiet, 48000, 0.0);
assert!(quiet.iter().zip(&orig).all(|(a, b)| (a - b).abs() < 1e-6));
}
#[test]
fn gate_mutes_below_threshold() {
let sr = 48000.0;
let p = defaults("ec.audio.gate");
// A −60 dB signal under the −40 dB threshold is attenuated by the −80 dB range.
let mut quiet: Vec<f32> = sine(500.0, sr, 9600).iter().map(|v| v * 0.001).collect();
process("ec.audio.gate", &p, &mut quiet, 48000, 0.0);
assert!(rms(&quiet, 4800) < 1e-6, "{}", rms(&quiet, 4800));
// A loud signal opens the gate.
let mut loud: Vec<f32> = sine(500.0, sr, 9600).iter().map(|v| v * 0.5).collect();
let r0 = rms(&loud, 4800);
process("ec.audio.gate", &p, &mut loud, 48000, 0.0);
assert!((rms(&loud, 4800) - r0).abs() < 0.01);
// Deterministic.
let mut a = sine(300.0, sr, 2000);
let mut b = a.clone();
process("ec.audio.gate", &p, &mut a, 48000, 0.0);
process("ec.audio.gate", &p, &mut b, 48000, 0.0);
assert_eq!(a, b);
}
#[test]
fn distortion_shapes_and_bounds() {
assert!((distort_sample(0, 0.5, 1.0, 8.0) - 0.5f32.tanh()).abs() < 1e-6);
assert_eq!(distort_sample(1, 0.8, 2.0, 8.0), 1.0);
assert!((distort_sample(2, 0.7, 2.0, 8.0) - 0.6).abs() < 1e-5);
assert_eq!(distort_sample(3, 0.3, 1.0, 2.0), 0.5);
let p = defaults("ec.audio.distortion");
let mut b = sine(200.0, 48000.0, 4800);
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
assert!(b.iter().all(|v| v.abs() <= 1.0));
assert!(rms(&b, 100) > 0.1);
assert!(preroll("ec.audio.compressor", &defaults("ec.audio.compressor")) > 0.4);
}
}
+11 -1
View File
@@ -174,7 +174,17 @@ fn blocks_match_one_long_mix() {
let x = ((t * 12345.678).sin() * 43758.5453).fract() as f32;
(x, -x)
}
for id in ["ec.audio.reverb", "ec.audio.basstreble", "ec.audio.flangechorus", "ec.audio.modulator", "ec.audio.delay", "ec.audio.parametriceq"] {
for id in [
"ec.audio.reverb",
"ec.audio.basstreble",
"ec.audio.flangechorus",
"ec.audio.modulator",
"ec.audio.delay",
"ec.audio.parametriceq",
"ec.audio.compressor",
"ec.audio.gate",
"ec.audio.distortion",
] {
let vals: Vec<(&str, Value)> = match id {
"ec.audio.basstreble" => vec![("bass", Value::Scalar(50.0))],
"ec.audio.parametriceq" => vec![("band1BoostCut", Value::Scalar(9.0))],