effectcraft/crates/effects/src/audio_fx.rs

1024 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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
//! composition mixdown (`effectcraft_render::audio`), which preview playback and export share.
//!
//! The mixdown renders in short blocks. To keep the result independent of block boundaries
//! every effect is stateless across calls: the mixer feeds [`preroll`] seconds of the layer's
//! audio before the block, runs the chain from a cleared state and keeps the tail.
//!
//! 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. 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, an optional program-dependent "auto" release and an output ceiling); Gate mutes
//! below its threshold with attack / hold / release; Distortion is a static waveshaper (soft and
//! hard clip, two saturation curves, tube, fuzz) with pre-gain, bit reduction, sample-and-hold
//! downsampling, dry/wet mix and output volume.
use std::f64::consts::PI;
use effectcraft_keyframe::Value;
use effectcraft_project::ParamUi;
use crate::{Buf, EffectCtx, EffectSpec, Params, num, p, popup, slider};
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>) -> EffectSpec {
EffectSpec { id, name, category: "Audio", params, render: passthrough, gpu: false, float: true }
}
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
b
}
fn pct(default: f64) -> (Value, ParamUi) {
(num(default), slider(0.0, 100.0, 0.0, 100.0, 2))
}
fn pp(id: &'static str, name: &'static str, v: (Value, ParamUi)) -> crate::ParamSpec {
p(id, name, v.0, v.1)
}
/// Distortion's Type options.
pub const DISTORTION_TYPES: [&str; 6] = ["Soft Clip", "Hard Clip", "Saturation 1", "Saturation 2", "Tube", "Fuzz"];
pub fn specs() -> Vec<EffectSpec> {
const BANDS: [[&str; 8]; 3] = [
["band1Enable", "Band 1 Enabled", "band1Frequency", "Band 1 Frequency", "band1Bandwidth", "Band 1 Bandwidth", "band1BoostCut", "Band 1 Boost/Cut"],
["band2Enable", "Band 2 Enabled", "band2Frequency", "Band 2 Frequency", "band2Bandwidth", "Band 2 Bandwidth", "band2BoostCut", "Band 2 Boost/Cut"],
["band3Enable", "Band 3 Enabled", "band3Frequency", "Band 3 Frequency", "band3Bandwidth", "Band 3 Bandwidth", "band3BoostCut", "Band 3 Boost/Cut"],
];
let mut eq = vec![];
for (b, (f, on)) in BANDS.iter().zip([(200.0, true), (1000.0, false), (5000.0, false)]) {
eq.push(p(b[0], b[1], Value::Bool(on), ParamUi::Checkbox));
eq.push(p(b[2], b[3], num(f), slider(20.0, 20000.0, 20.0, 20000.0, 1)));
eq.push(p(b[4], b[5], num(30.0), slider(1.0, 100.0, 1.0, 100.0, 1)));
eq.push(p(b[6], b[7], num(0.0), slider(-24.0, 24.0, -24.0, 24.0, 2)));
}
vec![
spec("ec.audio.backwards", "Backwards", vec![p("swapChannels", "Swap Channels", Value::Bool(false), ParamUi::Checkbox)]),
spec(
"ec.audio.basstreble",
"Bass & Treble",
vec![
p("bass", "Bass", num(0.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
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(-16.0), slider(-60.0, 0.0, -60.0, 0.0, 2)),
p("ratio", "Ratio (x:1)", num(3.0), slider(0.4, 30.0, 0.4, 30.0, 2)),
p("knee", "Knee (dB)", num(15.0), slider(0.0, 30.0, 0.0, 30.0, 2)),
p("attack", "Attack (ms)", num(6.0), slider(0.0, 400.0, 0.0, 400.0, 2)),
p("release", "Release (ms)", num(440.0), slider(1.0, 4000.0, 1.0, 4000.0, 2)),
p("autoRelease", "Auto Release", Value::Bool(true), ParamUi::Checkbox),
p("makeupGain", "Makeup Gain (dB)", num(0.0), slider(-30.0, 30.0, -30.0, 30.0, 2)),
p("outputLimit", "Output Limit (dB)", num(0.0), slider(-30.0, 0.0, -30.0, 0.0, 2)),
],
),
spec(
"ec.audio.delay",
"Delay",
vec![
p("delayTime", "Delay Time (milliseconds)", num(500.0), slider(1.0, 10000.0, 1.0, 2000.0, 2)),
pp("delayAmount", "Delay Amount", pct(50.0)),
pp("feedback", "Feedback", pct(50.0)),
pp("dryOut", "Dry Out", pct(75.0)),
pp("wetOut", "Wet Out", pct(75.0)),
],
),
spec(
"ec.audio.distortion",
"Distortion",
vec![
p("distortionType", "Type", Value::Enum(0), popup(&DISTORTION_TYPES)),
pp("drive", "Drive", pct(25.0)),
p("gain", "Gain", num(25.0), slider(0.0, 300.0, 0.0, 300.0, 2)),
pp("mix", "Mix", pct(100.0)),
p("volume", "Volume", num(11.0), slider(0.0, 100.0, 0.0, 100.0, 2)),
p("bitDepth", "Resolution (bit)", num(24.0), slider(1.0, 24.0, 1.0, 24.0, 0)),
p("downsample", "Downsample (x)", num(1.0), slider(1.0, 64.0, 1.0, 32.0, 0)),
],
),
spec(
"ec.audio.flangechorus",
"Flange & Chorus",
vec![
p("voiceSeparationTime", "Voice Separation Time (ms)", num(3.0), slider(0.0, 100.0, 0.0, 50.0, 2)),
p("voices", "Voices", num(1.0), slider(1.0, 8.0, 1.0, 8.0, 0)),
p("modulationRate", "Modulation Rate", num(0.4), slider(0.0, 10.0, 0.0, 5.0, 2)),
pp("modulationDepth", "Modulation Depth", pct(50.0)),
p("voicePhaseChange", "Voice Phase Change", num(0.0), ParamUi::Angle),
p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox),
p("stereoVoices", "Stereo Voices", Value::Bool(false), ParamUi::Checkbox),
pp("dryOut", "Dry Out", pct(50.0)),
pp("wetOut", "Wet Out", pct(50.0)),
],
),
spec(
"ec.audio.gate",
"Gate",
vec![
p("threshold", "Threshold (dB)", num(-60.0), slider(-60.0, 0.0, -60.0, 0.0, 2)),
p("attack", "Attack (ms)", num(10.0), slider(0.0, 1000.0, 0.0, 1000.0, 2)),
p("hold", "Hold (ms)", num(40.0), slider(0.0, 2500.0, 0.0, 2500.0, 2)),
p("release", "Release (ms)", num(220.0), slider(5.0, 4000.0, 5.0, 4000.0, 2)),
],
),
spec(
"ec.audio.highlowpass",
"High-Low Pass",
vec![
p("filterOptions", "Filter Options", Value::Enum(0), popup(&["High Pass", "Low Pass"])),
p("cutoffFrequency", "Cutoff Frequency", num(2000.0), slider(20.0, 20000.0, 20.0, 20000.0, 1)),
pp("dryOut", "Dry Out", pct(0.0)),
pp("wetOut", "Wet Out", pct(100.0)),
],
),
spec(
"ec.audio.modulator",
"Modulator",
vec![
p("modulationType", "Modulation Type", Value::Enum(0), popup(&["Sine", "Triangle"])),
p("modulationRate", "Modulation Rate", num(10.0), slider(0.0, 100.0, 0.0, 20.0, 2)),
pp("modulationDepth", "Modulation Depth", pct(5.0)),
pp("amplitudeModulation", "Amplitude Modulation", pct(0.0)),
],
),
spec("ec.audio.parametriceq", "Parametric EQ", eq),
spec(
"ec.audio.reverb",
"Reverb",
vec![
p("reverbTime", "Reverb Time (milliseconds)", num(100.0), slider(1.0, 5000.0, 1.0, 1000.0, 2)),
pp("diffusion", "Diffusion", pct(75.0)),
pp("decay", "Decay", pct(25.0)),
pp("brightness", "Brightness", pct(10.0)),
pp("dryOut", "Dry Out", pct(90.0)),
pp("wetOut", "Wet Out", pct(10.0)),
],
),
spec(
"ec.audio.stereomixer",
"Stereo Mixer",
vec![
p("leftLevel", "Left Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)),
p("rightLevel", "Right Level", num(100.0), slider(0.0, 400.0, 0.0, 200.0, 2)),
p("leftPan", "Left Pan", num(-100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
p("rightPan", "Right Pan", num(100.0), slider(-100.0, 100.0, -100.0, 100.0, 2)),
p("invertPhase", "Invert Phase", Value::Bool(false), ParamUi::Checkbox),
],
),
spec(
"ec.audio.tone",
"Tone",
vec![
p("waveformOptions", "Waveform Options", Value::Enum(0), popup(&["Sine", "Triangle", "Saw", "Square", "White Noise"])),
p("frequency1", "Frequency 1", num(440.0), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
p("frequency2", "Frequency 2", num(493.88), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
p("frequency3", "Frequency 3", num(587.33), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
p("frequency4", "Frequency 4", num(659.26), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
p("frequency5", "Frequency 5", num(783.99), slider(0.0, 22000.0, 0.0, 2000.0, 2)),
pp("level", "Level", pct(20.0)),
],
),
]
}
/// Is `id` an audio effect (applied by the mixdown rather than the compositor)?
pub fn is_audio_effect(id: &str) -> bool {
id.starts_with("ec.audio.")
}
/// Does the effect produce sound on its own (so a layer without audio becomes audible)?
pub fn generates_audio(id: &str) -> bool {
id == "ec.audio.tone"
}
/// Does the effect play the layer's audio backwards (a time mapping the mixer applies)?
pub fn reverses(id: &str) -> bool {
id == "ec.audio.backwards"
}
/// Seconds of input history the effect needs before a block to reach the state it would have
/// had when processing continuously.
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 auto = id == "ec.audio.compressor" && params.b("autoRelease");
let rel = if auto { AUTO_RELEASE_SLOW_MS } else { 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);
let taps = if fb > 1e-3 { (1e-3f64.ln() / fb.ln()).ceil() + 1.0 } else { 1.0 };
(d * taps).min(10.0)
}
"ec.audio.reverb" => {
let (fb, _, _, pre) = reverb_params(params);
// T60 of the longest comb (~37 ms) with loop gain fb, plus the pre-delay.
let t60 = if fb > 1e-3 { -3.0 * 0.0367 / fb.log10() } else { 0.05 };
(t60 + pre).min(4.0)
}
_ => 0.0,
}
}
/// Run audio effect `id` over interleaved stereo `buf` (L R L R …) at `rate` Hz, from a cleared
/// state. `start` is the layer time (seconds) of the first sample (oscillator phase).
pub fn process(id: &str, params: &Params, buf: &mut [f32], rate: u32, start: f64) {
if rate == 0 || buf.len() < 2 {
return;
}
let sr = rate as f64;
match id {
"ec.audio.backwards" if params.b("swapChannels") => {
buf.as_chunks_mut::<2>().0.iter_mut().for_each(|f| f.swap(0, 1));
}
"ec.audio.basstreble" => {
let bass = params.f("bass") / 100.0 * 12.0;
let treble = params.f("treble") / 100.0 * 12.0;
if bass != 0.0 {
run_biquad(buf, Biquad::low_shelf(sr, 200.0, bass));
}
if treble != 0.0 {
run_biquad(buf, Biquad::high_shelf(sr, 4000.0, treble));
}
}
"ec.audio.highlowpass" => {
let fc = params.f("cutoffFrequency").clamp(1.0, sr * 0.49);
let q = std::f64::consts::FRAC_1_SQRT_2;
let bq = if params.e("filterOptions") == 1 { Biquad::low_pass(sr, fc, q) } else { Biquad::high_pass(sr, fc, q) };
let dry = (params.f("dryOut") / 100.0) as f32;
let wet = (params.f("wetOut") / 100.0) as f32;
let orig = buf.to_vec();
run_biquad(buf, bq);
mix_dry_wet(buf, &orig, dry, wet);
}
"ec.audio.parametriceq" => {
for n in 1..=3 {
if !params.b(&format!("band{n}Enable")) {
continue;
}
let g = params.f(&format!("band{n}BoostCut"));
if g == 0.0 {
continue;
}
let f = params.f(&format!("band{n}Frequency")).clamp(1.0, sr * 0.49);
let bw = (params.f(&format!("band{n}Bandwidth")) / 100.0 * 3.0).max(0.05);
run_biquad(buf, Biquad::peaking(sr, f, bw, g));
}
}
"ec.audio.delay" => delay(params, buf, sr),
"ec.audio.compressor" => compressor(params, buf, sr),
"ec.audio.distortion" => distortion(params, buf, sr, start),
"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),
"ec.audio.stereomixer" => stereo_mixer(params, buf),
"ec.audio.tone" => tone(params, buf, sr, start),
_ => {}
}
}
fn mix_dry_wet(buf: &mut [f32], dry_src: &[f32], dry: f32, wet: f32) {
for (o, d) in buf.iter_mut().zip(dry_src) {
*o = *o * wet + *d * dry;
}
}
// ---------------------------------------------------------------- biquads (RBJ cookbook)
/// Normalised second-order section coefficients (a0 = 1).
#[derive(Clone, Copy, Debug)]
pub struct Biquad {
pub b: [f64; 3],
pub a: [f64; 2],
}
impl Biquad {
fn norm(b0: f64, b1: f64, b2: f64, a0: f64, a1: f64, a2: f64) -> Biquad {
Biquad { b: [b0 / a0, b1 / a0, b2 / a0], a: [a1 / a0, a2 / a0] }
}
pub fn low_pass(sr: f64, f0: f64, q: f64) -> Biquad {
let w = 2.0 * PI * f0 / sr;
let (s, c) = w.sin_cos();
let al = s / (2.0 * q);
Self::norm((1.0 - c) / 2.0, 1.0 - c, (1.0 - c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al)
}
pub fn high_pass(sr: f64, f0: f64, q: f64) -> Biquad {
let w = 2.0 * PI * f0 / sr;
let (s, c) = w.sin_cos();
let al = s / (2.0 * q);
Self::norm((1.0 + c) / 2.0, -(1.0 + c), (1.0 + c) / 2.0, 1.0 + al, -2.0 * c, 1.0 - al)
}
/// Peaking EQ with bandwidth `bw` in octaves and gain in dB.
pub fn peaking(sr: f64, f0: f64, bw: f64, db: f64) -> Biquad {
let a = 10f64.powf(db / 40.0);
let w = 2.0 * PI * f0 / sr;
let (s, c) = w.sin_cos();
let al = s * (2f64.ln() / 2.0 * bw * w / s).sinh();
Self::norm(1.0 + al * a, -2.0 * c, 1.0 - al * a, 1.0 + al / a, -2.0 * c, 1.0 - al / a)
}
/// Shelf with slope S = 1.
pub fn low_shelf(sr: f64, f0: f64, db: f64) -> Biquad {
let a = 10f64.powf(db / 40.0);
let w = 2.0 * PI * f0 / sr;
let (s, c) = w.sin_cos();
let al = s / 2.0 * std::f64::consts::SQRT_2;
let sq = 2.0 * a.sqrt() * al;
Self::norm(
a * ((a + 1.0) - (a - 1.0) * c + sq),
2.0 * a * ((a - 1.0) - (a + 1.0) * c),
a * ((a + 1.0) - (a - 1.0) * c - sq),
(a + 1.0) + (a - 1.0) * c + sq,
-2.0 * ((a - 1.0) + (a + 1.0) * c),
(a + 1.0) + (a - 1.0) * c - sq,
)
}
pub fn high_shelf(sr: f64, f0: f64, db: f64) -> Biquad {
let a = 10f64.powf(db / 40.0);
let w = 2.0 * PI * f0 / sr;
let (s, c) = w.sin_cos();
let al = s / 2.0 * std::f64::consts::SQRT_2;
let sq = 2.0 * a.sqrt() * al;
Self::norm(
a * ((a + 1.0) + (a - 1.0) * c + sq),
-2.0 * a * ((a - 1.0) + (a + 1.0) * c),
a * ((a + 1.0) + (a - 1.0) * c - sq),
(a + 1.0) - (a - 1.0) * c + sq,
2.0 * ((a - 1.0) - (a + 1.0) * c),
(a + 1.0) - (a - 1.0) * c - sq,
)
}
}
/// Direct form I over both channels of an interleaved buffer.
fn run_biquad(buf: &mut [f32], q: Biquad) {
for ch in 0..2 {
let (mut x1, mut x2, mut y1, mut y2) = (0.0f64, 0.0, 0.0, 0.0);
for i in (ch..buf.len()).step_by(2) {
let x = buf[i] as f64;
let y = q.b[0] * x + q.b[1] * x1 + q.b[2] * x2 - q.a[0] * y1 - q.a[1] * y2;
x2 = x1;
x1 = x;
y2 = y1;
y1 = y;
buf[i] = y as f32;
}
}
}
// ---------------------------------------------------------------- delay lines
/// Linear-interpolated read `d` samples (fractional) behind position `i` of channel `ch`.
fn read_frac(src: &[f32], i: usize, ch: usize, d: f64) -> f32 {
let pos = i as f64 - d;
if pos < 0.0 {
return 0.0;
}
let p0 = pos.floor() as usize;
let f = (pos - p0 as f64) as f32;
let a = src[p0 * 2 + ch];
let b = if p0 < i { src[(p0 + 1) * 2 + ch] } else { a };
a + (b - a) * f
}
fn delay(params: &Params, buf: &mut [f32], sr: f64) {
let d = ((params.f("delayTime").max(0.0) / 1000.0 * sr).round() as usize).max(1);
let amount = (params.f("delayAmount") / 100.0) as f32;
let fb = (params.f("feedback") / 100.0).clamp(0.0, 0.99) as f32;
let dry = (params.f("dryOut") / 100.0) as f32;
let wet = (params.f("wetOut") / 100.0) as f32;
let n = buf.len() / 2;
// Echo line: e[i] = x[i-d] + fb * e[i-d]; out = dry*x + wet*amount*e.
let mut e = vec![0.0f32; buf.len()];
for i in d..n {
for ch in 0..2 {
e[i * 2 + ch] = buf[(i - d) * 2 + ch] + fb * e[(i - d) * 2 + ch];
}
}
for (o, v) in buf.iter_mut().zip(&e) {
*o = *o * dry + v * wet * amount;
}
}
#[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 {
// Ratios below 1:1 expand above the threshold.
let r = ratio.max(0.1);
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
}
/// Auto Release: a program-dependent release from two envelopes. A fast one (the Attack time, a
/// short release) follows transients; a slow one (slow attack and release) only builds up under
/// sustained compression. The deeper of the two applies, so brief peaks recover quickly and long
/// loud passages slowly.
const AUTO_RELEASE_FAST_MS: f64 = 60.0;
const AUTO_RELEASE_SLOW_ATTACK_MS: f64 = 150.0;
const AUTO_RELEASE_SLOW_MS: f64 = 900.0;
fn compressor(params: &Params, buf: &mut [f32], sr: f64) {
let th = params.f("threshold");
let ratio = params.f("ratio").max(0.1);
let knee = params.f("knee").max(0.0);
let att = smoothing(params.f("attack"), sr);
let auto = params.b("autoRelease");
let rel = smoothing(if auto { AUTO_RELEASE_FAST_MS } else { params.f("release").max(0.001) }, sr);
let (att_s, rel_s) = (smoothing(AUTO_RELEASE_SLOW_ATTACK_MS, sr), smoothing(AUTO_RELEASE_SLOW_MS, sr));
let makeup = params.f("makeupGain");
// Output Limit: a final ceiling (brick-wall clip) so the result never exceeds it.
let limit = db_to_lin(params.f("outputLimit").min(0.0)) as f32;
// Smoothed gain change in dB: attack while the reduction grows, release as it shrinks.
let (mut g, mut gs) = (0.0f64, 0.0f64);
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
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 mut eff = g;
if auto {
let k = if target < gs { att_s } else { rel_s };
gs = target + (gs - target) * k;
eff = g.min(gs);
}
let lin = db_to_lin(eff + makeup) as f32;
for v in f.iter_mut() {
*v = (*v * lin).clamp(-limit, limit);
}
}
}
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;
// Openness 0..1 (closed..open) is the gain: a closed gate mutes.
let mut g = 0.0f64;
let mut held = 0usize;
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
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 = g as f32;
f[0] *= lin;
f[1] *= lin;
}
}
/// The Distortion effect's waveshaper for one sample: `kind` indexes [`DISTORTION_TYPES`],
/// `drive` (0..1) sets how hard the curve bends. The result stays within ±1.
pub fn distort_sample(kind: u32, x: f32, drive: f32) -> f32 {
let v = x * (1.0 + drive.clamp(0.0, 1.0) * 24.0);
match kind {
// Hard clip: linear up to a flat ceiling.
1 => v,
// Saturation 1: rational curve, gentler knee than tanh.
2 => v / (1.0 + v.abs()),
// Saturation 2: cubic soft saturator (odd harmonics), flat beyond ±1.5.
3 => {
let c = v.clamp(-1.5, 1.5);
c - c * c * c * 4.0 / 27.0
}
// Tube: asymmetric — the positive half saturates harder (even harmonics).
4 => {
if v >= 0.0 {
v.tanh()
} else {
(0.6 * v).tanh()
}
}
// Fuzz: near-square exponential clipper.
5 => v.signum() * (1.0 - (-3.0 * v.abs()).exp()),
// Soft clip.
_ => v.tanh(),
}
.clamp(-1.0, 1.0)
}
fn distortion(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
let kind = params.e("distortionType");
let drive = (params.f("drive") / 100.0) as f32;
// Gain: pre-distortion amplification (25 = unity); Volume: output level (11 = unity).
let pre = (params.f("gain").max(0.0) / 25.0) as f32;
let mix = (params.f("mix") / 100.0).clamp(0.0, 1.0) as f32;
let vol = (params.f("volume").max(0.0) / 11.0) as f32;
let bits = params.f("bitDepth").round().clamp(1.0, 24.0);
let q = (bits < 24.0).then(|| 2f32.powf(bits as f32 - 1.0));
let down = params.f("downsample").round().clamp(1.0, 64.0) as i64;
// Sample-and-hold on absolute sample positions, so blocks agree with one long run.
let first = (start * sr).round() as i64;
let mut held = [0.0f32; 2];
for (i, f) in buf.as_chunks_mut::<2>().0.iter_mut().enumerate() {
let take = i == 0 || (first + i as i64).rem_euclid(down) == 0;
for ch in 0..2 {
let x = f[ch];
let mut y = distort_sample(kind, x * pre, drive);
if let Some(q) = q {
y = (y * q).round() / q;
}
if take {
held[ch] = y;
}
f[ch] = (x * (1.0 - mix) + held[ch] * mix) * vol;
}
}
}
fn lfo(kind: u32, phase: f64) -> f64 {
let ph = phase.rem_euclid(1.0);
match kind {
1 => 1.0 - 4.0 * (ph - 0.5).abs(), // triangle, −1..1
_ => (2.0 * PI * ph).sin(),
}
}
fn flange_chorus(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
let sep = params.f("voiceSeparationTime").max(0.0) / 1000.0 * sr;
let voices = params.f("voices").round().clamp(1.0, 8.0) as usize;
let rate = params.f("modulationRate").max(0.0);
let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0);
let vphase = params.f("voicePhaseChange") / 360.0;
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
let stereo = params.b("stereoVoices");
let dry = (params.f("dryOut") / 100.0) as f32;
let wet = (params.f("wetOut") / 100.0) as f32;
let src = buf.to_vec();
let n = buf.len() / 2;
for i in 0..n {
let t = start + i as f64 / sr;
let mut acc = [0.0f32; 2];
let mut cnt = [0usize; 2];
for v in 0..voices {
let base = sep * (v + 1) as f64;
let d = (base * (1.0 + depth * lfo(0, rate * t + vphase * v as f64))).max(0.0);
for ch in 0..2 {
if stereo && voices > 1 && v % 2 != ch {
continue;
}
acc[ch] += read_frac(&src, i, ch, d);
cnt[ch] += 1;
}
}
for ch in 0..2 {
let w = if cnt[ch] > 0 { acc[ch] / cnt[ch] as f32 } else { 0.0 };
buf[i * 2 + ch] = src[i * 2 + ch] * dry + inv * w * wet;
}
}
}
fn modulator(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
let kind = params.e("modulationType");
let rate = params.f("modulationRate").max(0.0);
// Depth: vibrato as a modulated delay of up to 5 ms; amplitude: tremolo.
let depth = (params.f("modulationDepth") / 100.0).clamp(0.0, 1.0) * 0.005 * sr;
let am = (params.f("amplitudeModulation") / 100.0).clamp(0.0, 1.0);
let src = buf.to_vec();
let n = buf.len() / 2;
for i in 0..n {
let t = start + i as f64 / sr;
let l = lfo(kind, rate * t);
let d = depth * (1.0 + l) * 0.5;
let g = (1.0 - am * (1.0 - l) * 0.5) as f32;
for ch in 0..2 {
let x = if depth > 0.0 { read_frac(&src, i, ch, d) } else { src[i * 2 + ch] };
buf[i * 2 + ch] = x * g;
}
}
}
/// (comb feedback, damping, all-pass gain, pre-delay seconds)
fn reverb_params(params: &Params) -> (f64, f64, f64, f64) {
let decay = (params.f("decay") / 100.0).clamp(0.0, 1.0);
let fb = 0.7 + decay * 0.28;
let damp = (1.0 - params.f("brightness") / 100.0).clamp(0.0, 1.0) * 0.6;
let ap = 0.3 + 0.4 * (params.f("diffusion") / 100.0).clamp(0.0, 1.0);
let pre = params.f("reverbTime").max(0.0) / 1000.0;
(fb, damp, ap, pre)
}
fn reverb(params: &Params, buf: &mut [f32], sr: f64) {
const COMBS: [usize; 8] = [1116, 1188, 1277, 1356, 1422, 1491, 1557, 1617];
const ALLPASS: [usize; 4] = [556, 441, 341, 225];
const SPREAD: usize = 23;
let (fb, damp, apg, pre) = reverb_params(params);
let dry = (params.f("dryOut") / 100.0) as f32;
let wet = (params.f("wetOut") / 100.0) as f32;
let k = sr / 44100.0;
let pre_n = (pre * sr).round() as usize;
let n = buf.len() / 2;
let src = buf.to_vec();
for ch in 0..2 {
let spread = if ch == 1 { SPREAD } else { 0 };
let mut combs: Vec<(Vec<f64>, usize, f64)> = COMBS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0, 0.0)).collect();
let mut aps: Vec<(Vec<f64>, usize)> = ALLPASS.iter().map(|&l| (vec![0.0; (((l + spread) as f64 * k) as usize).max(1)], 0)).collect();
for i in 0..n {
let x = if i >= pre_n { (src[(i - pre_n) * 2] as f64 + src[(i - pre_n) * 2 + 1] as f64) * 0.5 * 0.03 } else { 0.0 };
let mut out = 0.0;
for (line, pos, filt) in combs.iter_mut() {
let y = line[*pos];
*filt = y * (1.0 - damp) + *filt * damp;
line[*pos] = x + *filt * fb;
*pos = (*pos + 1) % line.len();
out += y;
}
for (line, pos) in aps.iter_mut() {
let b = line[*pos];
let y = -out + b;
line[*pos] = out + b * apg;
*pos = (*pos + 1) % line.len();
out = y;
}
buf[i * 2 + ch] = src[i * 2 + ch] * dry + out as f32 * wet * 3.0;
}
}
}
fn stereo_mixer(params: &Params, buf: &mut [f32]) {
let ll = (params.f("leftLevel") / 100.0) as f32;
let rl = (params.f("rightLevel") / 100.0) as f32;
// Linear pan law: −100 = all left, 0 = both at full, +100 = all right.
let pan = |p: f64| -> (f32, f32) {
let p = (p / 100.0).clamp(-1.0, 1.0);
((1.0 - p).min(1.0) as f32, (1.0 + p).min(1.0) as f32)
};
let (l_to_l, l_to_r) = pan(params.f("leftPan"));
let (r_to_l, r_to_r) = pan(params.f("rightPan"));
let inv = if params.b("invertPhase") { -1.0 } else { 1.0 };
for f in buf.as_chunks_mut::<2>().0.iter_mut() {
let (l, r) = (f[0] * ll, f[1] * rl);
f[0] = (l * l_to_l + r * r_to_l) * inv;
f[1] = (l * l_to_r + r * r_to_r) * inv;
}
}
fn tone(params: &Params, buf: &mut [f32], sr: f64, start: f64) {
let kind = params.e("waveformOptions");
let level = (params.f("level") / 100.0) as f32;
let freqs: Vec<f64> = (1..=5).map(|i| params.f(&format!("frequency{i}"))).filter(|f| *f > 0.0).collect();
let n = buf.len() / 2;
for i in 0..n {
let t = start + i as f64 / sr;
let mut v = 0.0f64;
for f in &freqs {
let ph = (f * t).rem_euclid(1.0);
v += match kind {
1 => lfo(1, ph - 0.25),
2 => 2.0 * ph - 1.0,
3 => {
if ph < 0.5 {
1.0
} else {
-1.0
}
}
// White noise: a hash of the absolute sample position (every frequency alike).
4 => {
let h = ((t * sr).round() as i64 as u64 ^ f.to_bits()).wrapping_mul(0x9e37_79b9_7f4a_7c15);
let h = (h ^ (h >> 29)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
((h ^ (h >> 32)) >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0
}
_ => (2.0 * PI * ph).sin(),
};
}
let v = v as f32 * level;
buf[i * 2] += v;
buf[i * 2 + 1] += v;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn defaults(id: &str) -> Params {
let s = crate::find(id).unwrap();
Params { values: s.params.iter().map(|p| (p.id.to_string(), p.default.clone())).collect() }
}
fn sine(freq: f64, sr: f64, n: usize) -> Vec<f32> {
(0..n)
.flat_map(|i| {
let v = (2.0 * PI * freq * i as f64 / sr).sin() as f32;
[v, v]
})
.collect()
}
fn rms(b: &[f32], from: usize) -> f32 {
let s: f32 = b[from * 2..].iter().map(|v| v * v).sum();
(s / (b.len() - from * 2) as f32).sqrt()
}
#[test]
fn registered_as_audio() {
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",
"ec.audio.parametriceq",
"ec.audio.reverb",
"ec.audio.stereomixer",
"ec.audio.tone",
] {
let s = crate::find(id).unwrap_or_else(|| panic!("{id}"));
assert_eq!(s.category, "Audio");
assert!(is_audio_effect(id));
}
}
#[test]
fn high_low_pass_attenuates() {
let sr = 48000.0;
let mut p = defaults("ec.audio.highlowpass");
p.values.insert("filterOptions".into(), Value::Enum(1));
p.values.insert("cutoffFrequency".into(), num(500.0));
let mut hi = sine(8000.0, sr, 4800);
process("ec.audio.highlowpass", &p, &mut hi, 48000, 0.0);
let mut lo = sine(100.0, sr, 4800);
process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0);
assert!(rms(&hi, 1000) < 0.01, "{}", rms(&hi, 1000));
assert!(rms(&lo, 1000) > 0.6, "{}", rms(&lo, 1000));
// High pass does the opposite.
p.values.insert("filterOptions".into(), Value::Enum(0));
let mut lo = sine(50.0, sr, 9600);
process("ec.audio.highlowpass", &p, &mut lo, 48000, 0.0);
assert!(rms(&lo, 2000) < 0.02);
}
#[test]
fn delay_offsets_impulse() {
let mut p = defaults("ec.audio.delay");
p.values.insert("delayTime".into(), num(10.0));
p.values.insert("feedback".into(), num(0.0));
let mut b = vec![0.0f32; 2000];
b[0] = 1.0;
b[1] = 1.0;
process("ec.audio.delay", &p, &mut b, 10000, 0.0);
// dry 75 %, then the echo at 100 samples: wet 75 % × amount 50 %.
assert!((b[0] - 0.75).abs() < 1e-6);
assert!((b[200] - 0.375).abs() < 1e-6 && (b[201] - 0.375).abs() < 1e-6);
assert!(b[2..200].iter().all(|v| *v == 0.0));
assert!(preroll("ec.audio.delay", &p) >= 0.01);
}
#[test]
fn stereo_mixer_pans() {
let mut p = defaults("ec.audio.stereomixer");
let mut b = vec![1.0, 0.0, 1.0, 0.0];
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
assert_eq!(b, vec![1.0, 0.0, 1.0, 0.0], "defaults are identity");
p.values.insert("leftPan".into(), num(100.0));
let mut b = vec![1.0, 0.0];
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
assert_eq!(b, vec![0.0, 1.0]);
p.values.insert("leftPan".into(), num(0.0));
p.values.insert("leftLevel".into(), num(50.0));
let mut b = vec![1.0, 0.0];
process("ec.audio.stereomixer", &p, &mut b, 48000, 0.0);
assert_eq!(b, vec![0.5, 0.5]);
}
#[test]
fn tone_generates_frequency() {
let mut p = defaults("ec.audio.tone");
p.values.insert("frequency1".into(), num(1000.0));
for i in 2..=5 {
p.values.insert(format!("frequency{i}"), num(0.0));
}
let sr = 48000;
let mut b = vec![0.0f32; 48000 * 2];
process("ec.audio.tone", &p, &mut b, sr, 0.0);
let left: Vec<f32> = b.iter().step_by(2).copied().collect();
let crossings = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
assert!((crossings as i64 - 1000).abs() <= 1, "{crossings}");
let peak = left.iter().fold(0.0f32, |m, v| m.max(v.abs()));
assert!((peak - 0.2).abs() < 1e-3, "{peak}");
}
#[test]
fn bass_treble_eq_reverb_modulator_flange() {
let sr = 48000.0;
// Bass boost raises 60 Hz, treble cut lowers 10 kHz.
let mut p = defaults("ec.audio.basstreble");
p.values.insert("bass".into(), num(100.0));
p.values.insert("treble".into(), num(-100.0));
let mut lo = sine(60.0, sr, 9600);
process("ec.audio.basstreble", &p, &mut lo, 48000, 0.0);
assert!(rms(&lo, 4000) > 0.707 * 3.0, "{}", rms(&lo, 4000));
let mut hi = sine(10000.0, sr, 9600);
process("ec.audio.basstreble", &p, &mut hi, 48000, 0.0);
assert!(rms(&hi, 4000) < 0.707 * 0.4, "{}", rms(&hi, 4000));
// Parametric EQ boost at its centre.
let mut p = defaults("ec.audio.parametriceq");
p.values.insert("band1BoostCut".into(), num(12.0));
p.values.insert("band1Frequency".into(), num(1000.0));
let mut s = sine(1000.0, sr, 9600);
process("ec.audio.parametriceq", &p, &mut s, 48000, 0.0);
assert!((rms(&s, 4000) * std::f32::consts::SQRT_2 - 3.98).abs() < 0.2, "{}", rms(&s, 4000) * std::f32::consts::SQRT_2);
// Reverb leaves a tail after an impulse.
let p = defaults("ec.audio.reverb");
let mut b = vec![0.0f32; 48000];
b[0] = 1.0;
b[1] = 1.0;
process("ec.audio.reverb", &p, &mut b, 48000, 0.0);
assert!(b[2 * 12000..].iter().any(|v| v.abs() > 1e-5));
assert!(b.iter().all(|v| v.is_finite() && v.abs() < 2.0));
// Modulator with amplitude modulation dips the level; flange keeps it bounded.
let mut p = defaults("ec.audio.modulator");
p.values.insert("amplitudeModulation".into(), num(100.0));
let mut b = vec![1.0f32; 9600];
process("ec.audio.modulator", &p, &mut b, 48000, 0.0);
assert!(b.iter().any(|v| *v < 0.05) && b.iter().any(|v| *v > 0.95));
let p = defaults("ec.audio.flangechorus");
let mut b = sine(440.0, sr, 4800);
process("ec.audio.flangechorus", &p, &mut b, 48000, 0.0);
assert!(b.iter().all(|v| v.abs() <= 1.0 + 1e-4));
}
#[test]
fn backwards_swaps_channels() {
let mut p = defaults("ec.audio.backwards");
p.values.insert("swapChannels".into(), Value::Bool(true));
let mut b = vec![1.0, 2.0];
process("ec.audio.backwards", &p, &mut b, 48000, 0.0);
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");
for (k, v) in [("attack", 1.0), ("threshold", -20.0), ("ratio", 4.0), ("knee", 0.0), ("release", 100.0)] {
p.values.insert(k.into(), num(v));
}
p.values.insert("autoRelease".into(), Value::Bool(false));
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 mut p = defaults("ec.audio.gate");
p.values.insert("threshold".into(), num(-40.0));
// A −60 dB signal under the −40 dB threshold is muted.
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, 0.0) - 0.5f32.tanh()).abs() < 1e-6);
assert_eq!(distort_sample(1, 0.8, 1.0), 1.0);
for k in 0..DISTORTION_TYPES.len() as u32 {
for x in [-2.0f32, -0.3, 0.0, 0.2, 1.7] {
let y = distort_sample(k, x, 0.5);
assert!(y.abs() <= 1.0 && y.signum() * x.signum() >= 0.0, "{k} {x} {y}");
}
}
// Tube is asymmetric.
assert!(distort_sample(4, 0.1, 0.5) > -distort_sample(4, -0.1, 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);
}
#[test]
fn distortion_mix_resolution_and_downsample() {
let sr = 48000.0;
let src: Vec<f32> = sine(300.0, sr, 480).iter().map(|v| v * 0.5).collect();
// Mix 0 with unity volume passes the input through.
let mut p = defaults("ec.audio.distortion");
p.values.insert("mix".into(), num(0.0));
let mut b = src.clone();
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
assert!(b.iter().zip(&src).all(|(a, s)| (a - s).abs() < 1e-6));
// 2-bit resolution leaves at most 5 distinct levels (−1, −½, 0, ½, 1).
let mut p = defaults("ec.audio.distortion");
p.values.insert("bitDepth".into(), num(2.0));
let mut b = src.clone();
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
let mut levels: Vec<i32> = b.iter().map(|v| (v * 1000.0).round() as i32).collect();
levels.sort();
levels.dedup();
assert!(levels.len() <= 5, "{levels:?}");
// Downsample ×4 holds each value for 4 frames.
let mut p = defaults("ec.audio.distortion");
p.values.insert("downsample".into(), num(4.0));
let mut b = src.clone();
process("ec.audio.distortion", &p, &mut b, 48000, 0.0);
assert_eq!(b[0], b[6]);
assert_ne!(b[6], b[8]);
}
#[test]
fn compressor_output_limit_and_auto_release() {
let sr = 48000.0;
let mut p = defaults("ec.audio.compressor");
p.values.insert("outputLimit".into(), num(-12.0));
p.values.insert("makeupGain".into(), num(20.0));
let mut b = sine(1000.0, sr, 4800);
process("ec.audio.compressor", &p, &mut b, 48000, 0.0);
let ceiling = db_to_lin(-12.0) as f32;
assert!(b.iter().all(|v| v.abs() <= ceiling + 1e-6));
// After a short burst, auto release recovers faster than a long manual release.
let burst = |auto: bool| {
let mut p = defaults("ec.audio.compressor");
p.values.insert("autoRelease".into(), Value::Bool(auto));
p.values.insert("release".into(), num(4000.0));
let mut b: Vec<f32> = sine(1000.0, sr, 9600).iter().enumerate().map(|(i, v)| if i < 960 { *v } else { v * 0.2 }).collect();
process("ec.audio.compressor", &p, &mut b, 48000, 0.0);
rms(&b, 4800)
};
assert!(burst(true) > burst(false) * 1.2, "{} {}", burst(true), burst(false));
}
}