From 9fa0deb4e1e680de8864371e895df458a7eb63ce Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Mon, 2 Nov 2026 22:16:52 -0500 Subject: [PATCH] Clip gain to volume: index envelope lookups Converting clip gain to volume (and baking volume into clip gain) evaluated the clip's gain envelope by walking it from the start for every breakpoint being converted, so a large envelope in an opened or imported session cost envelope points squared and blocked the engine. Build a running-maximum index over the envelope once per clip and binary-search it. It returns exactly what the walk returned, including for envelopes stored out of order. --- crates/engine/src/cmd/mix.rs | 66 +++++++++++++++++++++++------- crates/engine/src/cmd/mix/tests.rs | 33 +++++++++++++++ 2 files changed, 84 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index dbd52102..1dc67d12 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -598,6 +598,7 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result { } /// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds. +#[cfg(test)] fn env_db(env: &[(i64, f32)], at: i64) -> f32 { let mut prev: Option<(i64, f32)> = None; for &(t, v) in env { @@ -612,6 +613,38 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 { prev.map_or(0.0, |(_, v)| v) } +/// Repeated lookups in one clip-gain envelope, each O(log n) instead of a walk from its start. +/// Gives exactly what walking the points in stored order gives, sorted or not: the first point +/// at or after `at` is the first whose running maximum position reaches `at`. +struct EnvLookup<'a> { + env: &'a [(i64, f32)], + /// Largest position among `env[..=i]`. + reach: Vec, +} + +impl<'a> EnvLookup<'a> { + fn new(env: &'a [(i64, f32)]) -> Self { + let reach = env + .iter() + .scan(i64::MIN, |m, &(t, _)| { + *m = (*m).max(t); + Some(*m) + }) + .collect(); + EnvLookup { env, reach } + } + + /// Value in dB at `at` (see `env_db`). + fn db(&self, at: i64) -> f32 { + let i = self.reach.partition_point(|&m| m < at); + let Some(&(t, v)) = self.env.get(i) else { return self.env.last().map_or(0.0, |&(_, v)| v) }; + match i.checked_sub(1).and_then(|p| self.env.get(p)) { + Some(&(pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + } + } +} + /// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`. fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) { let delta = |at: i64| lane.value_at(at, base) - base; @@ -621,39 +654,41 @@ fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model return; } let rel = |at: i64| at - c.start; + let old_env = std::mem::take(&mut c.gain_env); + let env = EnvLookup::new(&old_env); + let env_db = |at: i64| env.db(at); let rel0 = rel(sel0); let rel1 = rel(sel1); // Later writes win, so the selection anchors replace any point they land on. - let mut pts: Vec<(i64, f32)> = - c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + let mut pts: Vec<(i64, f32)> = old_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); if rel0 > 0 { - pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); + pts.push((rel0 - 1, env_db(rel0 - 1))); } - pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + pts.push((rel0, env_db(rel0) + delta(sel0))); for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { let at = rel(pt.at); - pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); + pts.push((at, env_db(at) + delta(pt.at))); } // The clip's own gain points inside the selection keep their shape, offset by the lane. - for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { - pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); + for &(at, _) in old_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(at) + delta(c.start.saturating_add(at)))); } if sel1 - 1 > sel0 { let at = rel(sel1 - 1); - pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + pts.push((at, env_db(at) + delta(sel1 - 1))); } if sel1 < c.end() { - pts.push((rel1, env_db(&c.gain_env, rel1))); + pts.push((rel1, env_db(rel1))); } pts.sort_by_key(|p| p.0); - let mut env = Vec::new(); + let mut baked = Vec::new(); for p in pts { - if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { - env.pop(); + if baked.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + baked.pop(); } - env.push(p); + baked.push(p); } - c.gain_env = env; + c.gain_env = baked; } /// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there. @@ -726,8 +761,9 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result { if let Some(l) = &old { ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1)); } + let env = EnvLookup::new(&c.gain_env); for at in ats { - pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start))); + pts.push((at, level(at) + c.gain_db + env.db(at - c.start))); } c.gain_db = 0.0; c.gain_env.clear(); diff --git a/crates/engine/src/cmd/mix/tests.rs b/crates/engine/src/cmd/mix/tests.rs index d134e4da..77087a4e 100644 --- a/crates/engine/src/cmd/mix/tests.rs +++ b/crates/engine/src/cmd/mix/tests.rs @@ -345,3 +345,36 @@ fn insert_and_send_reject_invalid_slot() { let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap(); assert_eq!(r["slot"], 0); } + +#[test] +fn indexed_envelope_lookup_matches_the_walk() { + let envs: [Vec<(i64, f32)>; 5] = [ + Vec::new(), + vec![(100, -3.0)], + vec![(0, 0.0), (100, -6.0), (100, 3.0), (250, -12.0), (900, 1.0)], + // Out of order, as a hand-edited session file may hold. + vec![(500, -1.0), (100, -6.0), (700, 4.0), (300, 2.0), (700, -9.0), (50, 0.5)], + (0..200).map(|i| ((i * 37) % 1000, (i as f32 * 0.7).sin() * 12.0)).collect(), + ]; + for env in &envs { + let lookup = EnvLookup::new(env); + for at in -20..1100 { + assert_eq!(lookup.db(at).to_bits(), env_db(env, at).to_bits(), "{env:?} at {at}"); + } + } +} + +#[test] +fn clip_gain_to_volume_with_a_large_envelope_stays_fast() { + let (mut e, t) = tone_session(&[]); + let clip = e.session().track(t).unwrap().clips()[0].id; + let n = 200_000i64; + let c = e.session_mut().find_clip_mut(clip).unwrap(); + c.length = n * 5 + 10; + c.gain_env = (0..n).map(|i| (i * 5, if i % 2 == 0 { -6.0 } else { 0.0 })).collect(); + let start = std::time::Instant::now(); + e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": n * 5 + 10})).unwrap(); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap(); + assert!(lane.points.len() as i64 >= n); +}