Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 951a5cd08a AudioSuite: budget pitch shift at its input length 2026-11-03 11:03:34 +00:00
Dylan M. Taylor 2b5785f2e2 AudioSuite: process each listed clip once, within a total budget
A clip listed more than once in audiosuite.process was processed again
on its own result: the replacement keeps the clip id, so an expanding
process (time stretch, varispeed, pitch shift) compounded the clip's
length each time, and every intermediate result stayed in the source
pool for the rest of the command.

Deduplicate the clip ids, and before processing anything check each
clip's per-render limit and the projected output of all clips together
against MAX_RENDER_SAMPLES, the same total budget the signal generator
already uses.
2026-11-02 22:29:03 -05:00
5 changed files with 80 additions and 190 deletions
+2 -25
View File
@@ -222,33 +222,10 @@ mod tests {
assert!(plugin_info("reverb").is_none());
}
/// A new directory that this test alone created, readable only by its user on Unix. Creation
/// fails if the path already exists: the test then loads native libraries from it, so it must
/// never reuse a directory someone else could have put a library in. It lives next to the test
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
/// directory without a sticky bit or with inheritable write access, where another user could
/// rename or populate it. Anyone who can write to the build directory can already replace the
/// test binary itself, so this parent adds no new trust.
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
let exe = std::env::current_exe().expect("test binary path");
let parent = exe.parent().expect("test binary has a parent directory");
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
#[cfg(unix)]
let builder = {
let mut b = std::fs::DirBuilder::new();
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b
};
#[cfg(not(unix))]
let builder = std::fs::DirBuilder::new();
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
d
}
#[test]
fn garbage_files_are_errors_not_crashes() {
let d = fresh_private_dir("soundcraft-clap-garbage");
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
std::fs::write(d.join("empty.clap"), b"").unwrap();
+69 -7
View File
@@ -20,14 +20,23 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// How many times longer than its input a process's output can be.
fn growth(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> f64 {
match name {
"time_stretch" => ratio,
"pitch_shift" => 2f64.powf(f64::from(semitones) / 12.0),
"varispeed" => f64::from(to) / sr,
"speed_ramp" if selection == SpeedRampSelection::Extend => 1.5,
_ => 1.0,
}
}
/// The most frames a process can take in: these processes cut their output at MAX_OFFLINE_SAMPLES frames.
fn frame_limit(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> usize {
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
match name {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
"time_stretch" | "pitch_shift" | "varispeed" | "speed_ramp" => {
(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / growth(name, ratio, semitones, sr, to, selection)) as usize
}
_ => usize::MAX,
}
}
@@ -76,14 +85,33 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
// Each clip once: a clip listed again would otherwise process the result of its first pass
// (an expanding process compounding its length every time).
let mut seen = std::collections::HashSet::new();
let ids: Vec<_> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
// Refuse before processing anything. Every result is kept as a new source for the life of
// the command (and undo holds it), so cap the total output across clips, not just each one.
// (pitch_shift resamples its stretched result back to the input length)
let grows = if name == "pitch_shift" { 1.0 } else { growth(&name, ratio, semitones, sr.as_f64(), to, selection).max(1.0) };
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
for id in &ids {
let Some((track, clip)) = e.session().find_clip(*id) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels()).max(1);
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let frames = (clip.length.max(0) as f64 * grows).ceil() as usize;
budget = budget
.checked_sub(frames.saturating_mul(channels))
.ok_or_else(|| bad("audiosuite.process", "too much audio to process in one command"))?;
}
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels());
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
let mut tmp = e.session().clone();
@@ -350,6 +378,40 @@ mod tests {
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
}
#[test]
fn a_clip_listed_twice_is_processed_once() {
let (mut e, id) = gained_clip();
let out =
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 4.0}, "clips": [id.0, id.0, id.0]})).unwrap();
assert_eq!(out["processed"], 1);
let c = e.session().tracks[0].clips()[0].clone();
assert!(c.length <= 64 * 4 + 64, "stretched once, not compounded: {}", c.length);
assert_eq!(e.session().sources.len(), 2, "one new source, not one per listing");
}
#[test]
fn process_refuses_a_total_budget_across_clips() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
// Three clips each within the per-clip limit whose results together exceed the budget.
let len = (soundcraft_mix::MAX_RENDER_SAMPLES / 3 + 1) as i64;
let ids: Vec<u64> = (0..3)
.map(|k| {
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, k * len, len));
id.0
})
.collect();
let mut e = crate::Engine::new(s);
let before = serde_json::to_value(e.session()).unwrap();
let out = e.execute("audiosuite.process", &json!({"process": "reverse", "clips": ids}));
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
assert!(ok, "three clips over budget, but got: {out:?}");
assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "refused before processing anything");
}
#[test]
fn generate_refuses_a_total_budget_across_tracks() {
let mut e = Engine::new(Session::default());
+6 -22
View File
@@ -1,6 +1,4 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -110,28 +108,14 @@ impl Sequence {
self.ctrls.sort_by_key(|c| c.tick);
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
// map per tick keeps the work per event constant after a dense tick.
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
for c in self.ctrls.drain(..) {
// Within a tick, replace an earlier event of the same channel+kind.
match last_at.entry((c.channel, kind_key(c.kind))) {
Entry::Occupied(mut e) => {
let (tick, idx) = *e.get();
match kept.get_mut(idx) {
Some(slot) if tick == c.tick => *slot = c,
_ => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
Entry::Vacant(e) => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
// Only events at the same tick (the tail of `kept`) can match.
let slot =
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
match slot {
Some(slot) => *slot = c,
None => kept.push(c),
}
}
self.ctrls = kept;
-69
View File
@@ -222,72 +222,3 @@ fn sequence_serde_round_trip() {
let json = serde_json::to_string(&seq).unwrap();
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
}
#[test]
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
// Every channel x controller number at one tick, twice: the second copy of each wins. A
// rescan of the tick's kept events per incoming event would make this quadratic.
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
let ctrls = [0, 1]
.iter()
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
.collect();
let mut seq = Sequence { notes: Vec::new(), ctrls };
let start = std::time::Instant::now();
assert_eq!(seq.remove_duplicates(), keys.len());
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(seq.ctrls.len(), keys.len());
assert!(seq.ctrls.iter().all(|c| c.value == 1));
}
#[test]
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
ctrls.sort_by_key(|c| c.tick);
let key = |k: CtrlKind| match k {
CtrlKind::Cc(n) => (0, n),
CtrlKind::PitchBend => (1, 0),
CtrlKind::ChannelPressure => (2, 0),
CtrlKind::PolyPressure(p) => (3, p),
CtrlKind::Program => (4, 0),
};
let mut kept: Vec<CtrlEvent> = Vec::new();
for c in ctrls {
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
Some(slot) => *slot = c,
None => kept.push(c),
}
}
kept
}
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let kinds = |r: u64| match r % 5 {
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
1 => CtrlKind::PitchBend,
2 => CtrlKind::ChannelPressure,
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
_ => CtrlKind::Program,
};
for round in 0..200 {
let mut ctrls = Vec::new();
for i in 0..(next() % 64) {
let r = next();
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
}
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
let removed = seq.remove_duplicates();
let expected = reference(ctrls.clone());
assert_eq!(seq.ctrls, expected, "round {round}");
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
}
}
+3 -67
View File
@@ -153,52 +153,18 @@ impl TempoMap {
}
}
/// Remove the meters after `tick`, returning each with the bar it starts: the bar
/// [`Self::bar_beat_at_tick`] gives its tick, counted in one pass over the map rather than one
/// walk per meter, so a session with many meter changes edits in linear time.
/// Remove the meters after `tick`, returning each with the bar it starts.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let mut later = Vec::new();
let (mut bar, mut seg_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
let tpb = m.ticks_per_bar();
if m.tick > tick {
later.push((bar.saturating_add(m.tick.saturating_sub(seg_tick) / tpb), *m));
}
if let Some(n) = self.meters.get(i + 1) {
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
}
}
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
self.meters.retain(|e| e.tick <= tick);
later
}
/// The bar and tick of the last meter's segment, where [`Self::bar_start_tick`]'s walk ends.
fn last_segment(&self) -> (i64, i64) {
let (mut cur_bar, mut cur_tick) = (1i64, 0i64);
for (i, m) in self.meters.iter().enumerate() {
if let Some(n) = self.meters.get(i + 1) {
cur_bar = cur_bar.saturating_add(ceil_div(n.tick.saturating_sub(cur_tick), m.ticks_per_bar()));
cur_tick = n.tick;
}
}
(cur_bar, cur_tick)
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
/// Each lands at or past the last meter, so the walk to its bar resumes from there; a bar
/// before it (meters off their bar lines) takes the full walk.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
let (mut cur_bar, mut cur_tick) = self.last_segment();
for (bar, ev) in later {
let tick = if bar >= cur_bar {
let tpb = self.meters.last().map_or(MeterEvent { tick: 0, numerator: 4, denominator: 4 }, |m| *m).ticks_per_bar();
cur_tick.saturating_add(bar.saturating_sub(cur_bar).saturating_mul(tpb))
} else {
self.bar_start_tick(bar)
};
let tick = self.bar_start_tick(bar);
self.put_meter(MeterEvent { tick, ..ev });
(cur_bar, cur_tick) = if bar >= cur_bar { (bar, tick) } else { self.last_segment() };
}
}
@@ -430,36 +396,6 @@ mod tests {
assert_eq!(m.bar_beat_at_tick(m.tick_at_bar_beat(bb)), bb);
}
#[test]
fn editing_an_early_meter_keeps_later_bars_and_scales_linearly() {
// 200 000 meter changes, one per bar, built directly: a walk per meter would take
// minutes to edit here, and even to build through `set_meter`.
let mut m = TempoMap::new(120.0, 4, 4).unwrap();
let mut tick = 0;
for bar in 1..200_000i64 {
let numerator = 3 + (bar % 2) as u32;
tick += m.meters.last().map_or(0, MeterEvent::ticks_per_bar);
m.meters.push(MeterEvent { tick, numerator, denominator: 4 });
}
let sample = [1usize, 1_000, 199_998];
let bars = |m: &TempoMap| sample.map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar);
assert_eq!(bars(&m), [2, 1_001, 199_999]);
m.set_meter(0, 7, 8).unwrap();
assert_eq!(bars(&m), [2, 1_001, 199_999], "later meters keep their bar numbers");
assert_eq!(m.meters.len(), 200_000);
assert!(m.meters.windows(2).all(|w| w[0].tick < w[1].tick));
assert!(m.remove_meter(m.meters[1].tick));
assert_eq!([1usize, 999, 199_997].map(|i| m.bar_beat_at_tick(m.meters[i].tick).bar), [3, 1_001, 199_999]);
// Meters off their bar lines (counted as bars 2 and 4) land where they always did: on
// bar 2 of the new meter, then two bars of their own later.
let mut off = TempoMap::new(120.0, 4, 4).unwrap();
off.meters.push(MeterEvent { tick: 100, numerator: 3, denominator: 4 });
off.meters.push(MeterEvent { tick: 5_000, numerator: 5, denominator: 4 });
off.set_meter(0, 2, 4).unwrap();
let placed: Vec<(i64, u32)> = off.meters.iter().map(|e| (e.tick, e.numerator)).collect();
assert_eq!(placed, vec![(0, 2), (2 * TICKS_PER_QUARTER, 3), (8 * TICKS_PER_QUARTER, 5)]);
}
#[test]
fn eighth_note_meters() {
let m = TempoMap::new(120.0, 6, 8).unwrap();