From 3a702d9c6f087b704015a4dd18a8d708fdf90f30 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Mon, 12 Oct 2026 17:35:33 +0100 Subject: [PATCH] Refuse renders too long to finish instead of cutting the audio short One offline render holds MAX_RENDER_SAMPLES samples, about 93 minutes of stereo at 48 kHz, and the offline time stretch, pitch shift and resample stop at MAX_OFFLINE_SAMPLES frames. Longer audio was cut short without a word: Render Clip Gain and Clip Effects Render left the end of the clip silent, every AudioSuite process shortened the clip, the track Commit made fell silent at the limit, and Export Clips wrote shortened files. Above 74 kHz an hour no longer fits, so TCE, Elastic Properties and Remove Pitch Shift padded the cut end with silence. Each now checks the length before it renders and refuses with the limit in seconds, as Consolidate does in #212. AudioSuite counts what Time Stretch, Pitch Shift and Varispeed make, so a fourfold stretch is refused at a quarter of the limit. Export Clips checks every clip before it writes a file. --- crates/engine/src/cmd/audiosuite.rs | 23 +++++++++----- crates/engine/src/cmd/clip.rs | 2 +- crates/engine/src/cmd/clip_more.rs | 2 +- crates/engine/src/cmd/file.rs | 4 +++ crates/engine/src/cmd/more_util.rs | 19 ++++++++--- crates/engine/src/io.rs | 19 ++++++++++- crates/engine/tests/long_renders.rs | 49 +++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 14 deletions(-) create mode 100644 crates/engine/tests/long_renders.rs diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index e2abfca0..3cf88b2c 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -40,12 +40,25 @@ fn process(e: &mut Engine, p: &Value) -> Result { return Err(bad("audiosuite.process", "select audio clips first")); } let sr = e.session().sample_rate; + let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0)); + let semitones = get("semitones", 0.0).clamp(-24.0, 24.0); + let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32; + // These processes cut their output at MAX_OFFLINE_SAMPLES frames. + let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize; + let most = match name.as_str() { + "time_stretch" => longest(ratio), + "pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)), + "varispeed" => longest(f64::from(to) / sr.as_f64()), + _ => usize::MAX, + }; 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(); @@ -78,13 +91,9 @@ fn process(e: &mut Engine, p: &Value) -> Result { audio } "duplicate" => audio, - "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf), - "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf), - "varispeed" => { - let speed = get("speed", 1.0).clamp(0.25, 4.0); - let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32; - soundcraft_dsp::offline::resample(&audio, sr.hz(), to) - } + "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf), + "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf), + "varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to), plugin => { let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) }; pl.prepare(srf, 1024, audio.len().max(1)); diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e5377..5df1cf62 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { let ids = clip_ids_param(e, p); let mut n = 0; for id in ids { - if crate::io::render_clip_gain(e, id)? { + if crate::io::render_clip_gain(e, "clip.gain_render", id)? { n += 1 } } diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 74dfd8de..7aa7203d 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -204,7 +204,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let mut n = 0; for id in &ids { - if crate::io::render_clip_gain(e, *id)? { + if crate::io::render_clip_gain(e, "clip.effects_render", *id)? { n += 1; } let s = e.session_mut(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf84..1a90f51b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -123,6 +123,10 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("file.export_clips", "no clips selected")); } + for (t, c) in ids.iter().filter_map(|id| e.session().find_clip(*id)) { + let channels = e.session().track(t).map_or(1, |x| x.channels()); + crate::io::refuse_longer(e.session(), "file.export_clips", &c.name, "export", c.length, crate::io::render_limit(channels))?; + } let n = crate::io::export_clips(e, &ids, &dir)?; Ok(json!({"written": n, "dir": dir})) }), diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index 756106b1..bf169821 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -132,9 +132,10 @@ pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) { } } -/// Longest audio we render in one stretch (one hour at the session rate). +/// Longest audio we render in one stretch: one hour at the session rate, and no more than +/// `time_stretch` makes. fn max_render(s: &Session) -> Samples { - s.sample_rate.samples(3600.0) + s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples) } /// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new @@ -142,8 +143,10 @@ fn max_render(s: &Session) -> Samples { pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result> { let ClipContent::Audio { source, offset } = c.content else { return Ok(None) }; let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) }; - if out_len <= 0 || out_len > max_render(s) { - return Err(bad(cmd, "the target length must be between 1 sample and one hour")); + let max = max_render(s); + if out_len <= 0 || out_len > max { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(bad(cmd, format!("the target length must be between 1 sample and {secs:.0} s"))); } let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; let src_len = ((c.length as f64) / stretch).round().max(1.0); @@ -202,4 +205,12 @@ mod tests { assert_eq!(env_db(&[], 50), 0.0); assert_eq!(env_db(&env, 500), -10.0); } + + #[test] + fn stretches_are_no_longer_than_time_stretch_makes() { + let mut s = Session::default(); + assert_eq!(max_render(&s), s.sample_rate.samples(3600.0)); + s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap(); + assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e4..fd17b368 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -408,13 +408,28 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { Ok(true) } +/// The most frames of `channels`-channel audio one offline render holds. +pub fn render_limit(channels: usize) -> usize { + soundcraft_mix::MAX_RENDER_SAMPLES / channels.max(1) +} + +/// Refuses `len` frames of `name` past `max`, where a render would cut the audio short. +pub fn refuse_longer(s: &Session, cmd: &str, name: &str, verb: &str, len: Samples, max: usize) -> Result<()> { + if len <= Samples::try_from(max).unwrap_or(Samples::MAX) { + return Ok(()); + } + let secs = max as f64 / s.sample_rate.as_f64(); + Err(crate::cmd::bad(cmd, format!("`{name}` is too long to {verb} (at most {secs:.0} s)"))) +} + /// Render clip gain into a new source and reset the clip's gain. -pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result { +pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result { let s = e.session(); let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) }; if !c.is_audio() { return Ok(false); } + refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?; let mut c2 = c.clone(); c2.fade_in = Default::default(); c2.fade_out = Default::default(); @@ -449,6 +464,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { return Ok(None); } let range = Range::new(0, end + s.sample_rate.samples(2.0)); + // The track renders through a main mix of at least two channels. + refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?; let audio = soundcraft_mix::render_track_pre_fader(s, t, range); let name = format!("{}.cm", tr.name); let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2)); diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/long_renders.rs new file mode 100644 index 00000000..c2c9fceb --- /dev/null +++ b/crates/engine/tests/long_renders.rs @@ -0,0 +1,49 @@ +//! Renders longer than one offline buffer holds are refused, rather than cut short and kept. + +use serde_json::{Value, json}; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES; +use soundcraft_engine::Engine; +use soundcraft_mix::MAX_RENDER_SAMPLES; +use soundcraft_model::{ChannelFormat, Clip, ClipId, Session, TrackKind}; + +/// A stereo track "Talk" with one clip `frames` long. Its source is one second: the commands +/// refuse on the clip's length before they render anything. +fn long_clip(frames: usize) -> (Engine, ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Talk")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]; 2] }; + let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64)); + (Engine::new(s), id) +} + +fn refused(e: &mut Engine, cmd: &str, params: &Value) { + let before = serde_json::to_value(e.session()).unwrap(); + let err = e.execute(cmd, params).expect_err(cmd).to_string(); + assert!(err.contains("is too long to"), "{cmd}: {err}"); + assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "{cmd} changed the session"); +} + +#[test] +fn clips_longer_than_one_render_are_refused() { + let (mut e, id) = long_clip(MAX_RENDER_SAMPLES / 2 + 1); + let dir = std::env::temp_dir().join(format!("soundcraft-long-renders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + refused(&mut e, "clip.gain_render", &json!({"clips": [id.0]})); + refused(&mut e, "clip.effects_render", &json!({"clips": [id.0]})); + refused(&mut e, "audiosuite.process", &json!({"process": "reverse", "clips": [id.0]})); + refused(&mut e, "track.commit", &json!({"tracks": ["Talk"]})); + refused(&mut e, "file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [id.0]})); + assert!(!dir.exists(), "Export Clips started writing before it refused"); +} + +#[test] +fn audiosuite_refuses_results_longer_than_one_render() { + let (mut e, id) = long_clip(MAX_OFFLINE_SAMPLES / 4 + 1); + let fourfold = [("time_stretch", json!({"ratio": 4.0})), ("pitch_shift", json!({"semitones": 24.0})), ("varispeed", json!({"speed": 0.25}))]; + for (process, params) in fourfold { + refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]})); + } +}