481 lines
22 KiB
Rust
481 lines
22 KiB
Rust
//! Essential Sound commands (`essentialSound.*`).
|
|
//!
|
|
//! Clips get an audio type (Dialogue / Music / SFX / Ambience) and per-type settings
|
|
//! ([`filmcraft_project::essential`]). Every change goes through one undoable edit that updates the
|
|
//! settings and reconciles the clip's essential effects, clip gain, Volume and Panner
|
|
//! ([`filmcraft_project::essential::apply`]), so what the panel does is ordinary clip data that the
|
|
//! mixer, playback and export render without special cases.
|
|
//!
|
|
//! | command | does |
|
|
//! |---|---|
|
|
//! | `essentialSound.inspect` | types, settings and essential effects of the selected audio clips; presets; targets |
|
|
//! | `essentialSound.setType` / `clearType` | assign / clear the audio type (clearing removes its effects and gain) |
|
|
//! | `essentialSound.set` | one setting by dotted key (`repair.noise.amount`), or several (`values`) |
|
|
//! | `essentialSound.applyPreset` / `savePreset` / `deletePreset` | built-in and user presets |
|
|
//! | `essentialSound.autoMatch` | measure integrated loudness (BS.1770, mono clips as one channel) and set the match gain to the target |
|
|
//! | `essentialSound.generateDucking` | Volume keyframes that duck Music / Ambience under the trigger types |
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
use filmcraft_audio_dsp::LoudnessMeter;
|
|
use filmcraft_project::essential::{self as es, AudioType, EssentialSound, Preset, Section};
|
|
use filmcraft_project::{ClipId, ParamValue, Project, TrackItem};
|
|
use filmcraft_render::SourceProvider;
|
|
use filmcraft_time::Tick;
|
|
|
|
use crate::autosave::UserSoundPreset;
|
|
use crate::commands::{CommandSpec, always, bad, bool_p, f64_p, has_seq, str_p, with_links};
|
|
use crate::{EngineError, Result, Session};
|
|
|
|
type Run = fn(&mut Session, &Value) -> Result<Value>;
|
|
type Enabled = fn(&Session) -> std::result::Result<(), String>;
|
|
|
|
/// The audio clips a command acts on: `clips` (or `clip`), else the selection, with linked partners.
|
|
pub fn targets(s: &Session, p: &Value) -> Vec<ClipId> {
|
|
let sel: Vec<ClipId> = match p.get("clips").and_then(Value::as_array) {
|
|
Some(a) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(),
|
|
None => match p.get("clip").and_then(Value::as_u64) {
|
|
Some(c) => vec![ClipId(c)],
|
|
None => s.state.selection.clone(),
|
|
},
|
|
};
|
|
let Some(seq) = s.active_sequence() else { return Vec::new() };
|
|
let all = with_links(s, &sel);
|
|
seq.audio_tracks.iter().flat_map(|t| t.items.iter()).filter(|i| all.contains(&i.id)).map(|i| i.id).collect()
|
|
}
|
|
|
|
fn mt_of(it: &TrackItem, t: Tick) -> Tick {
|
|
it.source_time_at(t.clamp(it.start, (it.end() - Tick(1)).max(it.start)))
|
|
}
|
|
|
|
/// Change the settings of `clips` with `f` and reconcile their effects, as one undo step (or merged
|
|
/// into the previous step with the same `merge` key while a control is dragged).
|
|
fn update(
|
|
s: &mut Session,
|
|
cmd: &str,
|
|
label: &str,
|
|
merge: Option<String>,
|
|
clips: &[ClipId],
|
|
f: impl Fn(&TrackItem, &mut Option<EssentialSound>) -> std::result::Result<(), String>,
|
|
) -> Result<usize> {
|
|
if clips.is_empty() {
|
|
return Err(bad(cmd, "select one or more audio clips"));
|
|
}
|
|
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
let ph = s.playhead();
|
|
let body = |p: &mut Project, _: &mut crate::EditorState| -> Result<usize> {
|
|
let seq = p.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
|
let mut n = 0;
|
|
for it in seq.audio_tracks.iter_mut().flat_map(|t| t.items.iter_mut()).filter(|i| clips.contains(&i.id)) {
|
|
let old = it.essential.clone();
|
|
let mut next = old.clone();
|
|
f(it, &mut next).map_err(|e| bad(cmd, e))?;
|
|
if next != old {
|
|
it.essential = next;
|
|
let mt = mt_of(it, ph);
|
|
es::apply(it, old.as_ref(), mt);
|
|
n += 1;
|
|
}
|
|
}
|
|
Ok(n)
|
|
};
|
|
match merge {
|
|
Some(k) => s.edit_merged(label, &k, body),
|
|
None => s.edit(label, body),
|
|
}
|
|
}
|
|
|
|
/// Built-in presets followed by the user's.
|
|
pub fn presets(s: &Session) -> Vec<Preset> {
|
|
let mut v = es::builtin_presets();
|
|
v.extend(s.prefs.essential_sound.user_presets.iter().map(|u| Preset {
|
|
name: u.name.clone(),
|
|
kind: u.settings.kind,
|
|
settings: u.settings.clone(),
|
|
builtin: false,
|
|
}));
|
|
v
|
|
}
|
|
|
|
fn type_p(p: &Value, cmd: &str) -> Result<Option<AudioType>> {
|
|
match str_p(p, "type") {
|
|
None => Ok(None),
|
|
Some(t) => AudioType::parse(t).map(Some).ok_or_else(|| bad(cmd, format!("unknown audio type `{t}` (dialogue, music, sfx, ambience)"))),
|
|
}
|
|
}
|
|
|
|
fn clip_json(s: &Session, it: &TrackItem, track: &str) -> Value {
|
|
let t = s.playhead();
|
|
let level = it.effect("volume").map(|e| e.f64_at("level", mt_of(it, t))).unwrap_or(0.0);
|
|
json!({
|
|
"clip": it.id.0,
|
|
"name": it.name,
|
|
"track": track,
|
|
"type": it.essential.as_ref().map(|e| e.kind.label()),
|
|
"settings": it.essential.as_ref().map(EssentialSound::to_value),
|
|
"gainDb": it.gain_db,
|
|
"volumeDb": level,
|
|
"volumeAnimated": it.effect("volume").and_then(|e| e.param("level")).is_some_and(|p| p.is_animated()),
|
|
"effects": it.effects.iter().enumerate().filter(|(_, e)| e.essential).map(|(i, e)| json!({"index": i, "effect": e.effect, "enabled": e.enabled})).collect::<Vec<_>>(),
|
|
})
|
|
}
|
|
|
|
fn inspect(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let clips = targets(s, p);
|
|
let mut out = Vec::new();
|
|
let mut kinds = Vec::new();
|
|
if let Some(seq) = s.active_sequence() {
|
|
for (k, t) in seq.audio_tracks.iter().enumerate() {
|
|
for it in t.items.iter().filter(|i| clips.contains(&i.id)) {
|
|
out.push(clip_json(s, it, &format!("A{}", k + 1)));
|
|
kinds.push(it.essential.as_ref().map(|e| e.kind));
|
|
}
|
|
}
|
|
}
|
|
kinds.dedup();
|
|
let kind = match kinds.as_slice() {
|
|
[Some(k)] => json!(k.label()),
|
|
[None] | [] => Value::Null,
|
|
_ => json!("mixed"),
|
|
};
|
|
let ps = presets(s);
|
|
let by_type = |t: AudioType| ps.iter().filter(|p| p.kind == t).map(|p| json!({"name": p.name, "builtin": p.builtin})).collect::<Vec<_>>();
|
|
Ok(json!({
|
|
"clips": out,
|
|
"type": kind,
|
|
"presets": AudioType::ALL.iter().map(|t| (t.label().to_string(), json!(by_type(*t)))).collect::<serde_json::Map<_, _>>(),
|
|
"sections": AudioType::ALL.iter().map(|t| (t.label().to_string(), json!(t.sections().iter().map(|s| s.label()).collect::<Vec<_>>()))).collect::<serde_json::Map<_, _>>(),
|
|
"targets": AudioType::ALL.iter().map(|t| (t.label().to_string(), json!(s.prefs.audio.loudness_target(*t)))).collect::<serde_json::Map<_, _>>(),
|
|
"eqPresets": es::EQ_PRESETS.iter().map(|p| p.name).collect::<Vec<_>>(),
|
|
"reverbPresets": es::REVERB_PRESETS.iter().map(|p| p.name).collect::<Vec<_>>(),
|
|
}))
|
|
}
|
|
|
|
fn set_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let t = type_p(p, "essentialSound.setType")?.ok_or_else(|| bad("essentialSound.setType", "need `type`"))?;
|
|
let clips = targets(s, p);
|
|
let n = update(s, "essentialSound.setType", &format!("Set Audio Type to {}", t.label()), None, &clips, |_, e| {
|
|
if e.as_ref().is_none_or(|x| x.kind != t) {
|
|
*e = Some(EssentialSound::new(t));
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"clips": n, "type": t.label()}))
|
|
}
|
|
|
|
fn clear_type(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let clips = targets(s, p);
|
|
let n = update(s, "essentialSound.clearType", "Clear Audio Type", None, &clips, |_, e| {
|
|
*e = None;
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"clips": n}))
|
|
}
|
|
|
|
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let mut kv: Vec<(String, Value)> = Vec::new();
|
|
if let Some(k) = str_p(p, "key") {
|
|
kv.push((k.to_string(), p.get("value").cloned().ok_or_else(|| bad("essentialSound.set", "need `value`"))?));
|
|
}
|
|
if let Some(m) = p.get("values").and_then(Value::as_object) {
|
|
kv.extend(m.iter().map(|(k, v)| (k.clone(), v.clone())));
|
|
}
|
|
if kv.is_empty() {
|
|
return Err(bad("essentialSound.set", "need `key` + `value` or `values`"));
|
|
}
|
|
let clips = targets(s, p);
|
|
if bool_p(p, "begin").unwrap_or(false) {
|
|
s.history.merge_key = None;
|
|
}
|
|
let keys: Vec<&str> = kv.iter().map(|(k, _)| k.as_str()).collect();
|
|
let merge = format!("essentialSound:{clips:?}:{}", keys.join(","));
|
|
let n = update(s, "essentialSound.set", "Essential Sound", Some(merge), &clips, |it, e| {
|
|
let st = e.as_mut().ok_or_else(|| format!("clip `{}` has no audio type; set one first", it.name))?;
|
|
for (k, v) in &kv {
|
|
st.set(k, v)?;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"clips": n}))
|
|
}
|
|
|
|
fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "essentialSound.applyPreset";
|
|
let name = str_p(p, "preset").ok_or_else(|| bad(cmd, "need `preset`"))?.to_string();
|
|
let forced = type_p(p, cmd)?;
|
|
let ps = presets(s);
|
|
let clips = targets(s, p);
|
|
let n = update(s, cmd, &format!("Apply Sound Preset {name}"), None, &clips, |it, e| {
|
|
let kind = forced.or(e.as_ref().map(|x| x.kind)).ok_or_else(|| format!("clip `{}` has no audio type; pass `type`", it.name))?;
|
|
let pr =
|
|
ps.iter().rev().find(|x| x.kind == kind && x.name.eq_ignore_ascii_case(&name)).ok_or_else(|| format!("no {} preset `{name}`", kind.label()))?;
|
|
*e = Some(es::with_preset(e.as_ref(), pr));
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"clips": n, "preset": name}))
|
|
}
|
|
|
|
fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "essentialSound.savePreset";
|
|
let name = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(cmd, "need `name`"))?.to_string();
|
|
let clips = targets(s, p);
|
|
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
|
let mut st =
|
|
clips.iter().find_map(|c| seq.find_item(*c).and_then(|(_, i)| i.essential.clone())).ok_or_else(|| bad(cmd, "select a clip with an audio type"))?;
|
|
if es::builtin_presets().iter().any(|b| b.kind == st.kind && b.name.eq_ignore_ascii_case(&name)) {
|
|
return Err(bad(cmd, format!("`{name}` is a built-in preset name")));
|
|
}
|
|
st.preset = name.clone();
|
|
st.loudness = es::Loudness { enabled: st.loudness.enabled, ..Default::default() };
|
|
st.volume = Default::default();
|
|
st.mute = false;
|
|
let kind = st.kind;
|
|
let mut prefs = s.prefs.clone();
|
|
prefs.essential_sound.user_presets.retain(|u| !(u.settings.kind == kind && u.name.eq_ignore_ascii_case(&name)));
|
|
prefs.essential_sound.user_presets.push(UserSoundPreset { name: name.clone(), settings: st });
|
|
s.set_prefs(prefs).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
|
|
// the clips now show the saved preset
|
|
let _ = update(s, cmd, "Save Sound Preset", None, &clips, |_, e| {
|
|
if let Some(x) = e.as_mut().filter(|x| x.kind == kind) {
|
|
x.preset = name.clone();
|
|
}
|
|
Ok(())
|
|
});
|
|
Ok(json!({"name": name, "type": kind.label()}))
|
|
}
|
|
|
|
fn delete_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "essentialSound.deletePreset";
|
|
let name = str_p(p, "name").or_else(|| str_p(p, "preset")).ok_or_else(|| bad(cmd, "need `name`"))?.to_string();
|
|
let kind = type_p(p, cmd)?;
|
|
let mut prefs = s.prefs.clone();
|
|
let before = prefs.essential_sound.user_presets.len();
|
|
prefs.essential_sound.user_presets.retain(|u| !(u.name.eq_ignore_ascii_case(&name) && kind.is_none_or(|k| k == u.settings.kind)));
|
|
if prefs.essential_sound.user_presets.len() == before {
|
|
return Err(bad(cmd, format!("no user preset `{name}` (built-in presets cannot be deleted)")));
|
|
}
|
|
s.set_prefs(prefs).map_err(|e| EngineError::Other(format!("saving preferences: {e}")))?;
|
|
Ok(json!({"deleted": name}))
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------- loudness
|
|
|
|
/// Integrated loudness (LUFS) of a clip's own signal (clip gain + clip effects) over its whole
|
|
/// timeline range, or `None` when it is silent / has no audio.
|
|
///
|
|
/// The clip is measured with the channels it has: a stereo (or wider) source as the two channels
|
|
/// it plays, a mono source or a mono pick as one channel (its two identical sides folded to one),
|
|
/// the way BS.1770 and ffmpeg's `ebur128` measure a mono programme. Metering the dual-mono pair the
|
|
/// clip plays would read 3 LU louder than the file and match it 3 dB too quietly (#296).
|
|
pub fn clip_loudness(item: &TrackItem, sr: u32, sources: &dyn SourceProvider) -> Option<f64> {
|
|
let a0 = item.start.to_units_floor(sr as i64);
|
|
let a1 = item.end().to_units_floor(sr as i64);
|
|
let mono = filmcraft_render::audio::clip_is_mono(item, sources)?;
|
|
let mut meter = LoudnessMeter::new(sr as f64, if mono { 1 } else { 2 });
|
|
let mut pos = a0;
|
|
while pos < a1 {
|
|
let n = (a1 - pos).min(sr as i64) as usize;
|
|
let [l, r] = filmcraft_render::audio::clip_signal(item, pos, n, sr, sources)?;
|
|
if mono {
|
|
let m: Vec<f32> = l.iter().zip(&r).map(|(a, b)| 0.5 * (a + b)).collect();
|
|
meter.process(&[&m]);
|
|
} else {
|
|
meter.process(&[&l, &r]);
|
|
}
|
|
pos += n as i64;
|
|
}
|
|
Some(meter.integrated()).filter(|v| v.is_finite())
|
|
}
|
|
|
|
fn auto_match(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "essentialSound.autoMatch";
|
|
let clips = targets(s, p);
|
|
let target_p = f64_p(p, "target");
|
|
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
|
let sr = seq.settings.sample_rate.max(1);
|
|
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
|
// measure first (read-only), then write every match in one undo step
|
|
let mut matches: Vec<(ClipId, f64, f64, f64)> = Vec::new(); // clip, measured (without match gain), target, new match gain
|
|
let mut report = Vec::new();
|
|
for it in seq.audio_tracks.iter().flat_map(|t| t.items.iter()).filter(|i| clips.contains(&i.id)) {
|
|
let Some(st) = it.essential.as_ref() else {
|
|
report.push(json!({"clip": it.id.0, "skipped": "no audio type"}));
|
|
continue;
|
|
};
|
|
let target = target_p.unwrap_or_else(|| s.prefs.audio.loudness_target(st.kind)).clamp(-60.0, 0.0);
|
|
let Some(l) = clip_loudness(it, sr, &provider) else {
|
|
report.push(json!({"clip": it.id.0, "skipped": "silent"}));
|
|
continue;
|
|
};
|
|
// the clip gain (including any earlier match gain) applies linearly after the effects
|
|
let current = st.loudness_gain_db();
|
|
let measured = l - current;
|
|
let gain = filmcraft_audio_dsp::normalize_gain_db(measured, target);
|
|
matches.push((it.id, measured, target, gain));
|
|
report.push(json!({"clip": it.id.0, "measuredLufs": measured, "targetLufs": target, "gainDb": gain}));
|
|
}
|
|
if matches.is_empty() {
|
|
return Err(bad(cmd, "no clips with an audio type and audible audio in the selection"));
|
|
}
|
|
let ids: Vec<ClipId> = matches.iter().map(|m| m.0).collect();
|
|
update(s, cmd, "Auto-Match Loudness", None, &ids, |it, e| {
|
|
if let (Some(st), Some(m)) = (e.as_mut(), matches.iter().find(|m| m.0 == it.id)) {
|
|
st.loudness.enabled = true;
|
|
st.loudness.measured_lufs = Some(m.1);
|
|
st.loudness.target_lufs = Some(m.2);
|
|
st.loudness.gain_db = m.3;
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"clips": report}))
|
|
}
|
|
|
|
// ------------------------------------------------------------------------------------- ducking
|
|
|
|
/// Analysis hop and smoothing window of the ducking detector.
|
|
const DUCK_HOP_S: f64 = 0.01;
|
|
const DUCK_WINDOW_HOPS: usize = 5;
|
|
/// Ignore trigger blips shorter than this; bridge pauses between words shorter than this.
|
|
const DUCK_MIN_ON_S: f32 = 0.1;
|
|
const DUCK_BRIDGE_S: f32 = 0.25;
|
|
|
|
/// Level envelope (dBFS, 10 ms hops, 50 ms centred window) of the summed trigger clips over
|
|
/// timeline samples `[a0, a1)`.
|
|
fn trigger_envelope(triggers: &[&TrackItem], a0: i64, a1: i64, sr: u32, sources: &dyn SourceProvider) -> Vec<f32> {
|
|
let hop = ((sr as f64 * DUCK_HOP_S).round() as usize).max(1);
|
|
let hops = ((a1 - a0).max(0) as usize).div_ceil(hop);
|
|
let mut power = vec![0.0f64; hops];
|
|
let chunk = hop * 100;
|
|
let mut pos = a0;
|
|
while pos < a1 {
|
|
let n = ((a1 - pos) as usize).min(chunk);
|
|
let mut sum = [vec![0.0f32; n], vec![0.0f32; n]];
|
|
for t in triggers {
|
|
if let Some(b) = filmcraft_render::audio::clip_signal(t, pos, n, sr, sources) {
|
|
for c in 0..2 {
|
|
for (d, x) in sum[c].iter_mut().zip(&b[c]) {
|
|
*d += x;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let base = ((pos - a0) as usize) / hop;
|
|
for (k, i) in (0..n).step_by(hop).enumerate() {
|
|
let e = (i + hop).min(n);
|
|
let pw: f64 = (i..e).map(|j| 0.5 * ((sum[0][j] as f64).powi(2) + (sum[1][j] as f64).powi(2))).sum::<f64>() / (e - i) as f64;
|
|
if let Some(p) = power.get_mut(base + k) {
|
|
*p = pw;
|
|
}
|
|
}
|
|
pos += n as i64;
|
|
}
|
|
let h = DUCK_WINDOW_HOPS / 2;
|
|
(0..hops)
|
|
.map(|k| {
|
|
let (lo, hi) = (k.saturating_sub(h), (k + h + 1).min(hops));
|
|
let m = power[lo..hi].iter().sum::<f64>() / (hi - lo) as f64;
|
|
(10.0 * m.max(1e-12).log10()).max(-120.0) as f32
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn generate_ducking(s: &mut Session, p: &Value) -> Result<Value> {
|
|
let cmd = "essentialSound.generateDucking";
|
|
let clips = targets(s, p);
|
|
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
|
|
let sr = seq.settings.sample_rate.max(1);
|
|
let provider = s.media.provider(s.project.clone(), s.services.clone());
|
|
let all: Vec<&TrackItem> = seq.audio_tracks.iter().flat_map(|t| t.items.iter()).collect();
|
|
let mut plans: Vec<(ClipId, Vec<(Tick, f64)>, f64)> = Vec::new();
|
|
let mut report = Vec::new();
|
|
for it in all.iter().copied().filter(|i| clips.contains(&i.id)) {
|
|
let Some(st) = it.essential.as_ref().filter(|e| e.kind.has(Section::Ducking)) else { continue };
|
|
let d = &st.ducking;
|
|
let triggers: Vec<&TrackItem> = all
|
|
.iter()
|
|
.copied()
|
|
.filter(|o| o.id != it.id && o.enabled && o.range().overlaps(&it.range()))
|
|
.filter(|o| match o.essential.as_ref() {
|
|
Some(e) => !e.mute && d.against.contains(&e.kind),
|
|
None => d.against_untyped,
|
|
})
|
|
.collect();
|
|
let (a0, a1) = (it.start.to_units_floor(sr as i64), it.end().to_units_floor(sr as i64));
|
|
let env = trigger_envelope(&triggers, a0, a1, sr, &provider);
|
|
let thr = filmcraft_audio_dsp::ducking::sensitivity_threshold_db(d.sensitivity as f32);
|
|
let regions = filmcraft_audio_dsp::ducking::activity(&env, DUCK_HOP_S as f32, thr, DUCK_MIN_ON_S, DUCK_BRIDGE_S);
|
|
let start_s = a0 as f64 / sr as f64;
|
|
let end_s = a1 as f64 / sr as f64;
|
|
let abs: Vec<(f64, f64)> = regions.iter().map(|(x, y)| (start_s + x, start_s + y)).collect();
|
|
let lvl = it.effect("volume").and_then(|e| e.param("level"));
|
|
let base = match lvl {
|
|
Some(p) if p.is_animated() => p.keyframes.iter().filter_map(|k| k.value.as_f64()).fold(f64::NEG_INFINITY, f64::max),
|
|
Some(p) => p.value.as_f64().unwrap_or(0.0),
|
|
None => 0.0,
|
|
};
|
|
let kfs = filmcraft_audio_dsp::ducking::duck_keyframes(&abs, start_s, end_s, base, d.reduce_db, d.fade_s);
|
|
let ticks: Vec<(Tick, f64)> = kfs.iter().map(|(t, v)| (Tick::from_seconds_f64(*t).clamp(it.start, it.end()), *v)).collect();
|
|
report.push(json!({
|
|
"clip": it.id.0,
|
|
"triggers": triggers.iter().map(|t| t.id.0).collect::<Vec<_>>(),
|
|
"regions": abs.iter().map(|(a, b)| json!([a, b])).collect::<Vec<_>>(),
|
|
"keyframes": ticks.iter().map(|(t, v)| json!({"time": t.0, "seconds": t.seconds(), "levelDb": v})).collect::<Vec<_>>(),
|
|
}));
|
|
plans.push((it.id, ticks, base));
|
|
}
|
|
if plans.is_empty() {
|
|
return Err(bad(cmd, "select Music or Ambience clips (ducking applies to those types)"));
|
|
}
|
|
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
|
|
s.edit("Generate Ducking Keyframes", |pr, _| {
|
|
let seq = pr.sequence_mut(seq_id).ok_or(EngineError::NoSequence)?;
|
|
for (c, ticks, base) in &plans {
|
|
let Some((_, it)) = seq.find_item_mut(*c) else { continue };
|
|
if let Some(e) = it.essential.as_mut() {
|
|
e.ducking.enabled = true;
|
|
}
|
|
let at: Vec<(Tick, f64)> = ticks.iter().map(|(t, v)| (mt_of(it, *t), *v)).collect();
|
|
let Some(par) = it.effect_mut("volume").and_then(|e| e.param_mut("level")) else { continue };
|
|
par.keyframes.clear();
|
|
par.value = ParamValue::Float(*base);
|
|
for (mt, v) in at {
|
|
par.put_keyframe(mt, ParamValue::Float(v));
|
|
}
|
|
}
|
|
Ok(())
|
|
})?;
|
|
Ok(json!({"clips": report}))
|
|
}
|
|
|
|
fn spec(id: &'static str, label: &'static str, params: &'static str, enabled: Enabled, run: Run, journal: bool) -> CommandSpec {
|
|
CommandSpec { id, label, menu: &[], shortcut: None, params, enabled, run, journal }
|
|
}
|
|
|
|
pub fn commands() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec("essentialSound.inspect", "Inspect Essential Sound", r#"{"clips":[id]?}"#, always, inspect, false),
|
|
spec("essentialSound.setType", "Set Audio Type", r#"{"clips":[id]?,"type":"dialogue|music|sfx|ambience"}"#, has_seq, set_type, true),
|
|
spec("essentialSound.clearType", "Clear Audio Type", r#"{"clips":[id]?}"#, has_seq, clear_type, true),
|
|
spec(
|
|
"essentialSound.set",
|
|
"Essential Sound Setting",
|
|
r#"{"clips":[id]?,"key":"repair.noise.on|repair.noise.amount|repair.humHz|clarity.eqPreset|creative.reverbPreset|ducking.reduceDb|volume.levelDb|mute|…","value":any,"values":{key:value}?,"begin":bool?}"#,
|
|
has_seq,
|
|
set,
|
|
true,
|
|
),
|
|
spec(
|
|
"essentialSound.applyPreset",
|
|
"Apply Sound Preset",
|
|
r#"{"clips":[id]?,"preset":str,"type":"dialogue|music|sfx|ambience"?}"#,
|
|
has_seq,
|
|
apply_preset,
|
|
true,
|
|
),
|
|
spec("essentialSound.savePreset", "Save Sound Preset", r#"{"clips":[id]?,"name":str}"#, has_seq, save_preset, true),
|
|
spec("essentialSound.deletePreset", "Delete Sound Preset", r#"{"name":str,"type":"dialogue|music|sfx|ambience"?}"#, always, delete_preset, true),
|
|
spec("essentialSound.autoMatch", "Auto-Match Loudness", r#"{"clips":[id]?,"target":lufs?}"#, has_seq, auto_match, true),
|
|
spec("essentialSound.generateDucking", "Generate Ducking Keyframes", r#"{"clips":[id]?}"#, has_seq, generate_ducking, true),
|
|
]
|
|
}
|