diff --git a/crates/engine/src/audio_effects_tests.rs b/crates/engine/src/audio_effects_tests.rs new file mode 100644 index 000000000..b85463e4c --- /dev/null +++ b/crates/engine/src/audio_effects_tests.rs @@ -0,0 +1,113 @@ +//! The Premiere audio-effect set through engine commands: listed with folders by +//! `effects.list`, applicable as clip effects (`effects.apply`) and track effects +//! (`mixer.addInsert`), parameters settable by id, and the sequence still renders. + +use super::*; +use filmcraft_project::ParamValue; +use filmcraft_project::effect::PREMIERE_AUDIO_EFFECTS; +use serde_json::json; + +fn demo() -> Session { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s +} + +fn a1_clip(s: &Session) -> u64 { + s.active_sequence().unwrap().audio_tracks[0].items[0].id.0 +} + +fn render(s: &Session, frames: usize) -> filmcraft_frame::AudioBuffer { + let provider = s.media.provider(s.project.clone(), s.services.clone()); + filmcraft_render::audio::mix_sequence(&s.project, s.active_sequence().unwrap(), 0, frames, &provider) +} + +#[test] +fn effects_list_has_every_audio_effect_in_its_folder() { + let mut s = demo(); + let list = s.execute("effects.list", json!({})).unwrap(); + let arr = list.as_array().unwrap(); + for id in PREMIERE_AUDIO_EFFECTS { + let e = arr.iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("{id} not listed")); + assert_eq!(e["kind"], "Audio", "{id}"); + assert_eq!(e["category"][0], "Audio Effects", "{id}"); + } + let folder = |id: &str| arr.iter().find(|e| e["id"] == id).unwrap()["category"][1].clone(); + assert_eq!(folder("tube_compressor"), "Amplitude and Compression"); + assert_eq!(folder("chorus_flanger"), "Modulation"); + assert_eq!(folder("binauralizer"), "Special"); + assert!(folder("volume_a").is_null(), "loose item"); +} + +#[test] +fn every_audio_effect_applies_to_clips_and_tracks_and_renders() { + let mut s = demo(); + let clip = a1_clip(&s); + for id in PREMIERE_AUDIO_EFFECTS { + let n0 = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.effects.len(); + s.execute("effects.apply", json!({"clips": [clip], "effect": id})).unwrap_or_else(|e| panic!("{id}: {e}")); + let it = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone(); + assert_eq!(it.effects.len(), n0 + 1, "{id}"); + assert_eq!(it.effects.last().unwrap().effect, id); + assert!(filmcraft_render::audio_fx::supported(id), "{id}"); + s.execute("mixer.addInsert", json!({"strip": "A1", "effect": id})).unwrap_or_else(|e| panic!("{id}: {e}")); + let b = render(&s, 2400); + assert_eq!(b.frames(), 2400); + assert!(b.channels.iter().flatten().all(|v| v.is_finite()), "{id}: non-finite mix"); + s.execute("mixer.removeInsert", json!({"strip": "A1", "slot": 0})).unwrap(); + s.execute("effects.remove", json!({"clip": clip, "index": n0})).unwrap(); + } +} + +#[test] +fn parameters_are_set_by_id_and_change_the_sound() { + let mut s = demo(); + let clip = a1_clip(&s); + let before = render(&s, 4800); + let peak = |b: &filmcraft_frame::AudioBuffer| b.channels.iter().flatten().fold(0.0f32, |a, v| a.max(v.abs())); + assert!(peak(&before) > 1e-3, "the demo has audio on A1"); + // Mute (a loose item) silences the clip. + s.execute("effects.apply", json!({"clips": [clip], "effect": "Mute"})).unwrap(); + let it = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone(); + let idx = it.effects.iter().position(|e| e.effect == "mute").unwrap(); + s.execute("effects.setParam", json!({"clip": clip, "effect": idx, "param": "mute", "value": true})).unwrap(); + let it = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone(); + assert_eq!(it.effects[idx].param("mute").unwrap().value, ParamValue::Bool(true)); + // the clip's own signal (other tracks of the demo keep playing) + let it = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone(); + let provider = s.media.provider(s.project.clone(), s.services.clone()); + let a0 = it.start.to_units_floor(48000); + let sig = filmcraft_render::audio::clip_signal(&it, a0, 4800, 48000, &provider).unwrap(); + let pk = sig.iter().flatten().fold(0.0f32, |a, v| a.max(v.abs())); + assert!(pk < 1e-6, "muted clip peak {pk}"); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + // A track-level Graphic Equalizer, its master gain set through mixer.setInsert (A1 soloed so + // only it is heard). + s.execute("mixer.setStrip", json!({"strip": "A1", "solo": true})).unwrap(); + let before = render(&s, 4800); + s.execute("mixer.addInsert", json!({"strip": "A1", "effect": "graphic_eq_30"})).unwrap(); + s.execute("mixer.setInsert", json!({"strip": "A1", "slot": 0, "params": {"gain": -12.0}})).unwrap(); + let quieter = render(&s, 4800); + let rms = |b: &filmcraft_frame::AudioBuffer| { + (b.channels.iter().flatten().map(|v| (*v as f64).powi(2)).sum::() / (b.frames() * b.channel_count()) as f64).sqrt() + }; + let d = 20.0 * (rms(&quieter) / rms(&before)).log10(); + assert!((d + 12.0).abs() < 1.0, "master gain −12 dB → {d}"); +} + +#[test] +fn shared_names_resolve_by_track_kind() { + let mut s = demo(); + let clip = a1_clip(&s); + for (name, id) in [("Invert", "invert_a"), ("Volume", "volume_a"), ("Channel Volume", "channel_volume_a"), ("Balance", "balance_a")] { + s.execute("effects.apply", json!({"clips": [clip], "effect": name})).unwrap(); + let it = s.active_sequence().unwrap().find_item(ClipId(clip)).unwrap().1.clone(); + assert_eq!(it.effects.last().unwrap().effect, id, "{name}"); + } + // On a video clip "Invert" is still the video effect. + let v = s.active_sequence().unwrap().video_tracks[0].items[0].id.0; + s.execute("effects.apply", json!({"clips": [v], "effect": "Invert"})).unwrap(); + let it = s.active_sequence().unwrap().find_item(ClipId(v)).unwrap().1.clone(); + assert!(it.effects.iter().any(|e| e.effect == "invert")); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 9db124484..48cf42a49 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -152,6 +152,26 @@ pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { pub(crate) fn clip_p(p: &Value, k: &str) -> Option { u64_p(p, k).map(ClipId) } +/// Effect by id or display name. Names shared by several definitions ("Invert", "Volume", +/// "Channel Volume") resolve to an applicable (non-intrinsic) effect whose kind matches the +/// target clips' tracks. +fn resolve_effect_name(s: &Session, p: &Value, name: &str) -> Option<&'static filmcraft_project::EffectDef> { + if let Some(d) = filmcraft_project::find_effect(name) { + return Some(d); + } + let n = name.to_ascii_lowercase(); + let cands: Vec<_> = filmcraft_project::effect_defs().iter().filter(|e| e.name.to_ascii_lowercase() == n).collect(); + if cands.len() <= 1 { + return cands.first().copied().or_else(|| filmcraft_project::find_effect(&n)); + } + let on_audio = s.active_sequence().is_some_and(|q| { + let clips = clips_p(s, p); + !clips.is_empty() && clips.iter().all(|c| q.find_item(*c).and_then(|(t, _)| q.track(t)).is_some_and(|t| t.kind == TrackKind::Audio)) + }); + let want = if on_audio { filmcraft_project::EffectKind::Audio } else { filmcraft_project::EffectKind::Video }; + cands.iter().find(|d| !d.intrinsic && d.kind == want).or_else(|| cands.iter().find(|d| !d.intrinsic)).or(cands.first()).copied() +} + pub(crate) fn clips_p(s: &Session, p: &Value) -> Vec { match p.get("clips").and_then(Value::as_array) { Some(a) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(), @@ -1723,7 +1743,7 @@ fn build() -> Vec { // ================= Effects ================= cmd!("effects.apply", "Apply Effect", [], None, r#"{"clips":[id]?,"effect":"gaussian_blur|Gaussian Blur|…"}"#, has_seq, |s, p| { let name = str_p(p, "effect").ok_or_else(|| bad("effects.apply", "need `effect`"))?; - let def = filmcraft_project::effect::find_effect_by_name(name).ok_or_else(|| bad("effects.apply", format!("unknown effect `{name}`")))?; + let def = resolve_effect_name(s, p, name).ok_or_else(|| bad("effects.apply", format!("unknown effect `{name}`")))?; if filmcraft_project::graphic::is_layer_id(def.id) { return Err(bad("effects.apply", "graphic layers are added with graphics.newText / graphics.newShape")); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index aa392e958..2b7afb025 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -722,6 +722,8 @@ pub fn media_duration(p: &Project, _pool: &MediaPool, id: ItemId) -> Option Value { "soloSafe": tr.mixer.solo_safe, "inputMap": tr.mixer.input_map.label(), "output": tr.mixer.output.map(|o| strip_label(seq, o)).unwrap_or_else(|| if id == MASTER_STRIP { String::new() } else { "Mix".into() }), - "inserts": tr.effects.iter().enumerate().map(|(i, e)| json!({"slot": i, "effect": e.effect, "name": e.def().map(|d| d.name).unwrap_or(""), "enabled": e.enabled, "postFader": e.post_fader})).collect::>(), + "inserts": tr.effects.iter().enumerate().map(|(i, e)| json!({"slot": i, "effect": e.effect, "name": e.def().map(|d| d.name).unwrap_or(""), "enabled": e.enabled, "postFader": e.post_fader, "params": insert_params(e)})).collect::>(), "sends": tr.mixer.sends.iter().enumerate().map(|(i, sd)| json!({"send": i, "target": strip_label(seq, sd.target), "levelDb": sd.level_db, "pan": sd.pan, "preFader": sd.pre_fader, "muted": sd.muted})).collect::>(), "at": {"time": t.0, "volumeDb": shown(LANE_VOLUME), "pan": shown(LANE_PAN), "mute": shown(LANE_MUTE) >= 0.5}, "lanes": lanes, @@ -583,6 +583,19 @@ fn slot_p(p: &Value, cmd: &str) -> Result { u64_p(p, "slot").map(|v| v as usize).ok_or_else(|| bad(cmd, "need `slot` (0-based)")) } +/// An insert's scalar parameter values (numbers; toggles as booleans) for `mixer.inspect`. +fn insert_params(e: &filmcraft_project::EffectInstance) -> Value { + let mut m = serde_json::Map::new(); + for (k, p) in &e.params { + let v = match &p.value { + ParamValue::Bool(b) => json!(b), + v => v.as_f64().map_or(Value::Null, |x| json!(x)), + }; + m.insert(k.clone(), v); + } + Value::Object(m) +} + fn add_insert(s: &mut Session, p: &Value) -> Result { let id = strip_p(s, p, "mixer.addInsert")?; let eid = str_p(p, "effect").ok_or_else(|| bad("mixer.addInsert", "need `effect` (an audio effect id)"))?; @@ -628,6 +641,12 @@ fn set_insert(s: &mut Session, p: &Value) -> Result { } if let Some(obj) = p.get("params").and_then(Value::as_object) { for (k, v) in obj { + // instances saved before a parameter existed get it from the definition + if e.param(k).is_none() + && let Some(d) = e.def().and_then(|d| d.param(k)) + { + e.params.insert(k.clone(), filmcraft_project::Param::new(d.default.clone())); + } let par = e.param_mut(k).ok_or_else(|| bad("mixer.setInsert", format!("no parameter `{k}`")))?; par.value = match (&par.value, v) { (ParamValue::Bool(_), Value::Bool(b)) => ParamValue::Bool(*b), diff --git a/crates/project/src/effect.rs b/crates/project/src/effect.rs index ff38f2057..15facea9c 100644 --- a/crates/project/src/effect.rs +++ b/crates/project/src/effect.rs @@ -11,7 +11,10 @@ use filmcraft_geom::Vec2; use serde::{Deserialize, Serialize}; use crate::keyframe::{Param, ParamValue}; + +mod audio; use crate::mask::Mask; +pub use audio::{GEQ10_LABELS, GEQ20_LABELS, GEQ30_LABELS, PREMIERE_AUDIO_EFFECTS}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum EffectKind { @@ -819,110 +822,7 @@ fn build_effects() -> Vec { vtrans("cube_spin", "Cube Spin", MOTION3D, vec![]), vtrans("flip_over", "Flip Over", MOTION3D, vec![]), // ---- audio effects ---- - audio("amplify", "Amplify", A_AMP, vec![fs("gain", "Gain", 0.0, (-96.0, 48.0), (-24.0, 24.0), "dB", 1)]), - audio( - "dynamics", - "Dynamics Processing", - A_AMP, - vec![ - fs("threshold", "Threshold", -20.0, (-60.0, 0.0), (-60.0, 0.0), "dB", 1), - fs("ratio", "Ratio", 4.0, (1.0, 30.0), (1.0, 30.0), ":1", 1), - fs("attack", "Attack", 10.0, (0.1, 500.0), (0.1, 500.0), "ms", 1), - fs("release", "Release", 100.0, (1.0, 5000.0), (1.0, 5000.0), "ms", 0), - ], - ), - audio( - "hard_limiter", - "Hard Limiter", - A_AMP, - vec![ - fs("max", "Maximum Amplitude", -0.1, (-30.0, 0.0), (-30.0, 0.0), "dB", 1), - fs("boost", "Input Boost", 0.0, (-30.0, 30.0), (-30.0, 30.0), "dB", 1), - fs("lookahead", "Look-Ahead Time", 7.0, (0.0, 30.0), (0.0, 30.0), "ms", 1), - fs("release", "Release Time", 100.0, (1.0, 1000.0), (1.0, 1000.0), "ms", 0), - ], - ), - audio("multiband_compressor", "Multiband Compressor", A_AMP, vec![]), - audio("channel_mixer_a", "Channel Mixer", A_AMP, vec![]), - audio( - "delay", - "Delay", - A_DELAY, - vec![ - fs("delay", "Delay", 1.0, (0.0, 2.0), (0.0, 2.0), "s", 2), - f("feedback", "Feedback", 0.0, 0.0, 100.0, "%"), - f("mix", "Mix", 50.0, 0.0, 100.0, "%"), - ], - ), - audio("analog_delay", "Analog Delay", A_DELAY, vec![]), - audio( - "parametric_eq", - "Parametric Equalizer", - A_FILTER, - vec![ - fs("low_freq", "Low Frequency", 100.0, (20.0, 20000.0), (20.0, 1000.0), "Hz", 0), - fs("low_gain", "Low Gain", 0.0, (-24.0, 24.0), (-24.0, 24.0), "dB", 1), - fs("mid_freq", "Mid Frequency", 1000.0, (20.0, 20000.0), (200.0, 8000.0), "Hz", 0), - fs("mid_gain", "Mid Gain", 0.0, (-24.0, 24.0), (-24.0, 24.0), "dB", 1), - fs("mid_q", "Mid Q", 1.0, (0.1, 20.0), (0.1, 10.0), "", 2), - fs("high_freq", "High Frequency", 8000.0, (20.0, 20000.0), (2000.0, 20000.0), "Hz", 0), - fs("high_gain", "High Gain", 0.0, (-24.0, 24.0), (-24.0, 24.0), "dB", 1), - ], - ), - audio("highpass", "Highpass", A_FILTER, vec![fs("cutoff", "Cutoff", 80.0, (20.0, 20000.0), (20.0, 2000.0), "Hz", 0)]), - audio("lowpass", "Lowpass", A_FILTER, vec![fs("cutoff", "Cutoff", 8000.0, (20.0, 20000.0), (500.0, 20000.0), "Hz", 0)]), - audio( - "bandpass", - "Bandpass", - A_FILTER, - vec![fs("center", "Center", 1000.0, (20.0, 20000.0), (20.0, 20000.0), "Hz", 0), fs("q", "Q", 1.0, (0.1, 20.0), (0.1, 20.0), "", 2)], - ), - audio("notch", "Notch Filter", A_FILTER, vec![]), - audio("simple_eq", "Simple Parametric EQ", A_FILTER, vec![]), - audio("graphic_eq", "Graphic Equalizer (10 Bands)", A_FILTER, vec![]), - audio("denoise", "DeNoise", A_NOISE, vec![f("amount", "Amount", 40.0, 0.0, 100.0, "%")]), - audio("dehummer", "DeHummer", A_NOISE, vec![ch("freq", "Frequency", &["50 Hz", "60 Hz"], 1), f("gain", "Gain", -40.0, -80.0, 0.0, "dB")]), - audio("declicker", "Automatic Click Remover", A_NOISE, vec![]), - audio( - "dereverb", - "DeReverb", - A_NOISE, - vec![f("amount", "Amount", 50.0, 0.0, 100.0, "%"), fs("rt60", "Decay Time (RT60)", 0.8, (0.1, 5.0), (0.1, 3.0), "s", 2)], - ), - audio( - "deesser", - "DeEsser", - A_AMP, - vec![ - fs("frequency", "Frequency", 6000.0, (2000.0, 12000.0), (2000.0, 12000.0), "Hz", 0), - fs("threshold", "Threshold", -12.0, (-40.0, 0.0), (-40.0, 0.0), "dB", 1), - fs("reduction", "Maximum Reduction", 8.0, (0.0, 24.0), (0.0, 24.0), "dB", 1), - ], - ), - audio("speech_enhance", "Enhance Speech", A_NOISE, vec![f("mix", "Mix", 100.0, 0.0, 100.0, "%"), ch("tone", "Voice", &["Low Tone", "High Tone"], 0)]), - audio("stereo_width", "Stereo Width", A_STEREO, vec![fs("width", "Width", 100.0, (0.0, 200.0), (0.0, 200.0), "%", 0)]), - audio( - "studio_reverb", - "Studio Reverb", - A_REVERB, - vec![ - f("room", "Room Size", 50.0, 0.0, 100.0, "%"), - f("decay", "Decay", 50.0, 0.0, 100.0, "%"), - f("damping", "High Frequency Damping", 50.0, 0.0, 100.0, "%"), - f("dry", "Dry", 90.0, 0.0, 100.0, "%"), - f("wet", "Wet", 35.0, 0.0, 100.0, "%"), - ], - ), - audio("convolution_reverb", "Convolution Reverb", A_REVERB, vec![]), - audio("surround_reverb", "Surround Reverb", A_REVERB, vec![]), - audio("loudness_radar", "Loudness Meter", A_SPECIAL, vec![]), - audio("invert_a", "Invert", A_SPECIAL, vec![]), - audio( - "pitch_shifter", - "Pitch Shifter", - A_TIME, - vec![fs("semitones", "Semi-tones", 0.0, (-12.0, 12.0), (-12.0, 12.0), "", 0), fs("cents", "Cents", 0.0, (-100.0, 100.0), (-100.0, 100.0), "", 0)], - ), + // (defined in effect/audio.rs; appended below) EffectDef { id: "constant_power", name: "Constant Power", @@ -957,6 +857,7 @@ fn build_effects() -> Vec { yuv: false, }, ]; + v.extend(audio::defs()); v.extend(crate::graphic::layer_defs()); // YUV badge for the colour/intrinsic set for e in &mut v { diff --git a/crates/project/src/effect/audio.rs b/crates/project/src/effect/audio.rs new file mode 100644 index 000000000..16734233b --- /dev/null +++ b/crates/project/src/effect/audio.rs @@ -0,0 +1,669 @@ +//! Audio effect definitions: Premiere's Audio Effects bin (Amplitude and Compression, Delay and +//! Echo, Filter and EQ, Modulation, Noise Reduction/Restoration, Reverb, Special, Stereo +//! Imagery, Time and Pitch, and the loose Balance / Mute / Volume items). +//! +//! Parameter ids match the `filmcraft-audio-dsp` effect parameters wherever the mapping is +//! direct (`render::audio_fx` checks this), so agents can drive every DSP parameter by id. + +use super::{A_AMP, A_DELAY, A_FILTER, A_NOISE, A_REVERB, A_SPECIAL, A_STEREO, A_TIME, EffectDef, ParamDef, audio, b, ch, f, fs, grp}; + +const A_MOD: &[&str] = &["Audio Effects", "Modulation"]; +/// Loose items at the end of the Audio Effects bin. +const A_ROOT: &[&str] = &["Audio Effects"]; + +fn db(id: &'static str, label: &'static str, def: f64, min: f64, max: f64) -> ParamDef { + fs(id, label, def, (min, max), (min, max), "dB", 1) +} +fn hz(id: &'static str, label: &'static str, def: f64, min: f64, max: f64) -> ParamDef { + fs(id, label, def, (min, max), (min, max), "Hz", 0) +} +fn ms(id: &'static str, label: &'static str, def: f64, min: f64, max: f64) -> ParamDef { + fs(id, label, def, (min, max), (min, max), "ms", 1) +} +fn pct(id: &'static str, label: &'static str, def: f64, min: f64, max: f64) -> ParamDef { + fs(id, label, def, (min, max), (min, max), "%", 0) +} +fn num(id: &'static str, label: &'static str, def: f64, min: f64, max: f64, unit: &'static str, decimals: u8) -> ParamDef { + fs(id, label, def, (min, max), (min, max), unit, decimals) +} +/// A non-animatable choice/bool already; non-animatable float (filter designs, impulses…). +fn fixed(mut p: ParamDef) -> ParamDef { + p.animatable = false; + p +} +fn group(g: &'static str, ps: Vec) -> Vec { + ps.into_iter().map(|p| grp(p, g)).collect() +} + +/// Band labels of the 10/20/30-band graphic equalisers (parameters `b1`…`bN`, plus `gain`). +pub const GEQ10_LABELS: [&str; 10] = ["31.5 Hz", "63 Hz", "125 Hz", "250 Hz", "500 Hz", "1 kHz", "2 kHz", "4 kHz", "8 kHz", "16 kHz"]; +pub const GEQ20_LABELS: [&str; 20] = [ + "31.5 Hz", "44 Hz", "63 Hz", "88 Hz", "125 Hz", "177 Hz", "250 Hz", "355 Hz", "500 Hz", "710 Hz", "1 kHz", "1.4 kHz", "2 kHz", "2.8 kHz", "4 kHz", + "5.6 kHz", "8 kHz", "11.2 kHz", "16 kHz", "22.4 kHz", +]; +pub const GEQ30_LABELS: [&str; 30] = [ + "25 Hz", "31.5 Hz", "40 Hz", "50 Hz", "63 Hz", "80 Hz", "100 Hz", "125 Hz", "160 Hz", "200 Hz", "250 Hz", "315 Hz", "400 Hz", "500 Hz", "630 Hz", "800 Hz", + "1 kHz", "1.25 kHz", "1.6 kHz", "2 kHz", "2.5 kHz", "3.15 kHz", "4 kHz", "5 kHz", "6.3 kHz", "8 kHz", "10 kHz", "12.5 kHz", "16 kHz", "20 kHz", +]; +const BAND_IDS: [&str; 30] = [ + "b1", "b2", "b3", "b4", "b5", "b6", "b7", "b8", "b9", "b10", "b11", "b12", "b13", "b14", "b15", "b16", "b17", "b18", "b19", "b20", "b21", "b22", "b23", + "b24", "b25", "b26", "b27", "b28", "b29", "b30", +]; + +fn geq(labels: &[&'static str]) -> Vec { + let mut v: Vec = labels.iter().zip(BAND_IDS).map(|(l, id)| db(id, l, 0.0, -24.0, 24.0)).collect(); + v.push(db("gain", "Master Gain", 0.0, -24.0, 24.0)); + v +} + +const SLOPES: &[&str] = &["12 dB/oct", "24 dB/oct", "36 dB/oct", "48 dB/oct"]; + +fn peq_band(p: [&'static str; 4], name: &'static str, freq: f64, q: f64) -> Vec { + group(name, vec![b(p[0], "On", true), hz(p[1], "Frequency", freq, 20.0, 20000.0), db(p[2], "Gain", 0.0, -30.0, 30.0), num(p[3], "Q", q, 0.1, 30.0, "", 2)]) +} + +fn parametric_eq() -> Vec { + let mut v = vec![db("master_gain", "Master Gain", 0.0, -30.0, 30.0)]; + v.extend(group("High Pass", vec![b("hp_on", "On", false), hz("hp_freq", "Frequency", 30.0, 10.0, 20000.0), ch("hp_slope", "Slope", SLOPES, 0)])); + // Low shelf / band 3 / high shelf keep the ids of the earlier three-band version. + v.extend(peq_band(["low_on", "low_freq", "low_gain", "low_q"], "Low Shelf", 100.0, 0.71)); + v.extend(peq_band(["b1_on", "b1_freq", "b1_gain", "b1_q"], "Band 1", 200.0, 1.0)); + v.extend(peq_band(["b2_on", "b2_freq", "b2_gain", "b2_q"], "Band 2", 500.0, 1.0)); + v.extend(peq_band(["mid_on", "mid_freq", "mid_gain", "mid_q"], "Band 3", 1000.0, 1.0)); + v.extend(peq_band(["b4_on", "b4_freq", "b4_gain", "b4_q"], "Band 4", 2000.0, 1.0)); + v.extend(peq_band(["b5_on", "b5_freq", "b5_gain", "b5_q"], "Band 5", 5000.0, 1.0)); + v.extend(peq_band(["high_on", "high_freq", "high_gain", "high_q"], "High Shelf", 8000.0, 0.71)); + v.extend(group("Low Pass", vec![b("lp_on", "On", false), hz("lp_freq", "Frequency", 18000.0, 20.0, 22000.0), ch("lp_slope", "Slope", SLOPES, 0)])); + v +} + +fn multiband() -> Vec { + let mut v = + group("Crossovers", vec![hz("xo1", "Low", 120.0, 20.0, 1000.0), hz("xo2", "Mid", 2000.0, 100.0, 8000.0), hz("xo3", "High", 10000.0, 1000.0, 20000.0)]); + let ids: [[&'static str; 7]; 4] = [ + ["b1_threshold", "b1_ratio", "b1_attack", "b1_release", "b1_gain", "b1_solo", "b1_bypass"], + ["b2_threshold", "b2_ratio", "b2_attack", "b2_release", "b2_gain", "b2_solo", "b2_bypass"], + ["b3_threshold", "b3_ratio", "b3_attack", "b3_release", "b3_gain", "b3_solo", "b3_bypass"], + ["b4_threshold", "b4_ratio", "b4_attack", "b4_release", "b4_gain", "b4_solo", "b4_bypass"], + ]; + for (k, i) in ids.iter().enumerate() { + let name = ["Band 1", "Band 2", "Band 3", "Band 4"][k]; + v.extend(group( + name, + vec![ + db(i[0], "Threshold", -18.0, -60.0, 0.0), + num(i[1], "Ratio", 3.0, 1.0, 30.0, ":1", 1), + ms(i[2], "Attack", 10.0, 0.1, 500.0), + ms(i[3], "Release", 100.0, 1.0, 5000.0), + db(i[4], "Gain", 0.0, -18.0, 18.0), + b(i[5], "Solo", false), + b(i[6], "Bypass", false), + ], + )); + } + v.extend(group( + "Output", + vec![ + db("output", "Output Gain", 0.0, -18.0, 18.0), + b("lim_on", "Limiter", false), + db("lim_threshold", "Limiter Threshold", -0.1, -30.0, 0.0), + ms("lim_release", "Limiter Release", 50.0, 1.0, 1000.0), + b("link", "Link Channels", true), + ], + )); + v +} + +fn dynamics() -> Vec { + let mut v = group( + "Auto Gate", + vec![ + b("gate_on", "Auto Gate", false), + db("gate_threshold", "Threshold", -50.0, -80.0, 0.0), + ms("gate_attack", "Attack", 2.0, 0.1, 100.0), + ms("gate_release", "Release", 100.0, 1.0, 3000.0), + ms("gate_hold", "Hold", 50.0, 0.0, 1000.0), + ], + ); + v.extend(group( + "Compressor", + vec![ + b("comp_on", "Compressor", true), + db("comp_threshold", "Threshold", -20.0, -60.0, 0.0), + num("comp_ratio", "Ratio", 2.0, 1.0, 30.0, ":1", 1), + ms("comp_attack", "Attack", 10.0, 0.1, 300.0), + ms("comp_release", "Release", 100.0, 1.0, 3000.0), + b("comp_auto", "Auto Makeup", false), + db("comp_makeup", "Makeup", 0.0, 0.0, 30.0), + ], + )); + v.extend(group( + "Expander", + vec![b("exp_on", "Expander", false), db("exp_threshold", "Threshold", -60.0, -80.0, 0.0), num("exp_ratio", "Ratio", 2.0, 1.0, 30.0, ":1", 1)], + )); + v.extend(group( + "Limiter", + vec![ + b("lim_on", "Limiter", false), + db("lim_threshold", "Threshold", -1.0, -30.0, 0.0), + ms("lim_release", "Release", 50.0, 1.0, 1000.0), + b("soft_clip", "Soft Clip", false), + ], + )); + v.push(db("output", "Output Gain", 0.0, -30.0, 30.0)); + v +} + +fn notch_filter() -> Vec { + let ids: [[&'static str; 3]; 6] = [ + ["n1_on", "n1_freq", "n1_gain"], + ["n2_on", "n2_freq", "n2_gain"], + ["n3_on", "n3_freq", "n3_gain"], + ["n4_on", "n4_freq", "n4_gain"], + ["n5_on", "n5_freq", "n5_gain"], + ["n6_on", "n6_freq", "n6_gain"], + ]; + let mut v = Vec::new(); + for (k, i) in ids.iter().enumerate() { + let name = ["Notch 1", "Notch 2", "Notch 3", "Notch 4", "Notch 5", "Notch 6"][k]; + v.extend(group(name, vec![b(i[0], "On", false), hz(i[1], "Frequency", 60.0 * (k + 1) as f64, 20.0, 20000.0), db(i[2], "Gain", -30.0, -90.0, 0.0)])); + } + v.push(ch("width", "Notch Width", &["Narrow", "Very Narrow", "Super Narrow"], 0)); + v +} + +fn fft_filter() -> Vec { + let ids: [[&'static str; 2]; 8] = [ + ["p1_freq", "p1_gain"], + ["p2_freq", "p2_gain"], + ["p3_freq", "p3_gain"], + ["p4_freq", "p4_gain"], + ["p5_freq", "p5_gain"], + ["p6_freq", "p6_gain"], + ["p7_freq", "p7_gain"], + ["p8_freq", "p8_gain"], + ]; + let freqs = [40.0, 100.0, 250.0, 600.0, 1500.0, 4000.0, 9000.0, 16000.0]; + let mut v = Vec::new(); + for (k, i) in ids.iter().enumerate() { + let name = ["Point 1", "Point 2", "Point 3", "Point 4", "Point 5", "Point 6", "Point 7", "Point 8"][k]; + v.extend(group(name, vec![hz(i[0], "Frequency", freqs[k], 20.0, 20000.0), db(i[1], "Gain", 0.0, -60.0, 20.0)])); + } + v.push(ch("interp", "Interpolation", &["Linear", "Smooth"], 1)); + v +} + +/// All audio effect definitions, in Effects-panel order. +pub(super) fn defs() -> Vec { + vec![ + // ---- Amplitude and Compression ---- + audio("amplify", "Amplify", A_AMP, vec![fs("gain", "Gain", 0.0, (-96.0, 24.0), (-24.0, 24.0), "dB", 1)]), + audio( + "channel_mixer_a", + "Channel Mixer", + A_AMP, + vec![ + pct("l_from_l", "Left - Left", 100.0, -100.0, 100.0), + pct("l_from_r", "Left - Right", 0.0, -100.0, 100.0), + b("invert_l", "Invert Left", false), + pct("r_from_l", "Right - Left", 0.0, -100.0, 100.0), + pct("r_from_r", "Right - Right", 100.0, -100.0, 100.0), + b("invert_r", "Invert Right", false), + ], + ), + audio( + "channel_volume_a", + "Channel Volume", + A_AMP, + vec![fs("left", "Left", 0.0, (-96.0, 24.0), (-60.0, 6.0), "dB", 1), fs("right", "Right", 0.0, (-96.0, 24.0), (-60.0, 6.0), "dB", 1)], + ), + audio( + "deesser", + "DeEsser", + A_AMP, + vec![ + hz("frequency", "Frequency", 6000.0, 2000.0, 12000.0), + db("threshold", "Threshold", -12.0, -40.0, 0.0), + db("reduction", "Maximum Reduction", 8.0, 0.0, 24.0), + ], + ), + audio("dynamics_rack", "Dynamics", A_AMP, dynamics()), + audio( + "dynamics", + "Dynamics Processing", + A_AMP, + vec![ + db("threshold", "Threshold", -20.0, -60.0, 0.0), + num("ratio", "Ratio", 4.0, 1.0, 30.0, ":1", 1), + ms("attack", "Attack", 10.0, 0.1, 500.0), + fs("release", "Release", 100.0, (1.0, 5000.0), (1.0, 5000.0), "ms", 0), + ], + ), + audio( + "hard_limiter", + "Hard Limiter", + A_AMP, + vec![ + db("max", "Maximum Amplitude", -0.1, -30.0, 0.0), + db("boost", "Input Boost", 0.0, -30.0, 30.0), + db_ms_lookahead(), + fs("release", "Release Time", 100.0, (1.0, 1000.0), (1.0, 1000.0), "ms", 0), + ], + ), + audio("multiband_compressor", "Multiband Compressor", A_AMP, multiband()), + audio( + "single_band_compressor", + "Single-band Compressor", + A_AMP, + vec![ + db("threshold", "Threshold", -20.0, -60.0, 0.0), + num("ratio", "Ratio", 4.0, 1.0, 30.0, ":1", 1), + ms("attack", "Attack", 10.0, 0.1, 300.0), + ms("release", "Release", 100.0, 1.0, 3000.0), + db("output", "Output Gain", 0.0, -30.0, 30.0), + ], + ), + audio( + "tube_compressor", + "Tube-modeled Compressor", + A_AMP, + vec![ + db("threshold", "Threshold", -20.0, -60.0, 0.0), + num("ratio", "Ratio", 4.0, 1.0, 30.0, ":1", 1), + ms("attack", "Attack", 10.0, 0.1, 500.0), + ms("release", "Release", 100.0, 1.0, 5000.0), + db("output", "Output Gain", 0.0, -30.0, 30.0), + ], + ), + // ---- Delay and Echo ---- + audio( + "analog_delay", + "Analog Delay", + A_DELAY, + vec![ + ch("mode", "Mode", &["Tape", "Tape/Tube", "Analog"], 0), + pct("dry", "Dry Out", 100.0, 0.0, 100.0), + pct("wet", "Wet Out", 40.0, 0.0, 100.0), + ms("delay", "Delay", 250.0, 5.0, 8000.0), + pct("feedback", "Feedback", 40.0, 0.0, 200.0), + pct("trash", "Trash", 0.0, 0.0, 200.0), + pct("spread", "Spread", 0.0, 0.0, 200.0), + ], + ), + audio( + "delay", + "Delay", + A_DELAY, + vec![ + fs("delay", "Delay", 1.0, (0.0, 2.0), (0.0, 2.0), "s", 2), + f("feedback", "Feedback", 0.0, 0.0, 100.0, "%"), + f("mix", "Mix", 50.0, 0.0, 100.0, "%"), + ], + ), + audio( + "multitap_delay", + "Multitap Delay", + A_DELAY, + vec![ + ms("delay1", "Delay 1", 250.0, 1.0, 4000.0), + pct("feedback1", "Feedback 1", 0.0, 0.0, 95.0), + db("level1", "Level 1", -6.0, -96.0, 0.0), + ms("delay2", "Delay 2", 500.0, 1.0, 4000.0), + pct("feedback2", "Feedback 2", 0.0, 0.0, 95.0), + db("level2", "Level 2", -9.0, -96.0, 0.0), + ms("delay3", "Delay 3", 750.0, 1.0, 4000.0), + pct("feedback3", "Feedback 3", 0.0, 0.0, 95.0), + db("level3", "Level 3", -12.0, -96.0, 0.0), + ms("delay4", "Delay 4", 1000.0, 1.0, 4000.0), + pct("feedback4", "Feedback 4", 0.0, 0.0, 95.0), + db("level4", "Level 4", -15.0, -96.0, 0.0), + pct("mix", "Mix", 50.0, 0.0, 100.0), + ], + ), + // ---- Filter and EQ ---- + audio("bandpass", "Bandpass", A_FILTER, vec![hz("center", "Center", 1000.0, 20.0, 20000.0), num("q", "Q", 1.0, 0.1, 20.0, "", 2)]), + audio("bass", "Bass", A_FILTER, vec![db("boost", "Boost", 0.0, -24.0, 24.0)]), + audio("fft_filter", "FFT Filter", A_FILTER, fft_filter()), + audio("graphic_eq", "Graphic Equalizer (10 Bands)", A_FILTER, geq(&GEQ10_LABELS)), + audio("graphic_eq_20", "Graphic Equalizer (20 Bands)", A_FILTER, geq(&GEQ20_LABELS)), + audio("graphic_eq_30", "Graphic Equalizer (30 Bands)", A_FILTER, geq(&GEQ30_LABELS)), + audio("highpass", "Highpass", A_FILTER, vec![fs("cutoff", "Cutoff", 80.0, (20.0, 20000.0), (20.0, 2000.0), "Hz", 0)]), + audio("lowpass", "Lowpass", A_FILTER, vec![fs("cutoff", "Cutoff", 8000.0, (20.0, 20000.0), (500.0, 20000.0), "Hz", 0)]), + audio("notch", "Notch Filter", A_FILTER, notch_filter()), + audio("parametric_eq", "Parametric Equalizer", A_FILTER, parametric_eq()), + audio( + "scientific_filter", + "Scientific Filter", + A_FILTER, + vec![ + ch("type", "Type", &["Bessel", "Butterworth", "Chebyshev", "Elliptical"], 1), + ch("mode", "Mode", &["Low Pass", "High Pass", "Band Pass", "Band Stop"], 0), + fixed(num("order", "Order", 6.0, 1.0, 12.0, "", 0)), + hz("cutoff", "Cutoff", 1000.0, 20.0, 20000.0), + hz("high_cutoff", "High Cutoff", 4000.0, 20.0, 20000.0), + fixed(num("ripple", "Passband Ripple", 1.0, 0.01, 6.0, "dB", 2)), + fixed(db("stop_atten", "Stopband Attenuation", 60.0, 20.0, 120.0)), + db("gain", "Master Gain", 0.0, -30.0, 30.0), + ], + ), + audio("simple_notch", "Simple Notch Filter", A_FILTER, vec![hz("center", "Center", 1000.0, 20.0, 20000.0), num("q", "Q", 10.0, 0.1, 100.0, "", 1)]), + audio( + "simple_eq", + "Simple Parametric EQ", + A_FILTER, + vec![hz("center", "Center", 1000.0, 20.0, 20000.0), num("q", "Q", 1.0, 0.1, 20.0, "", 2), db("boost", "Boost", 0.0, -24.0, 24.0)], + ), + audio("treble", "Treble", A_FILTER, vec![db("boost", "Boost", 0.0, -24.0, 24.0)]), + // ---- Modulation ---- + audio( + "chorus_flanger", + "Chorus/Flanger", + A_MOD, + vec![ + ch("mode", "Mode", &["Chorus", "Flanger"], 0), + num("speed", "Speed", 0.8, 0.05, 10.0, "Hz", 2), + pct("width", "Width", 50.0, 0.0, 100.0), + pct("intensity", "Intensity", 30.0, 0.0, 100.0), + pct("transience", "Transience", 0.0, 0.0, 100.0), + pct("mix", "Mix", 50.0, 0.0, 100.0), + ], + ), + audio( + "flanger", + "Flanger", + A_MOD, + vec![ + ms("initial_delay", "Initial Delay", 1.0, 0.1, 20.0), + ms("final_delay", "Final Delay", 5.0, 0.1, 20.0), + num("stereo_phasing", "Stereo Phasing", 90.0, 0.0, 180.0, "°", 0), + pct("feedback", "Feedback", 50.0, -100.0, 100.0), + num("rate", "Modulation Rate", 0.5, 0.01, 10.0, "Hz", 2), + b("inverted", "Inverted", false), + b("special", "Special Effects", false), + b("sinusoidal", "Sinusoidal", true), + pct("mix", "Mix", 50.0, 0.0, 100.0), + ], + ), + audio( + "phaser", + "Phaser", + A_MOD, + vec![ + fixed(num("stages", "Stages", 4.0, 2.0, 12.0, "", 0)), + pct("intensity", "Intensity", 100.0, 0.0, 100.0), + pct("depth", "Depth", 70.0, 0.0, 100.0), + num("rate", "Modulation Rate", 0.5, 0.01, 10.0, "Hz", 2), + num("phase_diff", "Phase Difference", 90.0, 0.0, 180.0, "°", 0), + hz("upper_freq", "Upper Frequency", 2500.0, 100.0, 20000.0), + pct("feedback", "Feedback", 0.0, -100.0, 100.0), + pct("mix", "Mix", 50.0, 0.0, 100.0), + db("output", "Output Gain", 0.0, -24.0, 24.0), + ], + ), + // ---- Noise Reduction/Restoration ---- + audio( + "declicker", + "Automatic Click Remover", + A_NOISE, + vec![num("threshold", "Threshold", 30.0, 1.0, 100.0, "", 0), num("complexity", "Complexity", 16.0, 1.0, 100.0, "", 0)], + ), + audio( + "dehummer", + "DeHummer", + A_NOISE, + vec![ + ch("freq", "Frequency", &["50 Hz", "60 Hz"], 1), + f("gain", "Gain", -40.0, -80.0, 0.0, "dB"), + fixed(num("harmonics", "Number of Harmonics", 5.0, 1.0, 10.0, "", 0)), + num("q", "Q", 30.0, 1.0, 100.0, "", 0), + ], + ), + audio("denoise", "DeNoise", A_NOISE, vec![f("amount", "Amount", 40.0, 0.0, 100.0, "%")]), + audio( + "dereverb", + "DeReverb", + A_NOISE, + vec![f("amount", "Amount", 50.0, 0.0, 100.0, "%"), fs("rt60", "Decay Time (RT60)", 0.8, (0.1, 5.0), (0.1, 3.0), "s", 2)], + ), + audio("speech_enhance", "Enhance Speech", A_NOISE, vec![f("mix", "Mix", 100.0, 0.0, 100.0, "%"), ch("tone", "Voice", &["Low Tone", "High Tone"], 0)]), + // ---- Reverb ---- + audio( + "convolution_reverb", + "Convolution Reverb", + A_REVERB, + vec![ + ch("impulse", "Impulse", &["Small Room", "Medium Room", "Large Hall", "Cathedral", "Plate", "Ambience", "Vocal Booth"], 1), + pct("mix", "Mix", 30.0, 0.0, 100.0), + fixed(pct("room_size", "Room Size", 100.0, 10.0, 100.0)), + fixed(hz("damping_lf", "Damping LF", 10.0, 10.0, 1000.0)), + fixed(hz("damping_hf", "Damping HF", 20000.0, 1000.0, 20000.0)), + ms("predelay", "Pre-Delay", 0.0, 0.0, 500.0), + pct("width", "Width", 100.0, 0.0, 100.0), + db("gain", "Gain", 0.0, -24.0, 12.0), + ], + ), + audio( + "studio_reverb", + "Studio Reverb", + A_REVERB, + vec![ + f("room", "Room Size", 50.0, 0.0, 100.0, "%"), + f("decay", "Decay", 50.0, 0.0, 100.0, "%"), + f("damping", "High Frequency Damping", 50.0, 0.0, 100.0, "%"), + f("dry", "Dry", 90.0, 0.0, 100.0, "%"), + f("wet", "Wet", 35.0, 0.0, 100.0, "%"), + ], + ), + audio( + "surround_reverb", + "Surround Reverb", + A_REVERB, + vec![ + pct("center_input", "Center Input", 100.0, 0.0, 100.0), + pct("room_size", "Room Size", 50.0, 0.0, 100.0), + num("decay", "Decay", 1.5, 0.1, 20.0, "s", 2), + ms("predelay", "Pre-Delay", 20.0, 0.0, 200.0), + pct("damping", "Damping", 40.0, 0.0, 100.0), + pct("early", "Early Reflections", 40.0, 0.0, 100.0), + hz("low_cut", "Low Frequency Cut", 20.0, 20.0, 1000.0), + hz("high_cut", "High Frequency Cut", 12000.0, 1000.0, 20000.0), + pct("width", "Width", 100.0, 0.0, 100.0), + pct("dry", "Dry", 80.0, 0.0, 100.0), + pct("wet", "Wet", 30.0, 0.0, 100.0), + ], + ), + // ---- Special ---- + audio( + "binauralizer", + "Binauralizer - Ambisonics", + A_SPECIAL, + vec![ + num("angle", "Speaker Angle", 30.0, 0.0, 90.0, "°", 0), + num("head_size", "Head Size", 17.5, 12.0, 24.0, "cm", 1), + pct("mix", "Mix", 100.0, 0.0, 100.0), + ], + ), + audio( + "distortion", + "Distortion", + A_SPECIAL, + vec![ + db("drive", "Drive", 12.0, 0.0, 48.0), + ch("curve", "Curve", &["Soft Clip", "Hard Clip", "Tube", "Foldback"], 0), + pct("symmetry", "Asymmetry", 0.0, -100.0, 100.0), + hz("tone", "Tone", 20000.0, 500.0, 20000.0), + db("output", "Output Gain", 0.0, -48.0, 12.0), + pct("mix", "Mix", 100.0, 0.0, 100.0), + ], + ), + audio("fill_left", "Fill Left with Right", A_SPECIAL, vec![]), + audio("fill_right", "Fill Right with Left", A_SPECIAL, vec![]), + audio( + "guitar_suite", + "GuitarSuite", + A_SPECIAL, + vec![ + pct("compressor", "Compressor", 30.0, 0.0, 100.0), + pct("distortion", "Distortion", 40.0, 0.0, 100.0), + ch("dist_type", "Distortion Type", &["Soft", "Hard", "Fuzz"], 0), + ch("amp", "Amplifier", &["None", "Clean Combo", "British Stack", "Tweed", "Modern High Gain"], 1), + ch("filter", "Filter", &["None", "Low Pass", "High Pass", "Band Pass"], 0), + hz("filter_freq", "Filter Frequency", 2000.0, 100.0, 10000.0), + pct("filter_res", "Filter Resonance", 20.0, 0.0, 100.0), + pct("mix", "Mix", 100.0, 0.0, 100.0), + db("output", "Output Gain", 0.0, -24.0, 12.0), + ], + ), + audio("invert_a", "Invert", A_SPECIAL, vec![]), + audio("loudness_radar", "Loudness Meter", A_SPECIAL, vec![num("target", "Target Loudness", -23.0, -36.0, -5.0, "LUFS", 1)]), + audio( + "mastering", + "Mastering", + A_SPECIAL, + vec![ + db("eq_low", "Low Shelf Gain", 0.0, -12.0, 12.0), + hz("eq_mid_freq", "Peak Frequency", 1000.0, 100.0, 10000.0), + db("eq_mid", "Peak Gain", 0.0, -12.0, 12.0), + db("eq_high", "High Shelf Gain", 0.0, -12.0, 12.0), + pct("reverb", "Reverb Amount", 0.0, 0.0, 100.0), + pct("exciter", "Exciter Amount", 0.0, 0.0, 100.0), + ch("exciter_mode", "Exciter Mode", &["Retro", "Tape", "Tube"], 1), + pct("widener", "Widener", 100.0, 0.0, 200.0), + pct("loudness", "Loudness Maximizer", 0.0, 0.0, 100.0), + db("output", "Output Gain", 0.0, -24.0, 12.0), + ], + ), + audio( + "panner_ambisonics", + "Panner - Ambisonics", + A_SPECIAL, + vec![num("pan", "Pan", 0.0, -180.0, 180.0, "°", 1), num("tilt", "Tilt", 0.0, -90.0, 90.0, "°", 1), num("roll", "Roll", 0.0, -180.0, 180.0, "°", 1)], + ), + audio("swap_channels", "Swap Channels", A_SPECIAL, vec![]), + audio("vocal_enhancer", "Vocal Enhancer", A_SPECIAL, vec![ch("mode", "Mode", &["Male", "Female", "Music"], 0)]), + // ---- Stereo Imagery ---- + audio( + "stereo_expander", + "Stereo Expander", + A_STEREO, + vec![num("center_pan", "Center Channel Pan", 0.0, -100.0, 100.0, "", 0), pct("expand", "Stereo Expand", 100.0, 0.0, 300.0)], + ), + audio("stereo_width", "Stereo Width", A_STEREO, vec![fs("width", "Width", 100.0, (0.0, 200.0), (0.0, 200.0), "%", 0)]), + // ---- Time and Pitch ---- + audio( + "pitch_shifter", + "Pitch Shifter", + A_TIME, + vec![fs("semitones", "Semi-tones", 0.0, (-12.0, 12.0), (-12.0, 12.0), "", 0), fs("cents", "Cents", 0.0, (-100.0, 100.0), (-100.0, 100.0), "", 0)], + ), + // ---- loose items ---- + audio("balance_a", "Balance", A_ROOT, vec![num("balance", "Balance", 0.0, -100.0, 100.0, "", 1)]), + audio("mute", "Mute", A_ROOT, vec![mute_param()]), + audio("volume_a", "Volume", A_ROOT, vec![b("bypass", "Bypass", false), fs("level", "Level", 0.0, (-96.0, 15.0), (-60.0, 15.0), "dB", 1)]), + ] +} + +fn db_ms_lookahead() -> ParamDef { + fs("lookahead", "Look-Ahead Time", 7.0, (0.0, 30.0), (0.0, 30.0), "ms", 1) +} + +/// Mute is keyframed to silence passages, so it is animatable (unlike most toggles). +fn mute_param() -> ParamDef { + let mut p = b("mute", "Mute", false); + p.animatable = true; + p +} + +/// Ids of the effects Premiere lists in its Audio Effects bin (excluding Audio Units). +pub const PREMIERE_AUDIO_EFFECTS: [&str; 53] = [ + "amplify", + "channel_mixer_a", + "channel_volume_a", + "deesser", + "dynamics_rack", + "dynamics", + "hard_limiter", + "multiband_compressor", + "single_band_compressor", + "tube_compressor", + "analog_delay", + "delay", + "multitap_delay", + "bandpass", + "bass", + "fft_filter", + "graphic_eq", + "graphic_eq_20", + "graphic_eq_30", + "highpass", + "lowpass", + "notch", + "parametric_eq", + "scientific_filter", + "simple_notch", + "simple_eq", + "treble", + "chorus_flanger", + "flanger", + "phaser", + "declicker", + "dehummer", + "denoise", + "dereverb", + "convolution_reverb", + "studio_reverb", + "surround_reverb", + "binauralizer", + "distortion", + "fill_left", + "fill_right", + "guitar_suite", + "invert_a", + "loudness_radar", + "mastering", + "panner_ambisonics", + "swap_channels", + "vocal_enhancer", + "stereo_expander", + "pitch_shifter", + "balance_a", + "mute", + "volume_a", +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::effect::{EffectKind, find_effect}; + + #[test] + fn premiere_audio_set_is_complete_and_foldered() { + let mut seen = std::collections::HashSet::new(); + for id in PREMIERE_AUDIO_EFFECTS { + assert!(seen.insert(id), "duplicate {id}"); + let d = find_effect(id).unwrap_or_else(|| panic!("missing {id}")); + assert_eq!(d.kind, EffectKind::Audio, "{id}"); + assert!(!d.intrinsic, "{id}"); + assert_eq!(d.category.first(), Some(&"Audio Effects"), "{id}"); + } + let folder = |id: &str| find_effect(id).unwrap().category.get(1).copied(); + assert_eq!(folder("multiband_compressor"), Some("Amplitude and Compression")); + assert_eq!(folder("multitap_delay"), Some("Delay and Echo")); + assert_eq!(folder("graphic_eq_30"), Some("Filter and EQ")); + assert_eq!(folder("phaser"), Some("Modulation")); + assert_eq!(folder("declicker"), Some("Noise Reduction/Restoration")); + assert_eq!(folder("surround_reverb"), Some("Reverb")); + assert_eq!(folder("guitar_suite"), Some("Special")); + assert_eq!(folder("stereo_expander"), Some("Stereo Imagery")); + assert_eq!(folder("pitch_shifter"), Some("Time and Pitch")); + assert_eq!(folder("mute"), None, "loose item"); + } + + #[test] + fn parametric_eq_keeps_the_three_band_ids() { + let d = find_effect("parametric_eq").unwrap(); + for id in ["low_freq", "low_gain", "mid_freq", "mid_gain", "mid_q", "high_freq", "high_gain"] { + assert!(d.param(id).is_some(), "{id}"); + } + } +} diff --git a/crates/render/src/audio_fx.rs b/crates/render/src/audio_fx.rs index 79174fbf8..dd44a1ad6 100644 --- a/crates/render/src/audio_fx.rs +++ b/crates/render/src/audio_fx.rs @@ -92,6 +92,8 @@ pub(crate) fn mapping(id: &str) -> Option { apply: |d, e, t| { d.set_param("frequency", e.param("freq").and_then(|p| p.value_at(t).as_f64()).unwrap_or(1.0) as f32); d.set_param("amount", (1.0 - 10f32.powf(f(e, "gain", t).min(0.0) / 20.0)) * 100.0); + set_if_changed(d, "harmonics", f(e, "harmonics", t)); + set_if_changed(d, "q", f(e, "q", t)); }, }, "deesser" => Mapping { @@ -137,10 +139,161 @@ pub(crate) fn mapping(id: &str) -> Option { preroll: |_, _| 0.1, apply: |d, e, t| ignore(d.set_param("semitones", (f(e, "semitones", t) + f(e, "cents", t) / 100.0).clamp(-12.0, 12.0))), }, - _ => return None, + "single_band_compressor" => Mapping { + dsp: "compressor", + preroll: |e, t| (f(e, "release", t) as f64 / 1000.0 * 5.0).clamp(0.05, 3.0), + apply: |d, e, t| { + set_if_changed(d, "knee", 0.0); + for id in ["threshold", "ratio", "attack", "release"] { + set_if_changed(d, id, f(e, id, t)); + } + set_if_changed(d, "makeup", f(e, "output", t)); + }, + }, + "bass" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| shelf(d, 1.0, 200.0, f(e, "boost", t)) }, + "treble" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.05, apply: |d, e, t| shelf(d, 2.0, 4000.0, f(e, "boost", t)) }, + "simple_notch" => Mapping { dsp: "parametric_eq", preroll: |_, _| 0.1, apply: |d, e, t| band(d, 5.0, f(e, "center", t), f(e, "q", t)) }, + "simple_eq" => Mapping { + dsp: "parametric_eq", + preroll: |_, _| 0.05, + apply: |d, e, t| { + band(d, 0.0, f(e, "center", t), f(e, "q", t)); + set_if_changed(d, "b1.gain", f(e, "boost", t)); + }, + }, + "volume_a" => Mapping { + dsp: "amplify", + preroll: |_, _| 0.0, + apply: |d, e, t| { + let bypass = e.f64_at("bypass", t) >= 0.5; + set_if_changed(d, "gain", if bypass { 0.0 } else { f(e, "level", t).clamp(-96.0, 24.0) }); + }, + }, + "analog_delay" => Mapping { + dsp: "analog_delay", + preroll: |e, t| { + let time = f(e, "delay", t).max(1.0) as f64 / 1000.0; + let fb = (f(e, "feedback", t) as f64 / 100.0).clamp(0.0, 0.95); + let repeats = if fb > 0.001 { (-4.0 / fb.log10()).clamp(1.0, 60.0) } else { 1.0 }; + (time * repeats).clamp(0.12, 8.0) + }, + apply: direct, + }, + "multitap_delay" => Mapping { + dsp: "multitap_delay", + preroll: |e, t| { + let mut worst = 0.0f64; + for k in 1..=4 { + let time = e.f64_at(["delay1", "delay2", "delay3", "delay4"][k - 1], t).max(1.0) / 1000.0; + let fb = (e.f64_at(["feedback1", "feedback2", "feedback3", "feedback4"][k - 1], t) / 100.0).clamp(0.0, 0.95); + let repeats = if fb > 0.001 { (-4.0 / fb.log10()).clamp(1.0, 60.0) } else { 1.0 }; + worst = worst.max(time * repeats); + } + worst.clamp(0.12, 8.0) + }, + apply: direct, + }, + "convolution_reverb" => Mapping { + dsp: "convolution_reverb", + preroll: |e, t| { + let rt = [0.45, 0.8, 1.9, 3.2, 1.6, 0.3, 0.2][(e.f64_at("impulse", t) as usize).min(6)]; + (rt * 1.1 * e.f64_at("room_size", t) / 100.0 + e.f64_at("predelay", t) / 1000.0).clamp(0.12, 4.0) + }, + apply: direct, + }, + "surround_reverb" => { + Mapping { dsp: "surround_reverb", preroll: |e, t| (e.f64_at("decay", t) + e.f64_at("predelay", t) / 1000.0).clamp(0.12, 8.0), apply: direct } + } + _ => { + let &(_, dsp, _) = DIRECT.iter().find(|(p, _, _)| *p == id)?; + Mapping { dsp, preroll: |e, _| direct_preroll(&e.effect), apply: direct } + } }) } +/// Project effects whose parameters map one-to-one (same ids, same units) onto a DSP effect: +/// (project id, DSP id, pre-roll seconds for a fresh chain). +pub const DIRECT: &[(&str, &str, f64)] = &[ + ("channel_mixer_a", "channel_mixer", 0.0), + ("channel_volume_a", "channel_volume", 0.0), + ("dynamics_rack", "dynamics_rack", 1.0), + ("multiband_compressor", "multiband_compressor", 1.0), + ("tube_compressor", "tube_compressor", 1.5), + ("fft_filter", "fft_filter", 0.1), + ("graphic_eq", "graphic_eq_10", 0.1), + ("graphic_eq_20", "graphic_eq_20", 0.1), + ("graphic_eq_30", "graphic_eq_30", 0.1), + ("notch", "notch_filter", 0.1), + ("parametric_eq", "parametric_eq_full", 0.1), + ("scientific_filter", "scientific_filter", 0.2), + ("chorus_flanger", "chorus_flanger", 0.1), + ("flanger", "flanger", 0.2), + ("phaser", "phaser", 0.1), + ("declicker", "click_remover", 0.05), + ("binauralizer", "binauralizer", 0.01), + ("distortion", "distortion", 0.05), + ("fill_left", "fill_left", 0.0), + ("fill_right", "fill_right", 0.0), + ("swap_channels", "swap_channels", 0.0), + ("guitar_suite", "guitar_suite", 0.2), + ("loudness_radar", "loudness_meter", 0.0), + ("mastering", "mastering", 2.0), + ("panner_ambisonics", "ambisonics_panner", 0.0), + ("vocal_enhancer", "vocal_enhancer", 0.3), + ("stereo_expander", "stereo_expander", 0.0), + ("balance_a", "balance", 0.0), + ("mute", "mute", 0.0), + ("analog_delay", "analog_delay", 0.0), + ("multitap_delay", "multitap_delay", 0.0), + ("convolution_reverb", "convolution_reverb", 0.0), + ("surround_reverb", "surround_reverb", 0.0), +]; + +/// Pre-roll of a direct mapping: its own tail, and at least long enough for the DSP's +/// parameter smoothing (≤ 100 ms) to settle from the defaults to the clip's settings. +fn direct_preroll(id: &str) -> f64 { + DIRECT.iter().find(|(p, _, _)| *p == id).map_or(0.0, |d| d.2).max(0.12) +} + +/// Set a DSP parameter only when it changed (keeps parameter-dependent rebuilds — impulse +/// responses, filter designs — off the per-block path). +fn set_if_changed(d: &mut dyn AudioEffect, id: &str, v: f32) { + if d.param(id) != Some(v) { + d.set_param(id, v); + } +} + +/// Copy every scalar project parameter (Float / Choice / Bool) to the DSP parameter of the +/// same id. +fn direct(d: &mut dyn AudioEffect, e: &EffectInstance, t: Tick) { + let Some(def) = e.def() else { return }; + for p in &def.params { + let v = e.params.get(p.id).map_or_else(|| p.default.as_f64().unwrap_or(0.0), |q| q.scalar_at(t)); + // sanitise like the DSP would, so unchanged values compare equal + let v = d.params().iter().find(|s| s.id == p.id).map_or(v as f32, |s| s.sanitize(v as f32)); + set_if_changed(d, p.id, v); + } +} + +/// A DSP instance configured like the project effect `e` at media time `t` (for the graphical +/// effect editors: [`AudioEffect::response_db`] / [`AudioEffect::transfer_db`]). +pub fn configured(e: &EffectInstance, t: Tick, sample_rate: u32) -> Option> { + let m = mapping(&e.effect)?; + let mut d = filmcraft_audio_dsp::create_effect(m.dsp, sample_rate as f32, 2)?; + (m.apply)(d.as_mut(), e, t); + Some(d) +} + +/// Processing latency (samples) of a project audio effect at `sample_rate`. +pub fn latency(effect_id: &str, sample_rate: u32) -> usize { + mapping(effect_id).and_then(|m| filmcraft_audio_dsp::create_effect(m.dsp, sample_rate as f32, 2)).map_or(0, |d| d.latency()) +} + +fn shelf(d: &mut dyn AudioEffect, kind: f32, freq: f32, gain: f32) { + band(d, kind, freq, std::f32::consts::FRAC_1_SQRT_2); + set_if_changed(d, "b1.gain", gain); +} + /// Whether a project audio effect has a DSP implementation (clip effects and mixer inserts). pub fn supported(effect_id: &str) -> bool { mapping(effect_id).is_some() @@ -257,3 +410,67 @@ pub fn process(item: &TrackItem, a0: i64, n: usize, sr: u32, read: &dyn Fn(i64, } out } + +#[cfg(test)] +mod tests { + use super::*; + use filmcraft_audio_dsp::Unit; + use filmcraft_project::{EffectKind, ParamKind, effect_defs}; + + #[test] + fn every_audio_effect_has_dsp() { + for d in effect_defs().iter().filter(|d| d.kind == EffectKind::Audio && !d.intrinsic) { + let m = mapping(d.id).unwrap_or_else(|| panic!("{} has no DSP mapping", d.id)); + assert!(filmcraft_audio_dsp::effect_info(m.dsp).is_some(), "{} → unknown DSP {}", d.id, m.dsp); + let inst = d.instance(); + let mut fx = configured(&inst, Tick::ZERO, 48000).unwrap(); + let mut l: Vec = (0..4800).map(|i| ((i * 7919 % 1000) as f32 / 1000.0 - 0.5) * 0.5).collect(); + let mut r = l.clone(); + fx.process(&mut [&mut l, &mut r]); + assert!(l.iter().chain(&r).all(|v| v.is_finite()), "{}", d.id); + } + assert_eq!(filmcraft_project::effect::PREMIERE_AUDIO_EFFECTS.len(), 53); + } + + #[test] + fn direct_mappings_match_dsp_parameters() { + for &(pid, dsp, _) in DIRECT { + let def = filmcraft_project::find_effect(pid).unwrap_or_else(|| panic!("{pid}")); + let info = filmcraft_audio_dsp::effect_info(dsp).unwrap_or_else(|| panic!("{dsp}")); + for p in &def.params { + let s = info.params.iter().find(|s| s.id == p.id).unwrap_or_else(|| panic!("{pid}.{} missing in {dsp}", p.id)); + match &p.kind { + ParamKind::Float { min, max, .. } => { + assert!(*min >= s.min as f64 - 1e-3 && *max <= s.max as f64 + 1e-3, "{pid}.{}: range {min}..{max} vs {}..{}", p.id, s.min, s.max); + let dv = p.default.as_f64().unwrap(); + assert!((dv - s.default as f64).abs() < 0.02, "{pid}.{}: default {dv} vs {}", p.id, s.default); + } + ParamKind::Choice(opts) => { + assert_eq!(s.unit, Unit::Choice, "{pid}.{}", p.id); + assert_eq!(opts.len(), s.choices.len(), "{pid}.{}", p.id); + assert_eq!(p.default.as_f64().unwrap(), s.default as f64, "{pid}.{}", p.id); + } + ParamKind::Bool => { + assert_eq!(s.unit, Unit::Toggle, "{pid}.{}", p.id); + assert_eq!(p.default.as_f64().unwrap(), s.default as f64, "{pid}.{}", p.id); + } + k => panic!("{pid}.{}: unsupported kind {k:?}", p.id), + } + } + } + } + + #[test] + fn editor_curves_follow_project_parameters() { + let def = filmcraft_project::find_effect("graphic_eq").unwrap(); + let mut inst = def.instance(); + inst.param_mut("b6").unwrap().value = filmcraft_project::ParamValue::Float(9.0); + let fx = configured(&inst, Tick::ZERO, 48000).unwrap(); + assert!((fx.response_db(1000.0).unwrap() - 9.0).abs() < 1.0); + let def = filmcraft_project::find_effect("dynamics_rack").unwrap(); + let fx = configured(&def.instance(), Tick::ZERO, 48000).unwrap(); + assert!((fx.transfer_db(0, 0.0).unwrap() + 10.0).abs() < 1e-3, "−20 dB threshold, 2:1"); + assert_eq!(latency("fft_filter", 48000), 2048); + assert_eq!(latency("amplify", 48000), 0); + } +}