Merge pull request #326 from grovebit/long-renders

Refuse renders too long to finish instead of cutting the audio short
This commit is contained in:
Brandon Thomas authored and GitHub committed 2026-10-13 04:42:56 -04:00
commit b764e299bc
7 files changed
+104 -14

No files matched your search

+16 -7
View File
@@ -40,12 +40,25 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
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));
+1 -1
View File
@@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
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
}
}
+1 -1
View File
@@ -206,7 +206,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result<Value> {
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();
+4
View File
@@ -123,6 +123,10 @@ pub fn specs() -> Vec<CommandSpec> {
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}))
}),
+15 -4
View File
@@ -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<Option<SourceId>> {
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);
}
}
+18 -1
View File
@@ -417,13 +417,28 @@ pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result<bo
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<bool> {
pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result<bool> {
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();
@@ -458,6 +473,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
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));
+49
View File
@@ -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]}));
}
}