mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
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:
7 files changed
+104
-14
No files matched your search
@@ -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));
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}))
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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]}));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user