From 97cf7e2c02336bfb7135e3b651c34d1a615bb724 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 20:39:38 -0400 Subject: [PATCH] M9.11: Audio effects (Compressor, Distortion, Gate) Co-Authored-By: Claude Opus 5.5 --- crates/effects/src/audio_fx.rs | 241 +++++++++++++++++++++++++++- crates/render/src/tests_audio_fx.rs | 12 +- 2 files changed, 249 insertions(+), 4 deletions(-) diff --git a/crates/effects/src/audio_fx.rs b/crates/effects/src/audio_fx.rs index 855fd10e..bd80519a 100644 --- a/crates/effects/src/audio_fx.rs +++ b/crates/effects/src/audio_fx.rs @@ -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 { 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 { 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 { 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 = 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 = 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 = 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); + } } diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index dfd49ccc..a249955f 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -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))],