Merge pull request #365 from grovebit/edit-modes

Make the Shuffle, Slip, Spot and Grid edit modes work like Pro Tools
This commit is contained in:
Brandon Thomas 2026-10-13 06:30:33 -04:00 committed by GitHub
commit 768629a8ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1949 additions and 186 deletions

View File

@ -40,7 +40,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result<Value> {
seq.sort();
let count = seq.notes.len();
let clip = Clip::midi(ClipId(0), "Audio as MIDI", 0, r.len(), seq);
e.clipboard = Clipboard { length: r.len(), tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::default() };
e.clipboard = Clipboard { tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::of_range(e.session(), r) };
Ok(json!({"notes": count, "length": r.len()}))
}

View File

@ -153,8 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")),
None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)),
};
let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1));
let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at };
if sh {
crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?;
}
let id = s.new_clip_id();
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, frames.max(1)));
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len));
Ok(json!({"clip": id, "track": t}))
}
),
@ -302,7 +307,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
copies.push((c.start + c.length * k, nc));
}
for (at, nc) in copies {
crate::edit::paste(s, tid, at, &[nc], c.length, false);
let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false);
made += 1;
}
}
@ -346,14 +351,11 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
let moves: Vec<(soundcraft_model::ClipId, i64)> = ids
.iter()
.filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point)))
.map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor))
.collect();
let moves: Vec<(soundcraft_model::ClipId, i64)> =
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
let s = e.session_mut();
for (id, d) in &moves {
crate::edit::move_clips(s, &[*id], *d, None);
crate::edit::move_clips(s, &[*id], *d, &[]);
}
Ok(json!({"clips": moves.len()}))
}

View File

@ -1,5 +1,6 @@
//! Edit menu commands.
use super::more_util::toggle_flag;
use super::*;
use crate::edit as ops;
use crate::{Clipboard, cmd};
@ -133,11 +134,33 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge),
cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| {
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
e.session_mut().edit.edit_mode = m;
Ok(json!({"mode": m}))
}),
cmd!(
noundo "edit.mode",
"Edit Mode",
[],
Some("F1-F4, Alt+1-4, `"),
"{mode: shuffle|slip|spot|grid|relative_grid, snap_to_grid?: bool (Shuffle, Slip and Spot)}",
always,
set_mode
),
cmd!(
noundo "edit.snap_to_grid",
"Snap to Grid",
[],
Some("Shift+F4"),
"{value?: bool} (toggles when omitted) — in Shuffle, Slip and Spot the Edit cursor and selections snap to the grid",
always,
snap_to_grid
),
cmd!(
noundo "edit.shuffle_lock",
"Shuffle Lock",
[],
None,
"{value?: bool} (toggles when omitted) — keeps Shuffle mode from being chosen",
always,
shuffle_lock
),
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
e.session_mut().edit.tool = t;
@ -148,10 +171,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Clips",
[],
None,
"{clips?: [id], by?: samples, to?: position (first clip start), track?: target}",
"{clips?: [id], by?: samples, to?: position for the first clip that can move (Grid modes snap its sync point), track?: where the first clip's track goes; the others keep their distance}",
has_selection,
move_clips
),
cmd!(
"edit.trim_clip",
"Trim Clip",
[],
None,
"{clip: id, start?: position, end?: position} — moves the clip's edges like the Trim tool, in the current Edit mode",
always,
trim_clip
),
cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate),
]
}
@ -160,6 +192,35 @@ fn shuffle(s: &Session) -> bool {
s.edit.edit_mode == EditMode::Shuffle
}
fn set_mode(e: &mut Engine, p: &Value) -> Result<Value> {
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
if m == EditMode::Shuffle && e.session().edit.flag(ops::SHUFFLE_LOCK) {
return Err(bad("edit.mode", "Shuffle is locked"));
}
let snap = bool_or(p, "snap_to_grid", false) && !m.is_grid();
let ed = &mut e.session_mut().edit;
ed.edit_mode = m;
ed.set_flag(ops::SNAP_TO_GRID, snap);
if m.is_grid() {
ed.set_flag(ops::RELATIVE_GRID, m == EditMode::GridRelative);
}
Ok(json!({"mode": m, "snap_to_grid": snap}))
}
fn snap_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
if e.session().edit.edit_mode.is_grid() {
return Err(bad("edit.snap_to_grid", "Grid mode always snaps"));
}
toggle_flag(e, p, ops::SNAP_TO_GRID)
}
fn shuffle_lock(e: &mut Engine, p: &Value) -> Result<Value> {
if shuffle(e.session()) && bool_or(p, "value", true) {
return Err(bad("edit.shuffle_lock", "leave Shuffle mode before locking it"));
}
toggle_flag(e, p, ops::SHUFFLE_LOCK)
}
fn has_last_selection(e: &Engine) -> std::result::Result<(), String> {
if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) }
}
@ -190,7 +251,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
if range.is_empty() {
return Err(bad(id, "selection is empty"));
}
let mut cb = Clipboard { length: range.len(), ..Clipboard::default() };
let mut cb = Clipboard::of_range(e.session(), range);
let follow = e.session().edit.automation_follows_edit;
for t in &tracks {
cb.tracks.push(ops::copy_range(e.session(), *t, range));
@ -232,15 +293,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
let sh = shuffle(e.session());
let s = e.session_mut();
for t in &tracks {
ops::clear_range(s, *t, range, sh);
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
l.clear_range(range.start, range.end);
if sh {
l.shift_from(range.end, -range.len());
}
}
}
cut_track(s, *t, range, sh, follow);
}
s.edit.selection = soundcraft_time::Range::point(range.start);
}
@ -249,17 +302,39 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
Ok(json!({"clips": n, "length": range.len()}))
}
/// Remove `r` from a track, closing the gap in Shuffle mode; automation follows when `follow`.
fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, follow: bool) {
if follow && let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
l.clear_range(r.start, r.end);
}
}
ops::clear_range(s, t, r, sh);
}
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.paste", p)?;
let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start);
let given = position_param(e, "edit.paste", p, "at")?;
let cb = e.clipboard.clone();
let sh = shuffle(e.session());
let follow = e.session().edit.automation_follows_edit;
let s = e.session();
let at = match given {
Some(at) => at,
None if s.edit.edit_mode == EditMode::GridRelative => {
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
}
None => s.edit.selection.start,
};
let sh = shuffle(s) && !cb.automation_only;
let follow = s.edit.automation_follows_edit;
let replace = Some(s.edit.selection).filter(|r| sh && given.is_none() && !r.is_empty());
let s = e.session_mut();
let mut pasted = 0;
for (i, t) in tracks.iter().enumerate() {
let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue };
pasted += ops::paste(s, *t, at, clips, cb.length, sh).len();
if let Some(r) = replace {
cut_track(s, *t, r, true, follow);
}
pasted += ops::paste(s, *t, at, clips, cb.length, sh).map_err(|why| bad("edit.paste", why))?.len();
if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) {
for l in lanes {
let dst = tr.lane_mut(&l.param);
@ -356,7 +431,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
c
})
.collect();
ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
}
}
at += len;
@ -485,12 +560,14 @@ fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
if range.is_empty() {
return Err(bad("edit.duplicate", "selection is empty"));
}
let sh = shuffle(e.session());
let follow = e.session().edit.automation_follows_edit;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let clips = ops::copy_range(s, *t, range);
n += ops::paste(s, *t, range.end, &clips, range.len(), false).len();
if let Some(tr) = s.track_mut(*t) {
n += ops::paste(s, *t, range.end, &clips, range.len(), sh).map_err(|why| bad("edit.duplicate", why))?.len();
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect();
for pt in pts {
@ -510,11 +587,13 @@ fn repeat(e: &mut Engine, p: &Value) -> Result<Value> {
if range.is_empty() {
return Err(bad("edit.repeat", "selection is empty"));
}
let sh = shuffle(e.session());
let s = e.session_mut();
for t in &tracks {
let clips = ops::copy_range(s, *t, range);
for k in 1..=count {
ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false);
let at = range.start.saturating_add(range.len().saturating_mul(k));
ops::paste(s, *t, at, &clips, range.len(), sh).map_err(|why| bad("edit.repeat", why))?;
}
}
Ok(json!({"repeats": count}))
@ -525,19 +604,25 @@ fn shift(e: &mut Engine, p: &Value) -> Result<Value> {
let later = bool_or(p, "later", true);
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let n = ops::move_clips(s, &ids, if later { by } else { -by }, None);
let n = ops::move_clips(s, &ids, if later { by } else { -by }, &[]);
Ok(json!({"moved": n}))
}
fn insert_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.insert_silence", p)?;
let range = range_param(e, "edit.insert_silence", p)?;
let sh = shuffle(e.session());
let follow = e.session().edit.automation_follows_edit;
let s = e.session_mut();
for t in &tracks {
ops::insert_silence(s, *t, range);
if let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
l.shift_from(range.start, range.len());
if sh {
ops::make_room(s, *t, range.start, range.len()).map_err(|why| bad("edit.insert_silence", why))?;
} else {
ops::clear_range(s, *t, range, false);
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
l.clear_range(range.start, range.end);
}
}
}
}
@ -764,7 +849,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) };
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, &[]) };
s.edit.selection = s.edit.selection.shifted(step);
if s.edit.selection.start < 0 {
s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start);
@ -777,26 +862,48 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("edit.move_clips", "no clips"));
}
let to_track = track_param(e, "edit.move_clips", p, "track")?;
let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0);
let s = e.session();
let dest = match track_param(e, "edit.move_clips", p, "track")? {
Some(to) => ids
.iter()
.find_map(|c| s.find_clip(*c))
.and_then(|(from, _)| ops::destinations(s, &ids, from, to))
.ok_or_else(|| bad("edit.move_clips", "the clips can't land there: past the last track, or on a track that can't hold them"))?,
None => Vec::new(),
};
let first = ops::first_movable(s, &ids);
let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) {
(Some(by), _) => by,
(None, Some(to)) => to - first,
(None, Some(to)) => to.saturating_sub(first.map_or(0, |c| c.start)),
_ => 0,
};
// Grid mode snaps the destination.
let s = e.session();
let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() {
s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first
} else {
delta
let mode = s.edit.edit_mode;
let delta = match first {
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
_ => delta,
};
let s = e.session_mut();
let n = ops::move_clips(s, &ids, delta, to_track);
let n = if mode == EditMode::Shuffle {
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
} else {
ops::move_clips(s, &ids, delta, &dest)
};
s.edit.selected_clips = ids;
Ok(json!({"moved": n, "by": delta}))
}
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
let id = clip_id(p, "edit.trim_clip")?;
let start = position_param(e, "edit.trim_clip", p, "start")?;
let end = position_param(e, "edit.trim_clip", p, "end")?;
if start.is_none() && end.is_none() {
return Err(bad("edit.trim_clip", "pass `start` and/or `end`"));
}
let sh = shuffle(e.session());
let c = ops::trim_clip(e.session_mut(), id, start, end, sh).ok_or_else(|| bad("edit.trim_clip", "no such clip, or it is edit-locked"))?;
Ok(json!({"start": c.start, "end": c.end()}))
}
fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.consolidate", p)?;
let r = range_param(e, "edit.consolidate", p)?;

View File

@ -290,7 +290,7 @@ pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result<Value> {
fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let mut cb = Clipboard { length: r.len(), ..Clipboard::default() };
let mut cb = Clipboard { automation_only: true, ..Clipboard::of_range(e.session(), r) };
let mut lanes_n = 0;
for t in &tracks {
cb.tracks.push(Vec::new());
@ -551,12 +551,13 @@ fn duplicate_extend(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.duplicate_extend";
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let (sh, follow) = (shuffle(e.session()), e.session().edit.automation_follows_edit);
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let clips = ops::copy_range(s, *t, r);
n += ops::paste(s, *t, r.end, &clips, r.len(), false).len();
if let Some(tr) = s.track_mut(*t) {
n += ops::paste(s, *t, r.end, &clips, r.len(), sh).map_err(|why| bad(id, why))?.len();
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect();
for pt in pts {
@ -683,7 +684,7 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result<Value> {
others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start)
};
if let Some(d) = delta.filter(|d| *d != 0) {
moved += ops::move_clips(s, &[id], d, None);
moved += ops::move_clips(s, &[id], d, &[]);
}
}
Ok(json!({"moved": moved}))

View File

@ -176,7 +176,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::clear_range(s, t, r, true);
crate::edit::clear_range(s, t, r, false);
crate::edit::shift_from(s, t, r.end, -r.len());
if let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
l.clear_range(r.start, r.end);

View File

@ -26,10 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
}
fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> {
match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) {
Some(ClipContent::Midi { sequence }) => Ok(sequence),

View File

@ -301,6 +301,10 @@ pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
}
}
pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
}
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();

View File

@ -177,7 +177,7 @@ pub fn specs() -> Vec<CommandSpec> {
tr.active_playlist = active;
}
let _ = src;
let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false);
let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false).unwrap_or_default();
Ok(json!({"clips": ids}))
}
),

View File

@ -5,8 +5,16 @@
//! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle
//! mode also ripples later clips.
use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId};
use soundcraft_model::{AutoParam, AutomationPoint, Clip, ClipContent, ClipId, Fade, FadeShape, Session, Track, TrackId};
use soundcraft_time::{Range, SampleRate, Samples, TempoMap};
use std::collections::{HashMap, HashSet};
/// Edit-state flag: the Edit cursor and selections snap to the grid in Shuffle, Slip and Spot.
pub const SNAP_TO_GRID: &str = "edit.snap_to_grid";
/// Edit-state flag: Shuffle mode can't be chosen.
pub const SHUFFLE_LOCK: &str = "edit.shuffle_lock";
/// Edit-state flag: the Grid mode last used was Relative, so choosing Grid again picks it.
pub const RELATIVE_GRID: &str = "edit.relative_grid";
/// Context needed to split MIDI clips in musical time.
#[derive(Clone, Copy)]
@ -101,8 +109,9 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
count
}
/// Remove material inside `range` on a track. In shuffle mode later clips move left.
/// Returns the removed material as clips positioned relative to `range.start`.
/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the
/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative
/// to `range.start`.
pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec<Clip> {
if range.is_empty() {
return Vec::new();
@ -122,14 +131,9 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool)
}
});
if shuffle {
let len = range.len();
for c in &mut pl.clips {
if c.start >= range.end {
c.start -= len;
}
}
let moved = ripple(s, track, range.end, -range.len());
follow_automation(s, track, range.end, moved);
}
pl.sort();
removed
}
@ -156,18 +160,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
}
/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless
/// `shuffle`, which first pushes later material right by `length`.
pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec<ClipId> {
/// `shuffle`, which first makes room for `length` (see [`make_room`], whose refusal it returns).
pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Result<Vec<ClipId>, &'static str> {
let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0));
if shuffle {
separate_at(s, track, at);
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
for c in &mut pl.clips {
if c.start >= at {
c.start += length;
}
}
}
make_room(s, track, at, length)?;
}
let mut ids = Vec::new();
for c in clips {
@ -177,7 +174,7 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt
ids.push(nc.id);
place_clip(s, track, nc);
}
ids
Ok(ids)
}
/// Place one clip, overwriting whatever it covers.
@ -189,21 +186,146 @@ pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) {
}
}
/// Insert silence: shift material at/after `range.start` right by the range length.
/// Insert silence: shift material at/after `range.start` right by the range length (Insert Time;
/// Shuffle mode edits use [`make_room`]).
pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) {
if range.is_empty() {
return;
}
separate_at(s, track, range.start);
shift_from(s, track, range.start, range.len());
}
/// Move every clip that starts at or after `from` by `by`, locked or not.
pub fn shift_from(s: &mut Session, track: TrackId, from: Samples, by: Samples) {
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
for c in &mut pl.clips {
if c.start >= range.start {
c.start += range.len();
}
for c in pl.clips.iter_mut().filter(|c| c.start >= from) {
c.start = c.start.saturating_add(by);
}
}
}
/// Time- or edit-locked: the clip doesn't move, and Shuffle stops at it.
pub fn locked(c: &Clip) -> bool {
c.time_locked || c.edit_locked
}
/// A clip's place on its track, for working out Shuffle layouts without touching the clips.
#[derive(Clone, Copy)]
struct Span {
id: ClipId,
start: Samples,
end: Samples,
locked: bool,
}
fn spans(clips: &[Clip]) -> Vec<Span> {
clips.iter().map(|c| Span { id: c.id, start: c.start, end: c.end(), locked: locked(c) }).collect()
}
/// Shuffle mode's ripple: the spans from `from` on move by `by`, up to the first locked one, which
/// stays put with everything after it. A move left stops at the session start, keeping their
/// spacing. Returns how far they moved and where the lock is (`Samples::MAX` when none).
fn ripple_spans(spans: &mut [Span], from: Samples, by: Samples) -> (Samples, Samples) {
let lock = spans.iter().filter(|c| c.locked && c.start >= from).map(|c| c.start).min().unwrap_or(Samples::MAX);
let moves = |c: &Span| c.start >= from && c.start < lock;
let floor = spans.iter().filter(|c| moves(c)).map(|c| c.start).min().unwrap_or(0).max(0);
let by = by.max(-floor);
for c in spans.iter_mut().filter(|c| moves(c)) {
c.start = c.start.saturating_add(by);
c.end = c.end.saturating_add(by);
}
(by, lock)
}
/// [`ripple_spans`] on a track's clips.
fn ripple(s: &mut Session, track: TrackId, from: Samples, by: Samples) -> (Samples, Samples) {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return (0, Samples::MAX) };
let mut sp = spans(&pl.clips);
let moved = ripple_spans(&mut sp, from, by);
for (c, n) in pl.clips.iter_mut().zip(&sp) {
c.start = n.start;
}
pl.sort();
moved
}
/// Automation Follows Edit for a Shuffle ripple (`moved` from [`ripple`]): the points from `from`
/// up to the lock move with the clips, and when material closes up the points in the span that
/// goes away go too.
fn follow_automation(s: &mut Session, track: TrackId, from: Samples, (by, lock): (Samples, Samples)) {
if !s.edit.automation_follows_edit || by == 0 {
return;
}
let Some(t) = s.track_mut(track) else { return };
for l in &mut t.automation {
if by < 0 {
l.clear_range(from.saturating_add(by), from);
}
for p in l.points.iter_mut().filter(|p| p.at >= from && p.at < lock) {
p.at = p.at.saturating_add(by);
}
l.points.sort_by_key(|p| p.at);
l.points.dedup_by_key(|p| p.at);
}
}
/// With Automation Follows Edit, take the points in `[start, end)` off a track, timed from `origin`.
fn take_automation(s: &mut Session, track: TrackId, start: Samples, end: Samples, origin: Samples) -> Vec<(AutoParam, AutomationPoint)> {
let mut out = Vec::new();
if !s.edit.automation_follows_edit || end <= start {
return out;
}
let Some(t) = s.track_mut(track) else { return out };
for l in &mut t.automation {
let taken = l.points.iter().filter(|p| p.at >= start && p.at < end);
out.extend(taken.map(|p| (l.param.clone(), AutomationPoint { at: p.at.saturating_sub(origin), value: p.value })));
l.clear_range(start, end);
}
out
}
fn put_automation(s: &mut Session, track: TrackId, origin: Samples, points: &[(AutoParam, AutomationPoint)]) {
let Some(t) = s.track_mut(track) else { return };
for (param, p) in points {
t.lane_mut(param).set_point(origin.saturating_add(p.at), p.value);
}
}
/// How far Shuffle mode can push the material from `at` on before it runs into a locked clip
/// (`Samples::MAX` when none follows).
pub fn shuffle_room(s: &Session, track: TrackId, at: Samples) -> Samples {
let clips = s.track(track).map(|t| t.clips()).unwrap_or_default();
let Some(lock) = clips.iter().filter(|c| locked(c) && c.end() > at).map(|c| c.start).min() else { return Samples::MAX };
let end = clips.iter().filter(|c| c.end() > at && c.start < lock).map(Clip::end).fold(at, Samples::max);
lock.saturating_sub(end)
}
/// Why Shuffle can't make room: a locked clip follows too closely.
pub const NO_ROOM: &str = "not enough room before a locked clip";
/// Why Shuffle can't make room: the point is inside a crossfade, which can't be pulled apart.
pub const IN_CROSSFADE: &str = "can't make room inside a crossfade";
/// Shuffle mode's room for `len` at `at`: later clips (and with Automation Follows Edit their
/// automation) move right. Refused, with nothing changed, when a locked clip is in the way or
/// `at` is inside a crossfade.
pub fn make_room(s: &mut Session, track: TrackId, at: Samples, len: Samples) -> Result<(), &'static str> {
if len <= 0 {
return Ok(());
}
let clips = s.track(track).map(|t| t.clips()).unwrap_or_default();
if clips.iter().filter(|c| c.start <= at && at < c.end()).count() > 1 {
return Err(IN_CROSSFADE);
}
if shuffle_room(s, track, at) < len {
return Err(NO_ROOM);
}
separate_at(s, track, at);
let moved = ripple(s, track, at, len);
follow_automation(s, track, at, moved);
Ok(())
}
/// Trim clips to keep only material inside `range`.
pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
let tempo = s.tempo.clone();
@ -280,14 +402,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
healed
}
/// Move clips by `delta` (and optionally to another track), overwriting what they land on.
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option<TrackId>) -> usize {
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
/// elsewhere (see [`destinations`]).
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
for t in &mut s.tracks {
let tid = t.id;
if let Some(pl) = t.playlist_mut() {
pl.clips.retain(|c| {
if ids.contains(&c.id) && !c.time_locked && !c.edit_locked {
if ids.contains(&c.id) && !locked(c) {
moving.push((tid, c.clone()));
false
} else {
@ -297,13 +421,298 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Opt
}
}
let n = moving.len();
for (tid, mut c) in moving {
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta).max(0);
place_clip(s, to_track.unwrap_or(tid), c);
}
(lands(dest, tid), c)
});
put_clips(s, placed.collect());
n
}
/// Place moved clips, overwriting what they cover but not each other, so a crossfaded pair
/// moved together stays a crossfade.
fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
for (t, c) in &placed {
clear_range(s, *t, c.range(), false);
}
for (t, c) in placed {
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.push(c);
pl.sort();
}
}
}
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder.
pub fn shown(s: &Session, t: &Track) -> bool {
!t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open)
}
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
/// from it among the tracks the Edit window shows, or among all tracks when a hidden one is
/// involved. None when one would land past either end, or on a track that can't hold its clips.
pub fn destinations(s: &Session, ids: &[ClipId], from: TrackId, to: TrackId) -> Option<Vec<(TrackId, TrackId)>> {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let holds = |t: &Track| t.clips().iter().any(|c| ids.contains(&c.id));
let visible = s.tracks.iter().filter(|t| t.id == from || t.id == to || holds(t)).all(|t| shown(s, t));
let rows: Vec<&Track> = s.tracks.iter().filter(|t| !visible || shown(s, t)).collect();
let row = |id: TrackId| rows.iter().position(|t| t.id == id).and_then(|i| isize::try_from(i).ok());
let shift = row(to)? - row(from)?;
s.tracks
.iter()
.filter(|t| holds(t))
.map(|t| {
let d = rows.get(usize::try_from(row(t.id)? + shift).ok()?)?;
(d.kind.has_playlist() && (d.kind == t.kind || d.kind.is_midi() && t.kind.is_midi())).then_some((t.id, d.id))
})
.collect()
}
/// The track that clips on `t` land on under a [`destinations`] map.
pub fn lands(dest: &[(TrackId, TrackId)], t: TrackId) -> TrackId {
dest.iter().find(|(from, _)| *from == t).map_or(t, |(_, d)| *d)
}
/// The earliest of clips `ids` that can move (neither time- nor edit-locked).
pub fn first_movable<'a>(s: &'a Session, ids: &[ClipId]) -> Option<&'a Clip> {
ids.iter().filter_map(|id| s.find_clip(*id)).map(|(_, c)| c).filter(|c| !locked(c)).min_by_key(|c| c.start)
}
/// One Shuffle lift: where the hole the clip leaves starts and how long it is, how far the clips
/// after it actually close up (none past a lock, none before the session start) and where the
/// first locked clip after it is.
#[derive(Clone, Copy)]
struct Lift {
id: ClipId,
start: Samples,
from: Samples,
shift: Samples,
by: Samples,
lock: Samples,
}
/// Lifting the movable `moving` clips off a track's `spans` the Shuffle way, right to left, in
/// one pass: the lifts in that order, and the spans that stay at the starts they close up to.
/// A hole starts where the clips before it end (a crossfade partner keeps its material) and
/// closes up to the next clip, so silence after the lifted clip stays. Clips are ordered by
/// start, then id, so of two stacked clips the later one counts as after the other.
fn lift_plan(mut sp: Vec<Span>, moving: &HashSet<ClipId>) -> (Vec<Lift>, Vec<Span>) {
sp.sort_by_key(|c| (c.start, c.id));
let lifted = |c: &Span| !c.locked && moving.contains(&c.id);
let reach: Vec<Samples> = std::iter::once(Samples::MIN)
.chain(sp.iter().scan(Samples::MIN, |far, c| {
*far = (*far).max(c.end);
Some(*far)
}))
.collect();
let mut lifts = Vec::new();
let (mut next, mut next_moves, mut lock) = (None, false, Samples::MAX);
for (i, c) in sp.iter().enumerate().rev() {
if !lifted(c) {
(next, next_moves) = (Some(c.start), !c.locked);
if c.locked {
lock = c.start;
}
continue;
}
let from = reach.get(i).map_or(c.start, |far| c.start.max(*far));
let shift = next.unwrap_or(c.end).min(c.end).saturating_sub(from).max(0);
let by = if next_moves { -shift.min(next.unwrap_or(0).max(0)) } else { 0 };
next = next.map(|n| n.saturating_add(by));
lifts.push(Lift { id: c.id, start: c.start, from, shift, by, lock });
}
let (mut by, mut pending) = (0, lifts.iter().rev());
let mut staying = Vec::with_capacity(sp.len() - lifts.len());
for c in sp {
if lifted(&c) {
by += pending.next().map_or(0, |l| l.by);
} else if c.locked {
by = 0;
staying.push(c);
} else {
staying.push(Span { start: c.start.saturating_add(by), end: c.end.saturating_add(by), ..c });
}
}
(lifts, staying)
}
/// Where Shuffle can land material on `track` once the movable `moving` clips have left it (later
/// clips close each gap): the clip boundaries, never inside a clip or a crossfade, sorted. Empty
/// for an empty track.
pub fn shuffle_slots(s: &Session, track: TrackId, moving: &[ClipId]) -> Vec<Samples> {
let clips = s.track(track).map(|t| t.clips()).unwrap_or_default();
let (_, mut sp) = lift_plan(spans(clips), &moving.iter().copied().collect());
sp.sort_by_key(|c| c.start);
let reach: Vec<Samples> = sp
.iter()
.scan(Samples::MIN, |far, c| {
*far = (*far).max(c.end);
Some(*far)
})
.collect();
let inside = |b: Samples| reach.get(sp.partition_point(|c| c.start < b).wrapping_sub(1)).is_some_and(|far| *far > b);
let mut slots: Vec<Samples> = sp.iter().flat_map(|c| [c.start, c.end]).filter(|b| !inside(*b)).collect();
slots.sort_unstable();
slots.dedup();
slots
}
/// The slot nearest `at` (the earlier one on a tie), or the session start when there are none.
pub fn nearest_slot(slots: &[Samples], at: Samples) -> Samples {
let i = slots.partition_point(|b| *b < at);
[i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|i| slots.get(i)).copied().min_by_key(|b| b.abs_diff(at)).unwrap_or(0)
}
/// Where Shuffle mode lands material dropped at `at` on `track` (see [`shuffle_slots`]).
pub fn shuffle_slot(s: &Session, track: TrackId, at: Samples, moving: &[ClipId]) -> Samples {
nearest_slot(&shuffle_slots(s, track, moving), at)
}
/// Lift the movable `moving` clips off a track the Shuffle way (see [`lift_plan`]); with
/// Automation Follows Edit their automation leaves with them. Returns the clips, each with that
/// automation timed from its start.
fn lift_all(s: &mut Session, track: TrackId, moving: &HashSet<ClipId>) -> Vec<(Clip, Vec<(AutoParam, AutomationPoint)>)> {
let Some(clips) = s.track(track).map(|t| spans(t.clips())) else { return Vec::new() };
let (lifts, staying) = lift_plan(clips, moving);
let mut points: HashMap<ClipId, Vec<(AutoParam, AutomationPoint)>> = HashMap::new();
for l in &lifts {
points.insert(l.id, take_automation(s, track, l.from, l.from.saturating_add(l.shift), l.start));
follow_automation(s, track, l.from.saturating_add(l.shift), (l.by, l.lock));
}
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() };
let starts: HashMap<ClipId, Samples> = staying.iter().map(|c| (c.id, c.start)).collect();
let (out, kept): (Vec<Clip>, Vec<Clip>) = std::mem::take(&mut pl.clips).into_iter().partition(|c| points.contains_key(&c.id));
pl.clips = kept;
for c in &mut pl.clips {
if let Some(start) = starts.get(&c.id) {
c.start = *start;
}
}
pl.sort();
out.into_iter()
.map(|c| {
let p = points.remove(&c.id).unwrap_or_default();
(c, p)
})
.collect()
}
/// The movable clips `ids` grouped by the track they land on (per `dest`): each destination and
/// the start of its earliest clip, which Shuffle lands on a slot; the others keep their distance.
pub fn shuffle_groups(s: &Session, ids: &[ClipId], dest: &[(TrackId, TrackId)]) -> Vec<(TrackId, Samples)> {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let mut groups: Vec<(TrackId, Samples)> = Vec::new();
for t in &s.tracks {
for c in t.clips().iter().filter(|c| ids.contains(&c.id) && !locked(c)) {
let d = lands(dest, t.id);
match groups.iter_mut().find(|g| g.0 == d) {
Some(g) => g.1 = g.1.min(c.start),
None => groups.push((d, c.start)),
}
}
}
groups
}
/// Move clips in Shuffle mode: each leaves its track (later clips close the gap, see
/// [`lift_plan`]), and the clips bound for each track land together, the earliest on the
/// [`shuffle_slot`] nearest `delta` away, pushing later clips right. With Automation Follows Edit
/// their automation travels with them. Refused, with the reason, when a locked clip or a
/// crossfade leaves no room.
pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> Result<usize, &'static str> {
let groups: Vec<(TrackId, Samples, Samples)> =
shuffle_groups(s, ids, dest).into_iter().map(|(d, first)| (d, first, shuffle_slot(s, d, first.saturating_add(delta), ids))).collect();
let moving: HashSet<ClipId> = ids.iter().copied().collect();
let sources: Vec<TrackId> = s.tracks.iter().filter(|t| t.clips().iter().any(|c| moving.contains(&c.id) && !locked(c))).map(|t| t.id).collect();
let mut lifted = Vec::new();
for t in sources {
lifted.extend(lift_all(s, t, &moving).into_iter().map(|(c, points)| (lands(dest, t), c, points)));
}
for (d, first, at) in groups {
let block: Vec<&(TrackId, Clip, Vec<(AutoParam, AutomationPoint)>)> = lifted.iter().filter(|l| l.0 == d).collect();
let len = block.iter().map(|l| l.1.end()).max().unwrap_or(first).saturating_sub(first);
make_room(s, d, at, len)?;
let mut placed = Vec::new();
for (_, c, points) in block {
let mut c = c.clone();
c.start = at.saturating_add(c.start.saturating_sub(first));
put_automation(s, d, c.start, points);
placed.push(c);
}
if let Some(pl) = s.track_mut(d).and_then(|t| t.playlist_mut()) {
pl.clips.extend(placed);
pl.sort();
}
}
Ok(lifted.len())
}
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
/// grid steps, keeping its offset from the grid.
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
let anchor = clip.start.saturating_add(sync);
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
}
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
/// Shuffle mode the start stays put (the material slides under it) and later clips follow the
/// change, up to a locked clip; otherwise a trim stops at the neighbouring clips.
pub fn trim_clip(s: &mut Session, id: ClipId, start: Option<Samples>, end: Option<Samples>, shuffle: bool) -> Option<Clip> {
let (track, old) = s.find_clip(id).filter(|(_, c)| !c.edit_locked).map(|(t, c)| (t, c.clone()))?;
let (mut lo, mut hi) = (Samples::MIN, Samples::MAX);
if shuffle {
let room = shuffle_room(s, track, old.end()).max(0);
lo = old.start.saturating_sub(room);
hi = old.end().saturating_add(room);
} else {
let others = s.track(track).map(|t| t.clips()).unwrap_or_default().iter().filter(|c| c.id != id);
if let Some(prev) = others.clone().filter(|c| c.start < old.start).map(Clip::end).max() {
lo = lo.max(prev.min(old.start));
}
if let Some(next) = others.filter(|c| c.start > old.start).map(|c| c.start).min() {
hi = hi.min(next.max(old.end()));
}
lo = lo.max(0);
}
let file = |c: &Clip| {
let start = c.start.saturating_sub(c.source_offset());
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end())))
};
let t = TimeCtx::of(s);
let mut c = old.clone();
if let Some(at) = start {
c.trim_start_to(at.max(file(&c).map_or(lo, |(f, _)| lo.max(f))));
rebase_midi_start(&mut c, old.start, t);
if shuffle {
c.start = old.start;
}
}
if let Some(at) = end {
c.trim_end_to(at.min(file(&c).map_or(hi, |(_, f)| hi.min(f))));
}
let pl = s.track_mut(track).and_then(|t| t.playlist_mut())?;
*pl.clip_mut(id)? = c.clone();
pl.sort();
if shuffle {
let moved = ripple(s, track, old.start.saturating_add(1), c.end().saturating_sub(old.end()));
follow_automation(s, track, old.end(), moved);
}
Some(c)
}
/// Length of the file or movie under a clip; None for MIDI, which has no end.
fn material_len(s: &Session, c: &Clip) -> Option<Samples> {
match c.content {
ClipContent::Audio { source, .. } => s.source(source).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)),
ClipContent::Video { source, .. } => s.video(source).map(|v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))),
ClipContent::Midi { .. } => None,
}
}
/// Create fades from a selection (the incumbent's Edit › Fades › Create semantics):
/// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out.
pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize {
@ -312,23 +721,7 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
}
let handles: Vec<(ClipId, Samples)> = {
let Some(tr) = s.track(track) else { return 0 };
tr.clips()
.iter()
.map(|c| {
let avail = match c.content {
ClipContent::Audio { source, offset } => {
let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX));
total.saturating_sub(offset).saturating_sub(c.length)
}
ClipContent::Video { source, offset } => {
let total = s.video(source).map_or(0, |v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7)));
total.saturating_sub(offset).saturating_sub(c.length)
}
ClipContent::Midi { .. } => 0,
};
(c.id, avail)
})
.collect()
tr.clips().iter().map(|c| (c.id, material_len(s, c).map_or(0, |n| n.saturating_sub(c.source_offset()).saturating_sub(c.length)))).collect()
};
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
pl.sort();
@ -461,7 +854,7 @@ mod tests {
let copied = copy_range(&s, t, Range::new(1500, 2500));
assert_eq!(copied.len(), 1);
assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500));
paste(&mut s, t, 4000, &copied, 1000, false);
paste(&mut s, t, 4000, &copied, 1000, false).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]);
}
@ -469,7 +862,7 @@ mod tests {
fn paste_shuffle_ripples() {
let (mut s, t) = session_with_clip();
let copied = copy_range(&s, t, Range::new(1000, 2000));
paste(&mut s, t, 1000, &copied, 1000, true);
paste(&mut s, t, 1000, &copied, 1000, true).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]);
}
@ -501,13 +894,109 @@ mod tests {
assert_eq!((c[0].end(), c[1].start), (3000, 3000));
}
fn three_clips() -> (Session, TrackId, [ClipId; 3]) {
let (mut s, t) = session_with_clip();
let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()];
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips = vec![
Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000),
Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000),
Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000),
];
}
(s, t, ids)
}
#[test]
fn shuffle_moves_reorder_clips_and_keep_the_silence() {
let (mut s, t, [_, b, _]) = three_clips();
assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1));
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a, ..]) = three_clips();
assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0);
assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000);
shuffle_clips(&mut s, &[a], 2500, &[]).unwrap();
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
}
#[test]
fn shuffling_onto_an_empty_track_starts_it_at_zero() {
let (mut s, t, [_, _, c]) = three_clips();
let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap();
assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]);
}
#[test]
fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() {
let (mut s, t, [a, b, c]) = three_clips();
trim_clip(&mut s, b, None, Some(2500), true);
trim_clip(&mut s, b, Some(1500), None, true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]);
let (mut s, t, _) = three_clips();
trim_clip(&mut s, a, None, Some(2500), false);
trim_clip(&mut s, c, Some(2000), None, false);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]);
trim_clip(&mut s, c, None, Some(99_999), false);
assert_eq!(clips(&s, t)[2], (3000, 9000, 1000));
}
#[test]
fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() {
let (mut s, t, [a, b, c]) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
clear_range(&mut s, t, Range::new(1000, 3000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a2, ..]) = three_clips();
s.find_clip_mut(c).unwrap().edit_locked = true;
assert_eq!(shuffle_room(&s, t, 0), 2000);
assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM));
assert_eq!(make_room(&mut s, t, 0, 2000), Ok(()));
assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1));
assert_eq!(clips(&s, t)[2], (5000, 1000, 3000));
let (mut s, t, _) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
trim_clip(&mut s, b, None, Some(9000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000);
}
#[test]
fn multi_track_moves_keep_the_tracks_apart() {
let (mut s, t1, [a, ..]) = three_clips();
let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let d = s.new_clip_id();
s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500));
let dest = destinations(&s, &[a, d], t1, t2);
assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)]));
assert_eq!(destinations(&s, &[a, d], t1, m), None);
assert_eq!(destinations(&s, &[a], t1, m), None);
assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2);
assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]);
assert_eq!(clips(&s, t3), vec![(0, 500, 0)]);
}
#[test]
fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
}
#[test]
fn moves_overwrite_and_respect_locks() {
let (mut s, t) = session_with_clip();
let id = s.track(t).unwrap().clips()[0].id;
assert_eq!(move_clips(&mut s, &[id], 500, None), 1);
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1);
assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]);
s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, None), 0);
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
}
}

View File

@ -98,6 +98,15 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
id
}
/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled.
fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
if file_rate == 0 || file_rate == session_rate {
return stamp;
}
let (from, to) = (u128::from(file_rate), u128::from(session_rate));
u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX)
}
/// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
@ -109,10 +118,12 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
return Err(EngineError::Io(format!("{name}: no audio")));
}
let channels = buf.num_channels();
let file_rate = info.sample_rate;
let s = e.session_mut();
let session_rate = s.sample_rate.hz();
let src = add_source(s, name, buf, path, info.format);
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
sx.time_reference = bwf.time_reference;
sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
}
let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
let track = match target {

View File

@ -90,6 +90,18 @@ pub struct Clipboard {
pub clip_effects: Vec<(String, f64)>,
/// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers.
pub markers: Vec<soundcraft_model::MemoryLocation>,
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
pub grid_offset: Samples,
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
pub automation_only: bool,
}
impl Clipboard {
/// An empty clipboard for material copied from `range`.
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
}
}
#[derive(Debug, Clone)]
@ -233,6 +245,7 @@ impl Engine {
pub fn undo(&mut self) -> bool {
let Some(h) = self.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.redo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
@ -242,12 +255,27 @@ impl Engine {
pub fn redo(&mut self) -> bool {
let Some(h) = self.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.undo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
true
}
/// Undo and Redo never change the Edit mode or its settings (Shuffle Lock above all).
fn keep_edit_mode(&mut self, from: &Session) {
use edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID};
let flags = [SNAP_TO_GRID, SHUFFLE_LOCK, RELATIVE_GRID];
let ed = &self.doc.edit;
if ed.edit_mode != from.edit.edit_mode || flags.iter().any(|f| ed.flag(f) != from.edit.flag(f)) {
let ed = &mut Arc::make_mut(&mut self.doc).edit;
ed.edit_mode = from.edit.edit_mode;
for f in flags {
ed.set_flag(f, from.edit.flag(f));
}
}
}
/// Record an undo point manually (for interactive gestures committed by the UI).
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
if Arc::ptr_eq(&before, &self.doc) {

View File

@ -0,0 +1,415 @@
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_engine::edit::SHUFFLE_LOCK;
use soundcraft_model::{AutoParam, Clip, ClipId, EditMode, Source, SourceId, TrackId};
fn source(id: u64, frames: u64) -> Source {
Source {
id: SourceId(id),
name: "src".into(),
path: "src.wav".into(),
channels: 1,
frames,
sample_rate: 48_000,
format: soundcraft_audio_io::FileFormat::Wav,
time_reference: 0,
unsaved: false,
}
}
fn new_track(e: &mut Engine) -> TrackId {
TrackId(e.execute("track.new", &json!({"count": 1})).unwrap()["tracks"][0].as_u64().unwrap())
}
fn engine(mode: &str) -> (Engine, TrackId, [ClipId; 2]) {
let mut e = Engine::default();
let t = new_track(&mut e);
let s = e.session_mut();
s.sources.push(source(7, 10_000));
let ids = [s.new_clip_id(), s.new_clip_id()];
s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips =
vec![Clip::audio(ids[0], "a", SourceId(7), 0, 0, 1000), Clip::audio(ids[1], "b", SourceId(7), 1000, 2000, 1000)];
e.execute("edit.mode", &json!({"mode": mode})).unwrap();
(e, t, ids)
}
/// A track of clips (start, length, offset) on a long file, source 8.
fn long(mode: &str, spans: &[(i64, i64, i64)]) -> (Engine, TrackId, Vec<ClipId>) {
let mut e = Engine::default();
let t = new_track(&mut e);
let s = e.session_mut();
s.sources.push(source(8, 1_000_000));
let ids: Vec<ClipId> = spans.iter().map(|_| s.new_clip_id()).collect();
s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips =
spans.iter().zip(&ids).map(|(&(start, len, offset), id)| Clip::audio(*id, "c", SourceId(8), offset, start, len)).collect();
e.execute("edit.mode", &json!({"mode": mode})).unwrap();
(e, t, ids)
}
fn clips(e: &Engine, t: TrackId) -> Vec<(i64, i64)> {
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect()
}
/// Each clip's id, length and file offset, in id order: what a move must never change.
fn material(e: &Engine, t: TrackId) -> Vec<(u64, i64, i64)> {
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.id.0, c.length, c.source_offset())).collect();
v.sort_unstable();
v
}
fn start(e: &Engine, id: ClipId) -> i64 {
e.session().find_clip(id).unwrap().1.start
}
fn points(e: &Engine, t: TrackId) -> Vec<i64> {
e.session().track(t).unwrap().lane(&AutoParam::Volume).map(|l| l.points.iter().map(|p| p.at).collect()).unwrap_or_default()
}
fn set_points(e: &mut Engine, t: TrackId, at: &[i64]) {
for a in at {
run(e, "automation.set_point", json!({"track": t.0, "param": "volume", "at": a, "value": -6.0}));
}
}
fn run(e: &mut Engine, id: &str, p: Value) {
e.execute(id, &p).unwrap();
}
#[test]
fn insert_silence_pushes_in_shuffle_and_clears_otherwise() {
for (mode, want) in [("shuffle", vec![(0, 500), (700, 500), (2200, 1000)]), ("slip", vec![(0, 500), (700, 300), (2000, 1000)])] {
let (mut e, t, _) = engine(mode);
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 500, "end": 700}));
assert_eq!(clips(&e, t), want, "{mode}");
}
}
#[test]
fn duplicate_and_repeat_make_room_in_shuffle() {
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (3000, 1000)]);
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.repeat", json!({"tracks": [t.0], "start": 0, "end": 1000, "count": 2}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000), (4000, 1000)]);
let (mut e, t, _) = engine("slip");
run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]);
}
#[test]
fn shuffle_paste_replaces_the_selection() {
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.copy", json!({"tracks": [t.0], "start": 0, "end": 1000}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 2000, "end": 2500}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 1000), (3000, 500)]);
}
#[test]
fn clip_list_drops_land_on_a_clip_boundary_in_shuffle() {
let (mut e, t, [_, b]) = engine("shuffle");
run(&mut e, "clip.place_source", json!({"source": 7, "track": t.0, "at": 1900}));
assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 10_000), (12_000, 1000)]);
e.session_mut().find_clip_mut(b).unwrap().time_locked = true;
assert!(e.execute("clip.place_source", &json!({"source": 7, "track": t.0, "at": 0})).is_err());
assert_eq!(clips(&e, t).len(), 3);
}
#[test]
fn grid_moves_to_a_position_snap_or_keep_their_offset() {
let (mut e, t, [_, b]) = engine("grid");
let s = e.session();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": step + 1000}));
assert_eq!(clips(&e, t)[1].0, step);
run(&mut e, "edit.mode", json!({"mode": "relative_grid"}));
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 2 * step + 2000}));
assert_eq!(clips(&e, t)[1].0, 2 * step);
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "by": 200}));
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 3 * step}));
assert_eq!(clips(&e, t)[1].0, 3 * step + 200);
}
#[test]
fn clips_cannot_move_onto_tracks_without_a_playlist() {
let (mut e, t, [a, _]) = engine("slip");
let aux = e.execute("track.new", &json!({"kind": "aux"})).unwrap()["tracks"][0].clone();
assert!(e.execute("edit.move_clips", &json!({"clips": [a.0], "by": 0, "track": aux})).is_err());
assert_eq!(clips(&e, t).len(), 2);
}
#[test]
fn trim_clip_needs_an_edge_and_an_unlocked_clip() {
let (mut e, t, [a, b]) = engine("slip");
assert!(e.execute("edit.trim_clip", &json!({"clip": a.0})).is_err());
e.session_mut().find_clip_mut(b).unwrap().edit_locked = true;
assert!(e.execute("edit.trim_clip", &json!({"clip": b.0, "end": 2500})).is_err());
run(&mut e, "edit.trim_clip", json!({"clip": a.0, "end": 5000}));
assert_eq!(clips(&e, t), vec![(0, 2000), (2000, 1000)]);
}
#[test]
fn shuffle_lock_keeps_shuffle_mode_out() {
let (mut e, _, _) = engine("slip");
run(&mut e, "edit.shuffle_lock", json!({}));
assert!(e.execute("edit.mode", &json!({"mode": "shuffle"})).is_err());
run(&mut e, "edit.mode", json!({"mode": "grid"}));
run(&mut e, "edit.shuffle_lock", json!({"value": false}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
assert!(e.execute("edit.shuffle_lock", &json!({})).is_err());
}
#[test]
fn snap_to_grid_rides_along_with_the_other_modes() {
let (mut e, _, _) = engine("slip");
let snap = |e: &Engine| e.session().edit.flag(soundcraft_engine::edit::SNAP_TO_GRID);
run(&mut e, "edit.snap_to_grid", json!({}));
assert!(snap(&e));
run(&mut e, "edit.mode", json!({"mode": "spot", "snap_to_grid": true}));
assert!(snap(&e));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
assert!(!snap(&e));
run(&mut e, "edit.mode", json!({"mode": "grid", "snap_to_grid": true}));
assert!(!snap(&e));
assert!(e.execute("edit.snap_to_grid", &json!({})).is_err());
}
#[test]
fn shuffle_edits_that_would_push_a_locked_clip_are_refused() {
let (mut e, t, [_, b]) = engine("shuffle");
e.session_mut().find_clip_mut(b).unwrap().time_locked = true;
for id in ["edit.insert_silence", "edit.duplicate"] {
let err = e.execute(id, &json!({"tracks": [t.0], "start": 0, "end": 1500})).unwrap_err().to_string();
assert!(err.contains("locked clip"), "{id}: {err}");
}
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(1000, 1000), (2000, 1000)]);
run(&mut e, "event.cut_time", json!({"start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]);
}
#[test]
fn clips_dragged_from_several_tracks_keep_their_tracks_apart() {
let (mut e, t, [a, _]) = engine("slip");
let new = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let [t2, t3] = [0, 1].map(|i| TrackId(new[i].as_u64().unwrap()));
let s = e.session_mut();
let d = s.new_clip_id();
s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(7), 0, 5000, 500));
run(&mut e, "edit.move_clips", json!({"clips": [a.0, d.0], "by": 0, "track": t2.0}));
assert_eq!(clips(&e, t), vec![(2000, 1000)]);
assert_eq!(clips(&e, t2), vec![(0, 1000)]);
assert_eq!(clips(&e, t3), vec![(5000, 500)]);
assert!(e.execute("edit.move_clips", &json!({"clips": [a.0, d.0], "by": 0, "track": t3.0})).is_err());
}
#[test]
fn relative_grid_pastes_keep_the_offset_from_the_grid() {
let (mut e, t, [_, b]) = engine("relative_grid");
let s = e.session();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
run(&mut e, "edit.select", json!({"clips": [b.0]}));
run(&mut e, "edit.copy", json!({}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 3 * step}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t).last(), Some(&(3 * step + 2000, 1000)));
run(&mut e, "edit.mode", json!({"mode": "slip"}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 5 * step}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
}
#[test]
fn shuffle_moves_keep_crossfade_partners_whole() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
let whole = material(&e, t);
let [a, b, c] = [ids[0], ids[1], ids[2]];
run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 5000}));
assert_eq!(material(&e, t), whole);
assert_eq!([start(&e, b), start(&e, c), start(&e, a)], [0, 1100, 2100]);
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
for by in [2500, -2500] {
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": by}));
assert_eq!(material(&e, t), whole, "{by}");
assert_eq!(start(&e, ids[1]) - start(&e, ids[0]), 900, "{by}");
}
assert_eq!([start(&e, ids[0]), start(&e, ids[2])], [0, 2000]);
run(&mut e, "edit.mode", json!({"mode": "slip"}));
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": 5000}));
assert_eq!(material(&e, t), whole);
}
#[test]
fn pasting_copied_automation_in_shuffle_leaves_the_clips_alone() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]);
set_points(&mut e, t, &[200]);
run(&mut e, "edit.copy_all_automation", json!({"tracks": [t.0], "start": 0, "end": 500}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
run(&mut e, "edit.select", json!({"clips": [ids[1].0]}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]);
}
#[test]
fn automation_follows_shuffle_moves_trims_and_drops() {
let setup = || {
let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]);
set_points(&mut e, t, &[500, 1500, 2500]);
(e, t, ids)
};
let (mut e, t, ids) = setup();
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 3000}));
assert_eq!(points(&e, t), [500, 1500, 2500]);
assert_eq!([start(&e, ids[1]), start(&e, ids[2]), start(&e, ids[0])], [0, 1000, 2000]);
let (mut e, t, ids) = setup();
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 500}));
assert_eq!(points(&e, t), [1000, 2000]);
let (mut e, t, _) = setup();
run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 1000}));
assert_eq!(points(&e, t), [500, 1_001_500, 1_002_500]);
}
#[test]
fn shuffle_automation_stops_at_locked_clips_and_needs_automation_follows_edit() {
let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (3000, 1000, 0)]);
e.session_mut().find_clip_mut(ids[2]).unwrap().time_locked = true;
set_points(&mut e, t, &[3500]);
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 500}));
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500}));
assert_eq!((start(&e, ids[2]), points(&e, t)), (3000, vec![3500]));
let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]);
set_points(&mut e, t, &[300, 1500, 5000]);
e.session_mut().edit.automation_follows_edit = false;
run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000}));
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 100}));
assert_eq!(points(&e, t), [300, 1500, 5000]);
}
#[test]
fn shuffle_clear_moves_automation_with_the_clips() {
let (mut e, t, _) = long("shuffle", &[(0, 10_000, 0), (20_000, 10_000, 0)]);
set_points(&mut e, t, &[0, 9_000, 20_000, 29_000]);
run(&mut e, "edit.clear", json!({"track": t.0, "start": 4_000, "end": 6_000}));
assert_eq!(clips(&e, t), [(0, 4_000), (4_000, 4_000), (18_000, 10_000)]);
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
}
#[test]
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
let (mut e, t, [a, _]) = engine("relative_grid");
run(&mut e, "event.tempo", json!({"bpm": 90, "at": 0}));
run(&mut e, "edit.select", json!({"clips": [a.0]}));
run(&mut e, "edit.copy", json!({}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 128_000}));
run(&mut e, "edit.paste", json!({}));
assert!(clips(&e, t).contains(&(128_000, 1000)), "{:?}", clips(&e, t));
run(&mut e, "edit.paste", json!({"tracks": [t.0], "at": 50_000}));
assert!(clips(&e, t).contains(&(50_000, 1000)), "{:?}", clips(&e, t));
}
#[test]
fn start_trims_keep_the_sync_point_on_its_audio() {
let (mut e, t, ids) = long("slip", &[(0, 10_000, 0)]);
let sync = |e: &Engine| e.session().find_clip(ids[0]).unwrap().1.sync_point;
e.session_mut().find_clip_mut(ids[0]).unwrap().sync_point = 5000;
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 2000}));
assert_eq!(sync(&e), 3000);
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 8000}));
assert_eq!(sync(&e), 0);
run(&mut e, "edit.mode", json!({"mode": "grid"}));
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "to": 30_000}));
assert_eq!(clips(&e, t), vec![(30_000, 2000)]);
}
#[test]
fn imported_time_stamps_count_at_the_session_rate() {
use soundcraft_audio_io::{AudioBuffer, BwfInfo, EncodeOptions, FileFormat};
let mut e = Engine::default();
let bwf = BwfInfo { time_reference: 1_587_600_000, ..BwfInfo::default() };
let opts = EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() };
let bytes = soundcraft_audio_io::encode(&AudioBuffer::new(44_100, 1, 441), &opts).unwrap();
soundcraft_engine::io::import_audio_bytes(&mut e, "stamped.wav", &bytes, None, None, 0).unwrap();
assert_eq!(e.session().sources[0].time_reference, 1_728_000_000);
}
#[test]
fn shuffle_trims_stop_at_a_locked_crossfade_and_stay_inside_the_file() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 500), (1000, 1000, 5000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 950, "end": 1050}));
e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true;
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
let before = material(&e, t);
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 1500}));
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 0}));
assert_eq!(material(&e, t), before);
let (mut e, _, [_, b]) = engine("shuffle");
run(&mut e, "edit.trim_clip", json!({"clip": b.0, "start": 2500, "end": 20_000}));
let c = e.session().find_clip(b).unwrap().1;
assert_eq!((c.source_offset(), c.source_offset() + c.length), (1500, 10_000));
}
#[test]
fn duplicate_and_extend_makes_room_in_shuffle() {
let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]);
run(&mut e, "edit.duplicate_extend", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]);
let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]);
e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true;
assert!(e.execute("edit.duplicate_extend", &json!({"tracks": [t.0], "start": 0, "end": 1000})).is_err());
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]);
}
#[test]
fn undo_leaves_the_edit_mode_and_shuffle_lock_alone() {
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500}));
run(&mut e, "edit.mode", json!({"mode": "slip"}));
run(&mut e, "edit.shuffle_lock", json!({}));
for step in [Engine::undo, Engine::redo] {
assert!(step(&mut e));
assert_eq!((e.session().edit.edit_mode, e.session().edit.flag(SHUFFLE_LOCK)), (EditMode::Slip, true));
}
}
#[test]
fn shuffle_cuts_reaching_before_the_session_start_keep_the_spacing() {
let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]);
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": -5000, "end": 500}));
assert_eq!(clips(&e, t), vec![(0, 500), (500, 1000), (1500, 1000)]);
}
#[test]
fn shuffle_never_pulls_a_crossfade_apart() {
let (mut e, t, ids) = long("slip", &[(0, 9000, 0), (9000, 11_000, 100_000), (20_000, 5000, 200_000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 8000, "end": 10_000}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
let err = e.execute("edit.duplicate", &json!({"tracks": [t.0], "start": 5000, "end": 9000})).unwrap_err().to_string();
assert!(err.contains("crossfade"), "{err}");
run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 7900}));
assert_eq!(clips(&e, t)[0], (0, 1_000_000));
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000_000, 1_008_000]);
}
#[test]
fn clips_move_to_and_from_hidden_tracks() {
let (mut e, t, [a, _]) = engine("slip");
let hidden = new_track(&mut e);
run(&mut e, "track.hide", json!({"tracks": [hidden.0], "hidden": true}));
run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": hidden.0}));
assert_eq!(clips(&e, hidden), vec![(0, 1000)]);
run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": t.0}));
assert_eq!(clips(&e, t).len(), 2);
}
#[test]
fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
let (mut e, t, _) = engine("shuffle");
assert!(e.execute("edit.shuffle_lock", &json!({"value": true, "tracks": [t.0]})).is_err());
run(&mut e, "edit.mode", json!({"mode": "grid"}));
assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
}

View File

@ -54,6 +54,8 @@ const CMDS: &[&str] = &[
"event.beat_detective",
"automation.thin",
"clip.remove_warp",
"clip.time_lock",
"clip.edit_lock",
];
fn params(i: usize, a: i64, b: i64, track: &str) -> Value {
@ -119,7 +121,7 @@ fn check_invariants(e: &Engine) -> Result<(), String> {
proptest! {
#![proptest_config(ProptestConfig { cases: 48, .. ProptestConfig::default() })]
#[test]
fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..48, -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) {
fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..CMDS.len(), -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) {
let mut e = soundcraft_engine::demo::demo_engine();
let original = serde_json::to_value(e.session()).unwrap();
let names = ["Kick", "Snare", "Bass", "Pad", "Keys"];

View File

@ -219,6 +219,10 @@ impl Clip {
// MIDI note ticks are re-anchored by the engine, which knows the tempo map.
self.length -= delta;
self.start = new_start;
if self.sync_point != 0 {
self.sync_point = self.sync_point.saturating_sub(delta);
}
self.drop_stray_sync();
self.clamp_fades();
// Keep the gain at the cut when earlier breakpoints are dropped.
if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) {
@ -232,6 +236,7 @@ impl Clip {
pub fn trim_end_to(&mut self, new_end: Samples) {
let new_end = new_end.max(self.start + 1);
self.length = new_end - self.start;
self.drop_stray_sync();
self.clamp_fades();
}
@ -240,6 +245,13 @@ impl Clip {
self.fade_in.len = self.fade_in.len.clamp(0, self.length);
self.fade_out.len = self.fade_out.len.clamp(0, self.length);
}
/// A sync point (0 means none) that a trim left outside the clip no longer marks anything.
fn drop_stray_sync(&mut self) {
if !(0..self.length).contains(&self.sync_point) {
self.sync_point = 0;
}
}
}
fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 {

View File

@ -142,6 +142,18 @@ mod tests {
(SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30)
}
#[test]
fn huge_typed_positions_parse_without_overflowing() {
let (sr, map, rate) = ctx();
for (text, fmt) in [
("999999999999999999:0", TimeFormat::Timecode),
("100000000000000:00:00:00", TimeFormat::Timecode),
("-9223372036854775808", TimeFormat::BarsBeats),
] {
let _ = parse_position(text, fmt, sr, &map, rate, 0, 0);
}
}
#[test]
fn formats_like_pro_tools_counters() {
let (sr, map, rate) = ctx();

View File

@ -141,7 +141,13 @@ impl GridValue {
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
match *self {
GridValue::Note { value, dotted, triplet } => {
let t = map.samples_to_ticks_f(pos, sr).floor() as i64;
// Float ticks can land a hair either side of a line's own tick.
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
if map.tick_to_samples(t, sr) > pos {
t = t.saturating_sub(1);
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
t = t.saturating_add(1);
}
// Grid lines restart at each bar line so odd meters stay aligned.
let bb = map.bar_beat_at_tick(t);
let bar_tick = map.bar_start_tick(bb.bar);
@ -151,7 +157,7 @@ impl GridValue {
}
_ => {
let step = self.step_samples(pos, sr, map, rate);
pos.div_euclid(step) * step
pos.div_euclid(step).saturating_mul(step)
}
}
}
@ -185,6 +191,19 @@ impl GridValue {
mod tests {
use super::*;
#[test]
fn grid_lines_are_their_own_line_at_odd_tempos() {
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] {
let map = TempoMap::new(bpm, 4, 4).unwrap();
let mut line = 0;
for _ in 0..64 {
assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm");
line = g.next_line(line, sr, &map, rate);
}
}
}
#[test]
fn sixteenth_grid_at_120() {
let g = GridValue::default();

View File

@ -20,6 +20,7 @@ pub enum Dialog {
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
Spot(crate::spot::Spot),
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
@ -48,6 +49,7 @@ impl Dialogs {
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
Dialog::TempoChange { .. } => "tempo_change",
Dialog::Spot(_) => "spot",
Dialog::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "group",
@ -278,6 +280,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::ClearClips { .. } => "Clear",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
Dialog::Spot(_) => "Spot",
Dialog::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create Group",
@ -478,6 +481,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
action = Some(tempo_change_action(*at, *bpm));
}
}
Dialog::Spot(d) => {
crate::spot::fields(ui, app.engine.session(), d);
if buttons(ui, "OK", enter) {
keep = false;
action = d.command(app.engine.session());
}
}
Dialog::Fades { shape } => {
for s in soundcraft_model::FadeShape::ALL {
ui.radio_value(shape, s.label().to_lowercase(), s.label());

View File

@ -1,12 +1,17 @@
//! The Edit window: toolbar, rulers, track headers and playlists.
use crate::dialogs::Dialog;
use crate::spot::Spot;
use crate::theme::{Tokens, bold, clip_colors, regular, rgb};
use crate::widgets::{groove, level_pos, meter_pos, raised_key, shade};
use crate::{Gesture, SoundApp, toolbar};
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_engine::edit as ops;
use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, EditMode, Session, Tool, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position};
use std::collections::HashSet;
use std::sync::Arc;
pub const HEADER_W: f32 = 136.0;
pub const COLUMN_W: f32 = 112.0;
@ -281,7 +286,7 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
}
fn in_edit_window(s: &Session, t: &Track) -> bool {
!t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open)
ops::shown(s, t)
}
/// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one.
@ -1587,7 +1592,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
} else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") {
clip.source().and_then(|src| s.source(src)).map(|src| {
format_position(
i64::try_from(src.time_reference).unwrap_or(0) + clip.source_offset(),
i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start),
TimeFormat::Timecode,
s.sample_rate,
&s.tempo,
@ -1694,7 +1699,8 @@ fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) {
// ---- interaction -----------------------------------------------------------------------------
fn snap(s: &Session, at: Samples) -> Samples {
if s.edit.edit_mode.is_grid() { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID);
if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
}
fn clip_at(track: &Track, at: Samples) -> Option<&Clip> {
@ -1748,8 +1754,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
&& let Some(p) = ui.ctx().pointer_latest_pos()
{
let s = app.engine.session();
let at = snap(s, sample_at(s, tl, p.x).max(0));
let _ = app.run("clip.place_source", json!({"source": src.0, "track": track.id.0, "at": at}));
let at = drag_snap(s, sample_at(s, tl, p.x).max(0), ui.input(|i| i.modifiers.command));
let spot = Spot::source(s, soundcraft_model::SourceId(src.0), track.id, at);
spot_or_run(app, spot);
}
if resp.dnd_hover_payload::<crate::DragSource>().is_some() {
ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside);
@ -1893,6 +1900,15 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
let mut ids = if mods.shift { s.edit.selected_clips.iter().map(|x| x.0).collect::<Vec<_>>() } else { Vec::new() };
ids.push(c.id.0);
let _ = app.run("edit.select", json!({"clips": ids}));
if s.edit.edit_mode == EditMode::Spot {
app.dialogs.open = Spot::clips(&s, vec![c.id], None, 0).map(Dialog::Spot);
}
}
}
Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => {
if let Some(c) = &hit {
let start = at - c.start < c.end() - at;
app.dialogs.open = Spot::trim(&s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot);
}
}
Tool::Zoom => {
@ -1970,22 +1986,14 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
*tracks = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| TrackId(*i)).collect();
}
}
Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => {
let raw = at - *grab_at;
*delta = if s.edit.edit_mode.is_grid() {
let base = s.track(*from_track).and_then(|t| clip_at(t, *grab_at)).map_or(*grab_at, |c| c.start);
snap(&s, base + raw) - base
} else {
raw
};
if s.edit.edit_mode == soundcraft_model::EditMode::Spot {
*delta = 0;
}
Some(Gesture::MoveClips { clips, grab_at, delta, from_track, to_track: tt }) => {
if let Some(t2) = to_track {
*tt = t2;
}
let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at);
*delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command));
}
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at),
Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)),
Some(Gesture::Fade { to, .. }) => *to = at,
Some(Gesture::Scrub { last }) => {
*last = at;
@ -2033,16 +2041,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option<Pos2>) {
match app.gesture.take() {
Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => {
let ids: Vec<u64> = clips.iter().map(|c| c.0).collect();
let mut params = json!({"clips": ids, "by": delta});
if to_track != from_track {
params["track"] = json!(to_track.0);
}
if delta != 0 || to_track != from_track {
let _ = app.run("edit.move_clips", params);
}
}
Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => drop_clips(app, clips, delta, from_track, to_track),
Some(Gesture::Fade { clip, track, fade_in, to }) => {
if let Some((_, c)) = s.find_clip(clip) {
// Not clamp: it panics on a zero-length clip.
@ -2050,8 +2049,8 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option<Pos2>) {
let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"}));
}
}
Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true),
Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false),
Some(Gesture::TrimStart { clip, to, .. }) => trim_clip(app, clip, to, true),
Some(Gesture::TrimEnd { clip, to, .. }) => trim_clip(app, clip, to, false),
Some(Gesture::Pencil { track, points }) => {
if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) {
let pid = automation_id(&param);
@ -2087,22 +2086,96 @@ fn automation_id(p: &AutoParam) -> String {
}
}
fn trim_clip(app: &mut SoundApp, clip: ClipId, track: TrackId, to: Samples, start: bool) {
let cmd = if start { "edit.trim_start_to_insertion" } else { "edit.trim_end_to_insertion" };
// Trims through the command layer: select the clip's track and position the insertion.
let sel_before = app.engine.session().edit.selection;
let tracks_before: Vec<u64> = app.engine.session().edit.selected_tracks.iter().map(|x| x.0).collect();
let c = app.engine.session().find_clip(clip).map(|(_, c)| c.clone());
if let Some(c) = c {
if start && to < c.start || !start && to > c.end() {
// Extending: use trim to fill selection with handles.
let r = if start { Range::new(to, c.end()) } else { Range::new(c.start, to) };
let _ = app.run("edit.trim_to_fill_selection", json!({"clips": [clip.0], "start": r.start, "end": r.end}));
} else {
let _ = app.run(cmd, json!({"tracks": [track.0], "at": to}));
}
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
}
/// What a clip drag works out once per target track rather than every frame: where the tracks go
/// (None when the clips can't land there), each destination's earliest clip and Shuffle slots,
/// the clips that move (track, start, length) and the clip Grid snaps.
struct DragPlan {
dest: Option<Vec<(TrackId, TrackId)>>,
groups: Vec<(TrackId, Samples, Vec<Samples>)>,
moving: Vec<(TrackId, Samples, Samples)>,
grabbed: Option<Clip>,
}
/// The [`DragPlan`] for `clips` grabbed at `grab_at` on track `from` and dragged onto `to`, kept
/// until the document, the Edit mode or the target changes.
fn drag_plan(ctx: &egui::Context, s: &Session, revision: u64, clips: &[ClipId], (from, to): (TrackId, TrackId), grab_at: Samples) -> Arc<DragPlan> {
type Key = (u64, EditMode, Vec<ClipId>, TrackId, TrackId, Samples);
let id = egui::Id::new("clip_drag_plan");
let key: Key = (revision, s.edit.edit_mode, clips.to_vec(), from, to, grab_at);
if let Some((k, plan)) = ctx.data(|d| d.get_temp::<(Key, Arc<DragPlan>)>(id))
&& k == key
{
return plan;
}
let dest = if to == from { Some(Vec::new()) } else { ops::destinations(s, clips, from, to) };
let groups = match &dest {
Some(d) if s.edit.edit_mode == EditMode::Shuffle => {
ops::shuffle_groups(s, clips, d).into_iter().map(|(t, first)| (t, first, ops::shuffle_slots(s, t, clips))).collect()
}
_ => Vec::new(),
};
let ids: HashSet<ClipId> = clips.iter().copied().collect();
let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c);
let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect();
let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default();
let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone());
let plan = Arc::new(DragPlan { dest, groups, moving, grabbed });
ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan))));
plan
}
/// How far a Grabber drag by `raw` from track `from` takes the clips in the current Edit mode.
/// Shuffle lands the earliest clip bound for the target track on a slot there, as
/// `edit.move_clips` does; Grid snaps the grabbed clip.
fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples {
let mode = s.edit.edit_mode;
if mode == EditMode::Shuffle {
let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from));
plan.groups
.iter()
.find(|g| g.0 == to)
.map_or(raw, |(_, first, slots)| ops::nearest_slot(slots, first.saturating_add(raw)).saturating_sub(*first))
} else if mode.is_grid() != flip
&& let Some(c) = &plan.grabbed
{
ops::grid_delta(s, c, raw, mode == EditMode::GridRelative)
} else {
raw
}
}
/// The dragged clips with one from the grabbed track first: `edit.move_clips` sends that track to
/// the target and the others along with it.
fn grabbed_first(s: &Session, clips: &[ClipId], from: TrackId) -> Vec<ClipId> {
let mut v = clips.to_vec();
v.sort_by_key(|c| s.find_clip(*c).is_none_or(|(t, _)| t != from));
v
}
fn drop_clips(app: &mut SoundApp, clips: Vec<ClipId>, delta: Samples, from: TrackId, to: TrackId) {
let s = app.engine.session();
let spot = Spot::clips(s, grabbed_first(s, &clips, from), (to != from).then_some(to), delta);
spot_or_run(app, spot);
}
fn trim_clip(app: &mut SoundApp, clip: ClipId, to: Samples, start: bool) {
let spot = Spot::trim(app.engine.session(), clip, start, to);
spot_or_run(app, spot);
}
/// Spot mode asks for exact positions first; the other modes make the edit straight away.
fn spot_or_run(app: &mut SoundApp, spot: Option<Spot>) {
let Some(spot) = spot else { return };
if app.engine.session().edit.edit_mode == EditMode::Spot {
app.dialogs.open = Some(Dialog::Spot(spot));
} else if let Some((id, params)) = spot.command(app.engine.session()) {
let _ = app.run(&id, params);
}
let _ = app.engine.execute("edit.select", &json!({"tracks": tracks_before, "start": sel_before.start, "end": sel_before.end}));
}
fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
@ -2240,12 +2313,20 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
scroll_to = follow_to;
}
// Ghost of a clip move.
if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture {
let row = app.edit_layout.rows.iter().find(|(i, _)| *i == to_track.0).map(|(_, r)| *r);
for c in clips {
if let (Some((_, clip)), Some(r)) = (s.find_clip(*c), row) {
let x0 = x_of(s, tl, clip.start + delta);
let x1 = x_of(s, tl, clip.end() + delta);
if let Some(Gesture::MoveClips { clips, delta, from_track, to_track, grab_at }) = &app.gesture
&& let plan = drag_plan(ui.ctx(), s, app.engine.revision, clips, (*from_track, *to_track), *grab_at)
&& let Some(dest) = &plan.dest
{
for &(t, start, len) in &plan.moving {
let d = ops::lands(dest, t);
let start = plan
.groups
.iter()
.find(|g| g.0 == d)
.map_or(start + delta, |(_, first, slots)| ops::nearest_slot(slots, first + delta) + (start - first));
if let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == d.0) {
let x0 = x_of(s, tl, start);
let x1 = x_of(s, tl, start + len);
painter.rect_stroke(
Rect::from_min_max(pos2(x0, r[1] + 1.0), pos2(x1, r[3] - 1.0)),
2.0,

View File

@ -137,6 +137,13 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c));
}
}
"lock" => {
painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c);
let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19);
let arc: Vec<Pos2> =
(0..=12).map(|i| std::f32::consts::PI * i as f32 / 12.0).map(|a| pos2(x - w * a.cos(), y - w * 1.4 * a.sin())).collect();
painter.add(Shape::line(arc, s));
}
"wave" => {
let n = 24;
let pts: Vec<Pos2> = (0..=n)

View File

@ -22,6 +22,7 @@ pub mod panels;
pub mod recovery;
pub mod score_editor;
pub mod shortcuts;
pub mod spot;
pub mod theme;
pub mod toolbar;
pub mod video_track;

View File

@ -133,13 +133,21 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
true
}
Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => {
let mode = match key {
Key::F1 => "shuffle",
Key::F2 => "slip",
Key::F3 => "spot",
_ => "grid",
edit_mode_key(app, key);
true
}
Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 if m.alt && !m.command && !m.ctrl && !m.shift => {
let f = match key {
Key::Num1 => Key::F1,
Key::Num2 => Key::F2,
Key::Num3 => Key::F3,
_ => Key::F4,
};
let _ = app.run("edit.mode", json!({"mode": mode}));
edit_mode_key(app, f);
true
}
Key::Backtick if plain => {
cycle_edit_mode(app);
true
}
Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => {
@ -233,6 +241,68 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
}
}
/// F1–F4 (and Alt+1–4) choose Shuffle, Slip, Spot and Grid; F4 in Grid switches between Absolute and
/// Relative unless Edit/Tool Mode Keyboard Lock is on. Holding F4 with F1–F3, in either order, adds Snap to Grid.
fn edit_mode_key(app: &mut SoundApp, pressed: Key) {
let ctx = app.docking_context.clone();
let held = |k: Key| ctx.as_ref().is_some_and(|c| c.input(|i| i.key_down(k)));
let (key, snap) = match [Key::F1, Key::F2, Key::F3].into_iter().find(|k| *k != pressed && held(*k)) {
Some(k) if pressed == Key::F4 => (k, true),
_ => (pressed, pressed != Key::F4 && held(Key::F4)),
};
let ed = &app.engine.session().edit;
let keep_grid = ed.edit_mode.is_grid() && ed.flag("options.edit_tool_mode_keyboard_lock");
// F4 pressed first in an F4+F1–F3 chord switched the Grid mode on its own; put that back.
let flipped = egui::Id::new("edit_mode_key_grid_before_f4");
let before =
(ed.edit_mode.is_grid() && !keep_grid).then(|| if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) { "relative_grid" } else { "grid" });
let restore = match &ctx {
Some(c) if pressed == Key::F4 => {
c.data_mut(|d| d.insert_temp(flipped, before));
None
}
Some(c) if snap => c.data_mut(|d| d.remove_temp::<Option<&'static str>>(flipped)).flatten(),
_ => None,
};
let mode = match key {
Key::F1 => "shuffle",
Key::F2 => "slip",
Key::F3 => "spot",
_ if keep_grid => return,
_ => grid_mode(app),
};
if let Some(grid) = restore {
let _ = app.run("edit.mode", json!({"mode": grid}));
}
let _ = app.run("edit.mode", json!({"mode": mode, "snap_to_grid": snap}));
}
/// What choosing Grid picks: in Grid the other Grid mode, from another mode the one used last.
pub(crate) fn grid_mode(app: &SoundApp) -> &'static str {
use soundcraft_model::EditMode;
let ed = &app.engine.session().edit;
match ed.edit_mode {
EditMode::Grid => "relative_grid",
EditMode::GridRelative => "grid",
_ if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) => "relative_grid",
_ => "grid",
}
}
/// ` steps through the Edit modes in F-key order, past a locked Shuffle.
fn cycle_edit_mode(app: &mut SoundApp) {
use soundcraft_model::EditMode;
let ed = &app.engine.session().edit;
let mode = match ed.edit_mode {
EditMode::Shuffle => "slip",
EditMode::Slip => "spot",
EditMode::Spot => grid_mode(app),
_ if ed.flag(soundcraft_engine::edit::SHUFFLE_LOCK) => "slip",
_ => "shuffle",
};
let _ = app.run("edit.mode", json!({"mode": mode}));
}
/// Single-key commands. Returns true when `key` was handled.
fn commands_focus(app: &mut SoundApp, key: Key, shift: bool) -> bool {
let id = match key {

173
crates/ui-egui/src/spot.rs Normal file
View File

@ -0,0 +1,173 @@
//! The Spot dialog: exact positions for clips in Spot mode.
use crate::theme::mono;
use egui::{Id, RichText, TextEdit, Ui, vec2};
use serde_json::{Value, json};
use soundcraft_engine::cmd::MAX_POSITION;
use soundcraft_engine::edit::first_movable;
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
use soundcraft_time::{Samples, TimeFormat, format_length};
#[derive(Debug, Clone)]
pub enum Target {
/// Clips moving together; the fields follow the earliest.
Clips { clips: Vec<ClipId>, track: Option<TrackId> },
/// The edge of a clip the Trim tool took.
Trim { clip: ClipId, start: bool },
/// An audio file dropped from the Clip List.
Source { source: SourceId, track: TrackId },
}
#[derive(Debug, Clone)]
pub struct Spot {
pub target: Target,
pub start: Samples,
pub len: Samples,
sync: Samples,
stamp: Option<Samples>,
format: TimeFormat,
text: [String; 3],
fresh: bool,
}
const FIELDS: [&str; 3] = ["Start", "Sync Point", "End"];
impl Spot {
fn new(s: &Session, target: Target, start: Samples, len: Samples, sync: Samples, stamp: Option<Samples>) -> Spot {
Spot { target, start, len, sync, stamp, format: s.edit.main_counter, text: Default::default(), fresh: true }
}
/// Clicked or dragged clips, moved `delta` so far.
pub fn clips(s: &Session, clips: Vec<ClipId>, track: Option<TrackId>, delta: Samples) -> Option<Spot> {
let c = first_movable(s, &clips)?;
let stamp = c.source().and_then(|id| s.source(id)).map(|src| recorded_at(s, src.time_reference).saturating_add(c.source_offset()));
Some(Spot::new(s, Target::Clips { clips, track }, c.start.saturating_add(delta), c.length, c.sync_point, stamp))
}
/// A clip's start or end edge, dragged to `to`.
pub fn trim(s: &Session, clip: ClipId, start: bool, to: Samples) -> Option<Spot> {
let (_, c) = s.find_clip(clip)?;
let (a, b) = if start { (to.min(c.end().saturating_sub(1)), c.end()) } else { (c.start, to.max(c.start.saturating_add(1))) };
Some(Spot::new(s, Target::Trim { clip, start }, a, b.saturating_sub(a), 0, None))
}
/// An audio file from the Clip List, dropped at `at`.
pub fn source(s: &Session, source: SourceId, track: TrackId, at: Samples) -> Option<Spot> {
let src = s.source(source)?;
let len = i64::try_from(src.frames).unwrap_or(0);
Some(Spot::new(s, Target::Source { source, track }, at, len, 0, Some(recorded_at(s, src.time_reference))))
}
fn value(&self, field: usize) -> Samples {
match field {
0 => self.start,
1 => self.start.saturating_add(self.sync),
_ => self.start.saturating_add(self.len),
}
}
fn set(&mut self, field: usize, v: Samples) {
match (&self.target, field) {
(Target::Trim { start: true, .. }, _) => {
self.len = self.start.saturating_add(self.len).saturating_sub(v);
self.start = v;
}
(Target::Trim { .. }, _) => self.len = v.saturating_sub(self.start),
(_, 0) => self.start = v,
(_, 1) => self.start = v.saturating_sub(self.sync),
_ => self.start = v.saturating_sub(self.len),
}
}
fn editable(&self, field: usize) -> bool {
match self.target {
Target::Trim { start, .. } => field == if start { 0 } else { 2 },
_ => true,
}
}
/// The command OK runs; None when nothing would change.
pub fn command(&self, s: &Session) -> Option<(String, Value)> {
Some(match &self.target {
Target::Clips { clips, track } => {
let first = first_movable(s, clips)?.start;
let by = self.start.saturating_sub(first);
if by == 0 && track.is_none() {
return None;
}
let mut p = json!({"clips": clips.iter().map(|c| c.0).collect::<Vec<_>>(), "by": by});
if let Some(t) = track {
p["track"] = json!(t.0);
}
("edit.move_clips".into(), p)
}
Target::Trim { clip, start: true } => ("edit.trim_clip".into(), json!({"clip": clip.0, "start": self.start})),
Target::Trim { clip, .. } => ("edit.trim_clip".into(), json!({"clip": clip.0, "end": self.value(2)})),
Target::Source { source, track } => ("clip.place_source".into(), json!({"source": source.0, "track": track.0, "at": self.start})),
})
}
}
/// Timeline position of a BWF time stamp (samples since midnight).
fn recorded_at(s: &Session, time_reference: u64) -> Samples {
i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start)
}
/// The dialog's fields; the caller adds OK and Cancel.
pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) {
let show = |at: Samples, f: TimeFormat| s.format_position(at, f);
egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
ui.label("Time Scale");
egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| {
for f in TimeFormat::ALL {
ui.selectable_value(&mut d.format, f, f.label());
}
});
ui.end_row();
for (i, label) in FIELDS.into_iter().enumerate() {
if i == 1 && matches!(d.target, Target::Trim { .. }) {
continue;
}
ui.label(label);
if d.editable(i) {
let id = Id::new(("spot_field", i));
if !ui.memory(|m| m.has_focus(id)) {
d.text[i] = show(d.value(i), d.format);
}
let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0));
if r.changed()
&& let Ok(v) = s.parse_position(&d.text[i], d.format)
&& (-MAX_POSITION..=MAX_POSITION).contains(&v)
{
d.set(i, v);
}
if std::mem::take(&mut d.fresh) {
r.request_focus();
select_all(ui, id, &d.text[i]);
}
} else {
ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0)));
}
ui.end_row();
}
ui.label("Duration");
ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0)));
ui.end_row();
if let Some(at) = d.stamp {
ui.label("Original Time Stamp");
ui.horizontal(|ui| {
ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0)));
if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() {
d.start = at;
}
});
ui.end_row();
}
});
}
fn select_all(ui: &Ui, id: Id, text: &str) {
let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default();
state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::default(), egui::text::CCursor::new(text.chars().count()))));
state.store(ui.ctx(), id);
}

View File

@ -5,6 +5,7 @@ use crate::widgets::{icon_button, key, key_fill};
use crate::{SoundApp, icons};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID};
use soundcraft_model::{EditMode, Tool};
use soundcraft_time::{TimeFormat, format_length};
@ -44,7 +45,9 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let cur = app.engine.session().edit.edit_mode;
let ed = &app.engine.session().edit;
let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK));
let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac);
let modes = [
(EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"),
(EditMode::Spot, 1, 0, "SPOT", "F3"),
@ -62,23 +65,49 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative);
key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off });
let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text });
if resp.clicked() {
let id = match m {
EditMode::Grid if cur == EditMode::Grid => "relative_grid",
EditMode::Shuffle => "shuffle",
EditMode::Spot => "spot",
EditMode::Slip => "slip",
_ => "grid",
};
let _ = app.run("edit.mode", json!({"mode": id}));
let fg = if on { t.mode_on_text } else { t.mode_off_text };
if m == EditMode::Grid && snap {
ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside);
}
resp.on_hover_text(format!("{} mode ({fkey})", label.to_lowercase()));
if m == EditMode::Shuffle && locked {
icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg);
} else {
let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), fg);
}
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label));
if resp.clicked() {
mode_click(app, m, ui.input(|i| i.modifiers));
}
resp.on_hover_text(match m {
EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"),
EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"),
EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"),
_ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }),
});
}
});
}
/// Shift adds Snap to Grid to Shuffle, Slip and Spot (on Grid it toggles it for the current mode);
/// Cmd/Ctrl on Shuffle toggles Shuffle Lock.
fn mode_click(app: &mut SoundApp, m: EditMode, mods: egui::Modifiers) {
let cur = app.engine.session().edit.edit_mode;
let _ = if m == EditMode::Shuffle && mods.command {
app.run("edit.shuffle_lock", json!({}))
} else if m == EditMode::Grid && mods.shift && !cur.is_grid() {
app.run("edit.snap_to_grid", json!({}))
} else {
let id = match m {
EditMode::Shuffle => "shuffle",
EditMode::Spot => "spot",
EditMode::Slip => "slip",
_ => crate::shortcuts::grid_mode(app),
};
app.run("edit.mode", json!({"mode": id, "snap_to_grid": mods.shift}))
};
}
fn zoom_group(app: &mut SoundApp, ui: &mut Ui) {
group(ui, 128.0, 52.0, |ui, _| {
ui.vertical(|ui| {

View File

@ -0,0 +1,293 @@
use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2};
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_engine::edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID};
use soundcraft_model::{Clip, ClipId, EditMode, Source, SourceId, TrackId};
use soundcraft_ui_egui::{Services, SoundApp, edit_window};
const SEC: i64 = 48_000;
fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
}
fn button(pos: Pos2, pressed: bool) -> Event {
Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }
}
fn key(key: Key) -> Vec<Event> {
chord(key, Modifiers::NONE)
}
fn chord(key: Key, modifiers: Modifiers) -> Vec<Event> {
[true, false].map(|pressed| key_event(key, pressed, modifiers)).to_vec()
}
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
fn app(mode: &str, tool: &str, spans: &[(i64, i64)]) -> (SoundApp, egui::Context, TrackId, Vec<ClipId>) {
let (app, ctx, tracks, ids) = tracks(mode, tool, &[spans]);
(app, ctx, tracks[0], ids)
}
fn tracks(mode: &str, tool: &str, lanes: &[&[(i64, i64)]]) -> (SoundApp, egui::Context, Vec<TrackId>, Vec<ClipId>) {
let mut e = Engine::default();
let made = e.execute("track.new", &json!({"count": lanes.len()})).unwrap()["tracks"].clone();
let tracks: Vec<TrackId> = (0..lanes.len()).map(|i| TrackId(made[i].as_u64().unwrap())).collect();
let s = e.session_mut();
s.sources.push(Source {
id: SourceId(7),
name: "src".into(),
path: "src.wav".into(),
channels: 1,
frames: 20 * SEC as u64,
sample_rate: 48_000,
format: soundcraft_audio_io::FileFormat::Wav,
time_reference: 0,
unsaved: false,
});
let mut ids = Vec::new();
for (t, spans) in tracks.iter().zip(lanes) {
for &(start, len) in *spans {
let id = s.new_clip_id();
s.track_mut(*t).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(id, "c", SourceId(7), start, start, len));
ids.push(id);
}
}
e.execute("edit.mode", &json!({"mode": mode})).unwrap();
e.execute("edit.tool", &json!({"tool": tool})).unwrap();
e.execute("view.zoom_set", &json!({"samples_per_px": 400.0})).unwrap();
let mut app = SoundApp::new(e, None, Services::default());
let ctx = egui::Context::default();
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
(app, ctx, tracks, ids)
}
fn point(app: &SoundApp, t: TrackId, at: i64) -> Pos2 {
let s = app.engine.session();
let [x0, y0, x1, y1] = app.edit_layout.timeline;
let row = app.edit_layout.rows.iter().find(|(id, _)| *id == t.0).unwrap().1;
let x = edit_window::x_of(s, Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at);
pos2(x, row[1] + s.track(t).unwrap().height.points() * 0.75)
}
fn drag(ctx: &egui::Context, app: &mut SoundApp, t: TrackId, from: i64, to: i64) {
let (from, to) = (point(app, t, from), point(app, t, to));
drag_between(ctx, app, from, to);
}
fn drag_between(ctx: &egui::Context, app: &mut SoundApp, from: Pos2, to: Pos2) {
frame(ctx, app, vec![Event::PointerMoved(from)]);
frame(ctx, app, vec![button(from, true)]);
let started = from + vec2(7.0_f32.copysign(to.x - from.x), 0.0);
for k in 0..=5 {
frame(ctx, app, vec![Event::PointerMoved(started + (to - started) * (k as f32 / 5.0))]);
}
frame(ctx, app, vec![button(to, false)]);
frame(ctx, app, vec![]);
}
fn click(ctx: &egui::Context, app: &mut SoundApp, at: Pos2) {
frame(ctx, app, vec![Event::PointerMoved(at)]);
frame(ctx, app, vec![button(at, true)]);
frame(ctx, app, vec![button(at, false)]);
}
fn starts(app: &SoundApp, ids: &[ClipId]) -> Vec<i64> {
ids.iter().map(|id| app.engine.session().find_clip(*id).unwrap().1.start).collect()
}
#[test]
fn shuffle_drags_swap_clips() {
let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (SEC, SEC)]);
drag(&ctx, &mut app, t, SEC * 3 / 2, SEC / 2 - 2000);
assert_eq!(starts(&app, &ids), vec![SEC, 0]);
}
#[test]
fn grid_drags_snap_the_dragged_clip_not_the_one_under_the_pointer() {
let (mut app, ctx, t, ids) = app("grid", "grabber", &[(1000, SEC), (3 * SEC + 777, SEC)]);
let s = app.engine.session();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
drag(&ctx, &mut app, t, SEC / 2, 3 * SEC + SEC / 2);
assert_eq!(starts(&app, &ids[..1])[0] % step, 0);
}
#[test]
fn spot_clicks_open_the_spot_dialog() {
let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]);
let at = point(&app, t, SEC * 3 / 2);
click(&ctx, &mut app, at);
frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("spot"));
frame(&ctx, &mut app, vec![Event::Text("4.5".into())]);
frame(&ctx, &mut app, key(Key::Enter));
assert!(app.dialogs.open.is_none());
assert_eq!(starts(&app, &ids), vec![SEC * 9 / 2]);
}
#[test]
fn spot_drags_ask_where_before_moving() {
let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]);
drag(&ctx, &mut app, t, SEC * 3 / 2, SEC * 5 / 2);
assert_eq!(starts(&app, &ids), vec![SEC]);
assert_eq!(app.dialogs.open_name(), Some("spot"));
frame(&ctx, &mut app, key(Key::Enter));
assert!((starts(&app, &ids)[0] - 2 * SEC).abs() < 4000);
}
#[test]
fn slip_trims_stop_at_the_next_clip() {
let (mut app, ctx, t, ids) = app("slip", "trim", &[(0, SEC), (2 * SEC, SEC)]);
drag(&ctx, &mut app, t, SEC * 3 / 4, 4 * SEC);
let s = app.engine.session();
assert_eq!(s.find_clip(ids[0]).unwrap().1.end(), 2 * SEC);
assert_eq!(starts(&app, &ids), vec![0, 2 * SEC]);
}
#[test]
fn f4_toggles_absolute_and_relative_grid_and_comes_back_to_the_last_one() {
let (mut app, ctx, _, _) = app("slip", "grabber", &[]);
let mode = |app: &SoundApp| app.engine.session().edit.edit_mode;
let mut seen = Vec::new();
for k in [Key::F4, Key::F4, Key::F4, Key::F4, Key::F2, Key::F4, Key::F1, Key::Backtick, Key::Backtick, Key::Backtick] {
frame(&ctx, &mut app, key(k));
seen.push(mode(&app));
}
use EditMode::*;
assert_eq!(seen, [Grid, GridRelative, Grid, GridRelative, Slip, GridRelative, Shuffle, Slip, Spot, GridRelative]);
app.engine.execute("options.keyboard_lock", &json!({})).unwrap();
frame(&ctx, &mut app, key(Key::F4));
frame(&ctx, &mut app, chord(Key::Num4, Modifiers::ALT));
assert_eq!(mode(&app), GridRelative);
}
#[test]
fn snap_to_grid_snaps_the_edit_cursor_while_slip_moves_stay_free() {
let (mut app, ctx, t, ids) = app("slip", "grabber", &[(1000, SEC)]);
app.engine.execute("edit.snap_to_grid", &json!({})).unwrap();
let s = app.engine.session();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
drag(&ctx, &mut app, t, SEC / 2, SEC / 2 + 10_000);
let moved = starts(&app, &ids)[0];
assert!(moved > 1000 && moved % step != 0, "{moved}");
app.engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap();
let at = point(&app, t, 3 * SEC + 1234);
click(&ctx, &mut app, at);
assert_eq!(app.engine.session().edit.selection.start % step, 0);
}
#[test]
fn mode_keys_add_snap_to_grid_and_cycle_past_a_locked_shuffle() {
let (mut app, ctx, _, _) = app("slip", "grabber", &[]);
let state = |app: &SoundApp| (app.engine.session().edit.edit_mode, app.engine.session().edit.flag(SNAP_TO_GRID));
frame(&ctx, &mut app, chord(Key::F4, Modifiers::SHIFT));
assert_eq!(state(&app), (EditMode::Slip, true));
let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE);
frame(&ctx, &mut app, vec![f4(true)]);
assert_eq!(state(&app), (EditMode::Grid, false));
frame(&ctx, &mut app, key(Key::F1));
frame(&ctx, &mut app, vec![f4(false)]);
assert_eq!(state(&app), (EditMode::Shuffle, true));
frame(&ctx, &mut app, chord(Key::Num3, Modifiers::ALT));
assert_eq!(state(&app), (EditMode::Spot, false));
app.engine.execute("edit.shuffle_lock", &json!({})).unwrap();
let mut seen = Vec::new();
for _ in 0..4 {
frame(&ctx, &mut app, key(Key::Backtick));
seen.push(state(&app).0);
}
assert_eq!(seen, [EditMode::Grid, EditMode::Slip, EditMode::Spot, EditMode::Grid]);
frame(&ctx, &mut app, key(Key::F1));
assert_eq!(state(&app).0, EditMode::Grid);
}
#[test]
fn toolbar_clicks_add_snap_to_grid_lock_shuffle_and_switch_grid_modes_despite_the_keyboard_lock() {
let mut e = Engine::default();
e.execute("edit.mode", &json!({"mode": "slip"})).unwrap();
let app = SoundApp::new(e, None, Services::default());
let mut h = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
let click = |h: &mut egui_kittest::Harness<'_, SoundApp>, label: &str, modifiers: Modifiers| {
h.get_by_label(label).click_modifiers(modifiers);
h.step();
};
let state = |h: &egui_kittest::Harness<'_, SoundApp>| {
let ed = &h.state().engine.session().edit;
(ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK))
};
click(&mut h, "GRID", Modifiers::SHIFT);
assert_eq!(state(&h), (EditMode::Slip, true, false));
click(&mut h, "SPOT", Modifiers::NONE);
assert_eq!(state(&h), (EditMode::Spot, false, false));
click(&mut h, "SHUFFLE", Modifiers::COMMAND);
click(&mut h, "SHUFFLE", Modifiers::NONE);
assert_eq!(state(&h), (EditMode::Spot, false, true));
assert!(h.state().ui.status.contains("locked"), "{}", h.state().ui.status);
click(&mut h, "SLIP", Modifiers::SHIFT);
assert_eq!(state(&h), (EditMode::Slip, true, true));
h.state_mut().engine.execute("options.keyboard_lock", &json!({})).unwrap();
for (label, want) in [("GRID", EditMode::Grid), ("GRID", EditMode::GridRelative), ("SLIP", EditMode::Slip), ("GRID", EditMode::GridRelative)] {
click(&mut h, label, Modifiers::NONE);
assert_eq!(state(&h).0, want, "{label}");
}
}
#[test]
fn dragging_clips_on_two_tracks_moves_both_down_a_track() {
let (mut app, ctx, t, ids) = tracks("slip", "grabber", &[&[(0, SEC)], &[(2 * SEC, SEC)], &[]]);
app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
let (from, to) = (point(&app, t[0], SEC / 2), point(&app, t[1], SEC / 2));
drag_between(&ctx, &mut app, from, to);
let s = app.engine.session();
assert_eq!(s.find_clip(ids[0]).map(|(t, _)| t), Some(t[1]));
assert_eq!(s.find_clip(ids[1]).map(|(t, _)| t), Some(t[2]));
}
#[test]
fn shuffle_drags_of_clips_on_two_tracks_land_on_the_grabbed_tracks_boundaries() {
let lanes: [&[(i64, i64)]; 2] = [&[(4 * SEC, SEC), (5 * SEC, 3 * SEC), (17 * SEC / 2, SEC / 2)], &[(0, SEC), (SEC, 6 * SEC)]];
let (mut app, ctx, t, ids) = tracks("shuffle", "grabber", &lanes);
app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[3].0]})).unwrap();
let before = starts(&app, &ids);
drag(&ctx, &mut app, t[0], 9 * SEC / 2, 9 * SEC / 2 + 3 * SEC / 10);
assert_eq!(starts(&app, &ids), before);
}
#[test]
fn shuffle_drags_leave_locked_clips_in_the_selection_out_of_it() {
let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (3 * SEC, SEC)]);
app.engine.session_mut().find_clip_mut(ids[0]).unwrap().time_locked = true;
app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
drag(&ctx, &mut app, t, 7 * SEC / 2, 2 * SEC);
assert_eq!(starts(&app, &ids), vec![0, SEC]);
}
#[test]
fn f4_pressed_before_f1_leaves_the_kind_of_grid_alone() {
let (mut app, ctx, _, _) = app("grid", "grabber", &[]);
let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE);
frame(&ctx, &mut app, vec![f4(true)]);
frame(&ctx, &mut app, key(Key::F1));
frame(&ctx, &mut app, vec![f4(false)]);
let ed = &app.engine.session().edit;
assert_eq!((ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(RELATIVE_GRID)), (EditMode::Shuffle, true, false));
}