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:
Fabio LussiandClaude Opus 5.5 committed 2026-10-12 03:02:56 +02:00
1 parent 525f00b719
commit 092b3cc890
4 files changed
+144 -9

No files matched your search

+1 -1
View File
@@ -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;
}
}
+3 -1
View File
@@ -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
View File
@@ -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));
}