diff --git a/crates/mix/src/offline.rs b/crates/mix/src/offline.rs index d544b0fc..ca0cdf90 100644 --- a/crates/mix/src/offline.rs +++ b/crates/mix/src/offline.rs @@ -131,33 +131,60 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { out } +/// Block size for clip effects in [`render_clips`]. +const CLIP_FX_BLOCK: usize = 4096; +/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds. +const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0; +/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail. +const CLIP_FX_TAIL_FLOOR: f32 = 1e-9; + /// Render only one track's clips (no mixer processing) — Consolidate. pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> { let Some(t) = s.track(track) else { return Vec::new() }; let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1)); let mut out = vec![vec![0.0f32; len]; t.channels().max(1)]; let mut scratch = Vec::new(); + // One reusable block for every clip with effects. + let mut block: Vec> = Vec::new(); + let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0); for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) { match clip_fx_params(s, c.id.0) { None => render_clip(s, c, range.start, len, &mut out, &mut scratch), Some(params) => { - let mut tmp = vec![vec![0.0f32; len]; out.len()]; - render_clip(s, c, range.start, len, &mut tmp, &mut scratch); - let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len()); + if block.len() != out.len() { + block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()]; + } + let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len()); fx.configure(params); - let mut done = 0; + // The effects start from silence, so nothing comes out before the clip starts: + // process from there through the clip and a bounded tail, not the whole selection. + let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len); + let first = offset(c.start); + let clip_end = offset(c.end()); + let tail_end = offset(c.end().saturating_add(max_tail)); + let mut done = first; while done < len { - let n = (len - done).min(4096); - let mut block: Vec> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect(); + let n = (len - done).min(CLIP_FX_BLOCK); + for ch in &mut block { + ch.fill(0.0); + } + if done < clip_end { + render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch); + } fx.process(&mut block, n); + let mut peak = 0.0f32; for (o, b) in out.iter_mut().zip(block.iter()) { if let Some(dst) = o.get_mut(done..done + n) { for (x, y) in dst.iter_mut().zip(b.iter()) { *x += *y; + peak = peak.max(y.abs()); } } } done += n; + if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) { + break; + } } } } diff --git a/crates/mix/src/tests.rs b/crates/mix/src/tests.rs index 5508f764..02c533c9 100644 --- a/crates/mix/src/tests.rs +++ b/crates/mix/src/tests.rs @@ -1208,3 +1208,74 @@ fn gain_ramp_matches_converting_both_ends_every_sample() { } } } + +/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection +/// (what `render_clips` did before it limited them to the clip and its tail). +fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec> { + let t = s.track(track).unwrap(); + let len = range.len() as usize; + let mut out = vec![vec![0.0f32; len]; t.channels().max(1)]; + let mut scratch = Vec::new(); + for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) { + let params = crate::clips::clip_fx_params(s, c.id.0).unwrap(); + let mut tmp = vec![vec![0.0f32; len]; out.len()]; + crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch); + let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len()); + fx.configure(params); + for done in (0..len).step_by(4096) { + let n = (len - done).min(4096); + let mut block: Vec> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect(); + fx.process(&mut block, n); + for (o, b) in out.iter_mut().zip(&block) { + o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y); + } + } + } + out +} + +#[test] +fn consolidate_clip_effects_cover_the_clip_and_its_tail() { + let fx_sets: [&[(&str, f64)]; 2] = [&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)]]; + for (k, params) in fx_sets.iter().enumerate() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] }))); + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800)); + for (param, value) in params.iter() { + s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value); + } + let range = Range::new(5_000, 100_000); + let got = render_clips(&s, t, range); + let want = render_clips_full_span(&s, t, range); + assert_eq!(got.len(), want.len()); + assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound"); + if k == 0 { + // The compressor's lookahead output after the clip's end is kept. + assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3); + } + for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() { + assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}"); + } + } +} + +#[test] +fn consolidate_many_tiny_effect_clips_stays_fast() { + // Thousands of one-sample clips with effects across a long selection: each one is processed + // over its own span, not the whole selection. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] }))); + for i in 0..2000i64 { + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1)); + s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0); + } + let start = std::time::Instant::now(); + let out = render_clips(&s, t, Range::new(0, 4_000_000)); + assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); + assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6); + assert_eq!(out[0][1001], 0.0); +}