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); +}