Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 249613044b address review: skip the lead-in for gain-only clip effects; keep the full-span chunk grid 2026-11-04 01:41:44 +00:00
Dylan M. Taylor 170d123bc6 address review: settle clip effect smoothers on a bounded silent lead-in 2026-11-03 19:14:33 +00:00
Dylan M. Taylor 292071eeff Consolidate: run clip effects over the clip and a bounded tail
render_clips allocated and ran a clip-effects workspace spanning the
whole selection for every clip with effects, however short the clip.
Many tiny effect clips in a long selection multiplied allocation and
effect processing by the selection length.

Process each clip's effects in reused 4096-frame blocks from the clip's
start (the effects start from silence, so nothing comes out before it)
through its end, then keep rendering the tail until a block falls below
-180 dBFS or 10 s past the clip's end.
2026-11-02 21:55:59 -05:00
3 changed files with 155 additions and 7 deletions
+4
View File
@@ -105,6 +105,10 @@ impl ClipFx {
}
}
}
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
pub(crate) fn has_smoothed_modules(&self) -> bool {
self.eq.is_some() || self.comp.is_some()
}
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
if let Some(eq) = &mut self.eq {
eq.process(io, frames);
+59 -7
View File
@@ -131,33 +131,85 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
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;
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
/// (the smoothers settle within ~0.3 s).
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
/// step at the same samples.
const CLIP_FX_CHUNK: usize = 32;
/// 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<Vec<f32>> {
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<f32>> = 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;
while done < len {
let n = (len - done).min(4096);
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
// 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));
// Smoothed parameters start at their defaults; settle them on silence, as the
// full-span render did, so the clip opens at the configured setting.
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
let grid = first - first % CLIP_FX_CHUNK;
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
lead -= lead % CLIP_FX_CHUNK;
// a gain-only effect has no smoother to settle
if !fx.has_smoothed_modules() {
lead = 0;
}
while lead > 0 {
let n = lead.min(CLIP_FX_BLOCK);
for ch in &mut block {
ch.fill(0.0);
}
fx.process(&mut block, n);
lead -= n;
}
let mut done = grid;
while done < len {
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;
}
}
}
}
+92
View File
@@ -1208,3 +1208,95 @@ 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<Vec<f32>> {
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<Vec<f32>> = 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)]; 3] =
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.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}");
}
}
}
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
/// effect's 32-frame chunks must line up with the full-span render's.
#[test]
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
for offset in [1i64, 31, 32, 33, 1000] {
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, offset, 4800));
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
let range = Range::new(0, 20_000);
let got = render_clips(&s, t, range);
let want = render_clips_full_span(&s, t, range);
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
assert!((a - b).abs() < 1e-5, "clip at {offset}, 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);
}