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:
commit
768629a8ce
|
|
@ -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()}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)?;
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}))
|
||||
}
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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(¶m);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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| {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
Loading…
Reference in New Issue