mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-14 14:26:46 -04:00
Consolidate covers the whole edit selection
Consolidate clamped its range to the track's last clip end plus one second, so on an empty track a 0-5 s selection made a 1 s clip and a 2-7 s selection made nothing. It now renders, clears and places exactly the selection, and refuses a selection too long to render instead of placing a shorter clip. Prepare DPE keeps the old per-track bound. Fixes #164 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
525f00b719
commit
092b3cc890
4 files changed
+144
-9
No files matched your search
@@ -759,7 +759,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
if crate::io::consolidate(e, "edit.consolidate", t, r)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let r = soundcraft_time::Range::new(0, end);
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
// Each track keeps its own length (its material plus a second), as before.
|
||||
let tr = crate::io::bounded_to_track(e.session(), t, r);
|
||||
if crate::io::consolidate(e, id, t, tr)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
||||
+16
-7
@@ -387,18 +387,27 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
|
||||
keep.len()
|
||||
}
|
||||
|
||||
/// Consolidate the selection on a track into one new clip backed by a rendered source.
|
||||
pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
|
||||
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
|
||||
/// whole range, silence included; a range too long to render is refused rather than shortened.
|
||||
pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result<bool> {
|
||||
let s = e.session();
|
||||
if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) {
|
||||
let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) };
|
||||
let start = r.start.max(0);
|
||||
let len = r.end.saturating_sub(start);
|
||||
if len <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let r = bounded_to_track(s, t, r);
|
||||
if r.is_empty() {
|
||||
return Ok(false);
|
||||
let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1);
|
||||
if usize::try_from(len).map_or(true, |n| n > max) {
|
||||
let secs = max as f64 / s.sample_rate.as_f64();
|
||||
return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name)));
|
||||
}
|
||||
let r = Range::new(start, start + len);
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str()));
|
||||
if audio.iter().any(|ch| ch.len() as i64 != len) {
|
||||
return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into()));
|
||||
}
|
||||
let name = format!("{}_consolidated", tr.name);
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &name, buf, None, FileFormat::Wav);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Consolidate makes one clip per track that covers exactly the edit selection (#164).
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::{AudioBuffer, FileFormat};
|
||||
use soundcraft_engine::{Engine, EngineError};
|
||||
use soundcraft_model::{Clip, TrackId};
|
||||
use soundcraft_time::Range;
|
||||
|
||||
fn stereo_tracks(n: u64) -> Engine {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"count": n, "format": "stereo"})).unwrap();
|
||||
e.session_mut().edit.selected_tracks = e.session().tracks.iter().map(|t| t.id).collect();
|
||||
e
|
||||
}
|
||||
|
||||
fn sr(e: &Engine) -> i64 {
|
||||
e.session().sample_rate.samples(1.0)
|
||||
}
|
||||
|
||||
/// Put a constant-level clip of `secs` seconds at the start of track `i`.
|
||||
fn add_clip(e: &mut Engine, i: usize, secs: i64) -> TrackId {
|
||||
let frames = usize::try_from(secs * sr(e)).unwrap();
|
||||
let t = e.session().tracks[i].id;
|
||||
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; frames]; 2] };
|
||||
let src = soundcraft_engine::io::add_source(e.session_mut(), "tone", buf, None, FileFormat::Wav);
|
||||
let id = e.session_mut().new_clip_id();
|
||||
soundcraft_engine::edit::place_clip(e.session_mut(), t, Clip::audio(id, "tone", src, 0, 0, frames as i64));
|
||||
t
|
||||
}
|
||||
|
||||
fn clips(e: &Engine, i: usize) -> Vec<(i64, i64)> {
|
||||
e.session().tracks[i].clips().iter().map(|c| (c.start, c.end())).collect()
|
||||
}
|
||||
|
||||
/// Frames in the source behind the only clip on track `i`, and its first sample.
|
||||
fn source_of_only_clip(e: &Engine, i: usize) -> (usize, f32) {
|
||||
let s = e.session();
|
||||
let c = &s.tracks[i].clips()[0];
|
||||
let audio = s.pool.get(c.source().unwrap()).unwrap();
|
||||
(audio.buffer.frames(), audio.buffer.channels[0][0])
|
||||
}
|
||||
|
||||
fn assert_each_track_has(e: &Engine, start: i64, end: i64) {
|
||||
for (i, t) in e.session().tracks.iter().enumerate() {
|
||||
assert_eq!(clips(e, i), vec![(start, end)], "track {}", t.name);
|
||||
assert_eq!(source_of_only_clip(e, i).0 as i64, end - start, "track {}", t.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_silence_from_zero_covers_the_whole_selection() {
|
||||
let mut e = stereo_tracks(5);
|
||||
let sr = sr(&e);
|
||||
e.session_mut().edit.selection = Range::new(0, 5 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
assert_each_track_has(&e, 0, 5 * sr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_silence_later_in_the_timeline_covers_the_whole_selection() {
|
||||
let mut e = stereo_tracks(5);
|
||||
let sr = sr(&e);
|
||||
e.session_mut().edit.selection = Range::new(2 * sr, 7 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
assert_each_track_has(&e, 2 * sr, 7 * sr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consolidating_past_the_end_of_a_clip_keeps_the_trailing_silence() {
|
||||
let mut e = stereo_tracks(1);
|
||||
let sr = sr(&e);
|
||||
add_clip(&mut e, 0, 1);
|
||||
e.session_mut().edit.selection = Range::new(0, 5 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
assert_each_track_has(&e, 0, 5 * sr);
|
||||
assert_eq!(source_of_only_clip(&e, 0).1, 0.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_track_consolidate_is_one_undo_step_and_fills_empty_tracks() {
|
||||
let mut e = stereo_tracks(3);
|
||||
let sr = sr(&e);
|
||||
add_clip(&mut e, 1, 2);
|
||||
let before = e.session().clone();
|
||||
let undo_depth = e.undo_history().len();
|
||||
e.session_mut().edit.selection = Range::new(sr, 4 * sr);
|
||||
e.execute("edit.consolidate", &json!({})).unwrap();
|
||||
// The clip on track 2 is split at the selection start; the head stays put.
|
||||
assert_eq!(clips(&e, 0), vec![(sr, 4 * sr)]);
|
||||
assert_eq!(clips(&e, 1), vec![(0, sr), (sr, 4 * sr)]);
|
||||
assert_eq!(clips(&e, 2), vec![(sr, 4 * sr)]);
|
||||
assert_eq!(e.undo_history().len(), undo_depth + 1);
|
||||
assert!(e.undo());
|
||||
for (a, b) in e.session().tracks.iter().zip(before.tracks.iter()) {
|
||||
assert_eq!(a.clips(), b.clips(), "track {}", a.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_selection_too_long_to_render_is_refused_not_shortened() {
|
||||
let mut e = stereo_tracks(2);
|
||||
add_clip(&mut e, 0, 1);
|
||||
let before: Vec<_> = (0..2).map(|i| clips(&e, i)).collect();
|
||||
// A stereo track renders at most MAX_RENDER_SAMPLES / 2 frames (about 93 minutes at 48 kHz).
|
||||
let max = i64::try_from(soundcraft_mix::MAX_RENDER_SAMPLES / 2).unwrap();
|
||||
let err = e.execute("edit.consolidate", &json!({"start": 0, "end": max + 1})).unwrap_err();
|
||||
assert!(matches!(err, EngineError::BadParams(..)), "{err}");
|
||||
assert!(err.to_string().contains("too long"), "{err}");
|
||||
let after: Vec<_> = (0..2).map(|i| clips(&e, i)).collect();
|
||||
assert_eq!(after, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_dpe_still_consolidates_each_track_to_its_own_material() {
|
||||
let mut e = stereo_tracks(2);
|
||||
let sr = sr(&e);
|
||||
let a = add_clip(&mut e, 0, 1);
|
||||
let b = add_clip(&mut e, 1, 3);
|
||||
e.execute("options.prepare_dpe_tracks", &json!({"tracks": [a.0, b.0]})).unwrap();
|
||||
// Unchanged from before #164: each track runs to its last clip plus a second, capped at the session end.
|
||||
assert_eq!(clips(&e, 0), vec![(0, 2 * sr)]);
|
||||
assert_eq!(clips(&e, 1), vec![(0, 3 * sr)]);
|
||||
assert_eq!(source_of_only_clip(&e, 0), (2 * sr as usize, 0.25));
|
||||
}
|
||||
Reference in new issue
Block a user