Files
soundcraft/crates/engine/src/cmd/edit.rs
T
Dylan M. Taylor d683aee3eb refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776) (#1097)
* refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776)

Extract helpers; behavior-preserving.

* fix: clippy lints in extracted helpers
2026-10-25 19:32:34 -04:00

1328 lines
60 KiB
Rust

//! Edit menu commands.
use super::{
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
};
use soundcraft_time::Samples;
use super::more_util::toggle_flag;
use crate::edit as ops;
use crate::{Clipboard, Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))),
cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| {
super::edit_more::copy_clip_gain(e, p)?;
reset_clip_gain(e, p, "edit.cut_clip_gain")
}),
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
e,
p,
"edit.clear_clip_gain"
)),
cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{tracks?, start?, end?}", has_range, |e, p| {
super::edit_more::cut_all_automation(e, p)
}),
cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)),
cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(
e,
p,
Some("pan")
)),
cmd!("edit.clear_plugin_automation", "Plugin Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(
e,
p,
Some("plugin")
)),
cmd!(
"edit.paste_repeat_to_fill",
"Repeat to Fill Selection",
["Edit", "Paste Special"],
Some("Cmd+Alt+V"),
"{}",
has_clipboard,
repeat_to_fill
),
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
let s = e.session_mut();
s.edit.selected_tracks.clear();
s.edit.selected_clips.clear();
s.edit.selected_sources.clear();
s.edit.selection.end = s.edit.selection.start;
Ok(json!({}))
}),
cmd!(noundo "edit.selection_to_timeline", "Change Timeline to Match Edit", ["Edit", "Selection"], Some("Alt+Shift+5"), "{}", always, |e, _| {
let s = e.session_mut();
s.edit.timeline_selection = s.edit.selection;
Ok(json!({}))
}),
cmd!(noundo "edit.timeline_to_selection", "Change Edit to Match Timeline", ["Edit", "Selection"], Some("Alt+Shift+6"), "{}", always, |e, _| {
let s = e.session_mut();
s.edit.selection = s.edit.timeline_selection;
Ok(json!({}))
}),
cmd!(noundo "edit.move_selection_left", "Move Edit Left", ["Edit", "Selection"], Some("Cmd+Alt+L"), "{}", always, |e, _| shift_selection(e, -1)),
cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)),
cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)),
cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)),
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], Some("Ctrl+Shift+P"), "{}", has_tracks, |e, _| extend_tracks(e, -1)),
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], Some("Ctrl+Shift+;"), "{}", has_tracks, |e, _| extend_tracks(e, 1)),
cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
let s = e.session_mut();
if !s.edit.selected_tracks.is_empty() {
s.edit.selected_tracks.remove(0);
}
Ok(json!({}))
}),
cmd!(noundo "edit.remove_selection_bottom", "Remove Edit from Bottom", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
e.session_mut().edit.selected_tracks.pop();
Ok(json!({}))
}),
cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+D"), "{tracks?, start?, end?}", has_range, duplicate),
cmd!("edit.repeat", "Repeat...", ["Edit"], Some("Alt+R"), "{count: n}", has_range, repeat),
cmd!("edit.shift", "Shift...", ["Edit"], Some("Alt+H"), "{by: samples|{seconds}, later?: bool}", has_range, shift),
cmd!("edit.insert_silence", "Insert Silence", ["Edit"], Some("Cmd+Shift+E"), "{tracks?, start?, end?}", has_range, insert_silence),
cmd!("edit.separate", "At Selection", ["Edit", "Separate"], Some("Cmd+E"), "{tracks?, at?|start/end}", has_selection, separate),
cmd!("edit.separate_on_grid", "On Grid", ["Edit", "Separate"], None, "{tracks?, start?, end?}", has_range, separate_on_grid),
cmd!("edit.separate_at_transients", "At Transients", ["Edit", "Separate"], None, "{sensitivity?: 0..1}", has_range, separate_at_transients),
cmd!("edit.heal", "Heal Separation", ["Edit"], Some("Cmd+H"), "{tracks?, start?, end?}", has_selection, heal),
cmd!("edit.trim_to_selection", "To Selection", ["Edit", "Trim"], Some("Cmd+T"), "{tracks?, start?, end?}", has_range, trim_to_selection),
cmd!("edit.trim_start_to_insertion", "Start to Insertion", ["Edit", "Trim"], Some("Alt+Shift+7"), "{tracks?, at?}", has_selection, |e, p| {
trim_edge(e, p, true)
}),
cmd!("edit.trim_end_to_insertion", "End to Insertion", ["Edit", "Trim"], Some("Alt+Shift+8"), "{tracks?, at?}", has_selection, |e, p| {
trim_edge(e, p, false)
}),
cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file),
cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill),
cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips),
cmd!(
"edit.copy_to_new_playlist",
"New Playlist",
["Edit", "Copy Selection to..."],
Some("Alt+Shift+Down"),
"{}",
has_range,
copy_to_new_playlist
),
cmd!(
"edit.strip_silence",
"Strip Silence",
["Edit"],
Some("Cmd+U"),
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, mode?: strip|extract|separate, name?, number?: 1, zeros?: 0, suffix?} (strip removes the silence; extract removes the audio and keeps the silence; separate separates the clips at the edges of the silence and does not remove any; with a name, the clips left in the selection are named name, zeros, a number counting up from number, suffix)",
has_range,
strip_silence
),
cmd!(
query "edit.strip_silence_preview",
"Strip Silence Rectangles",
[],
None,
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
has_range,
strip_silence_preview
),
cmd!(
"edit.fades_create",
"Create...",
["Edit", "Fades"],
Some("Cmd+F"),
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?, curvature?: -1..1}",
has_range,
fades_create
),
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
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, 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;
Ok(json!({"tool": t}))
}),
cmd!(
"edit.move_clips",
"Move Clips",
[],
None,
"{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),
]
}
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()) }
}
/// Remember the selection `before` had when a command changed it, for Restore Last Selection.
pub(crate) fn remember_selection(e: &mut Engine, before: &Session) {
let (old, new) = (&before.edit, &e.session().edit);
if old.selection == new.selection && old.selected_tracks == new.selected_tracks {
return;
}
e.last_selection = Some((old.selection, old.selected_tracks.clone()));
}
/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so
/// invoking it again goes back.
fn restore_selection(e: &mut Engine, _: &Value) -> Result<Value> {
let (range, mut tracks) = e.last_selection.clone().ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?;
tracks.retain(|t| e.session().track(*t).is_some());
let ed = &mut e.session_mut().edit;
ed.selection = range;
ed.selected_tracks = tracks;
Ok(json!({"selection": range}))
}
fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?;
let range = range_param(e, id, p)?;
if range.is_empty() {
return Err(bad(id, "selection is empty"));
}
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));
let lanes = e
.session()
.track(*t)
.map(|tr| {
tr.automation
.iter()
.map(|l| {
let mut l2 = l.clone();
l2.points = l
.points
.iter()
.filter(|p| range.contains(p.at))
.map(|p| soundcraft_model::AutomationPoint { at: p.at - range.start, value: p.value })
.collect();
l2
})
.collect()
})
.unwrap_or_default();
cb.automation.push(lanes);
}
cb.keep_auto_names(e.session());
cb.markers = e
.session()
.markers
.iter()
.filter(|m| m.kind == soundcraft_model::MarkerKind::Marker && range.contains(m.start))
.map(|m| {
let mut m = m.clone();
m.end -= m.start;
m.start -= range.start;
m.end += m.start;
m
})
.collect();
if cut {
let sh = shuffle(e.session());
let s = e.session_mut();
for t in &tracks {
cut_track(s, *t, range, sh, follow);
}
s.edit.selection = soundcraft_time::Range::point(range.start);
}
let n: usize = cb.tracks.iter().map(Vec::len).sum();
e.clipboard = cb;
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);
}
/// The clipboard for a paste, with the Auto Rename schemes of its clips put into the session first
/// ([`Clipboard::adopt_auto_names`]). A paste that succeeds stores it back in `e.clipboard`, so later
/// pastes find its schemes under their session keys; a refused paste leaves the clipboard as it was.
pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
let mut cb = e.clipboard.clone();
if cb.tracks.iter().flatten().any(|c| c.auto_name.is_some()) {
cb.adopt_auto_names(e.session_mut());
}
cb
}
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.paste", p)?;
let given = position_param(e, "edit.paste", p, "at")?;
let cb = clipboard_for_paste(e);
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 };
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);
if !l.points.is_empty() {
dst.clear_range(at, at + cb.length);
}
for pt in &l.points {
dst.set_point(pt.at + at, pt.value);
}
}
}
}
s.edit.selection = soundcraft_time::Range::new(at, at + cb.length);
e.clipboard = cb;
Ok(json!({"pasted": pasted}))
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.clear", p)?;
let range = range_param(e, "edit.clear", p)?;
let ids = clip_ids_param(e, p);
let sh = shuffle(e.session());
let s = e.session_mut();
let mut n = 0;
if range.is_empty() && !ids.is_empty() {
for t in &mut s.tracks {
if let Some(pl) = t.playlist_mut() {
let before = pl.clips.len();
pl.clips.retain(|c| !ids.contains(&c.id));
n += before - pl.clips.len();
}
}
s.edit.selected_clips.clear();
} else {
for t in &tracks {
n += ops::clear_range(s, *t, range, sh).len();
}
}
Ok(json!({"removed": n}))
}
fn reset_clip_gain(e: &mut Engine, p: &Value, _id: &str) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
for id in &ids {
if let Some(c) = s.find_clip_mut(*id) {
c.gain_db = 0.0;
c.gain_env.clear();
}
}
Ok(json!({"clips": ids.len()}))
}
fn clear_automation(e: &mut Engine, p: &Value, which: Option<&str>) -> Result<Value> {
let tracks = tracks_required(e, "edit.clear_automation", p)?;
let r = range_param(e, "edit.clear_automation", p)?;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
let hit = match which {
None => true,
Some("pan") => matches!(l.param, soundcraft_model::AutoParam::Pan(_)),
Some(_) => matches!(l.param, soundcraft_model::AutoParam::Plugin { .. }),
};
if hit {
n += l.clear_range(r.start, r.end);
}
}
}
}
Ok(json!({"points_removed": n}))
}
fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
let range = range_param(e, "edit.paste_repeat_to_fill", p)?;
let len = e.clipboard.length.max(1);
let tracks = tracks_required(e, "edit.paste_repeat_to_fill", p)?;
let cb = clipboard_for_paste(e);
let s = e.session_mut();
let mut at = range.start;
let mut n = 0;
while at < range.end && n < 10_000 {
for (i, t) in tracks.iter().enumerate() {
if let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) {
let trimmed: Vec<_> = clips
.iter()
.filter(|c| at + c.start < range.end)
.map(|c| {
let mut c = c.clone();
if at + c.end() > range.end {
c.trim_end_to(range.end - at);
}
c
})
.collect();
let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
}
}
at += len;
n += 1;
}
e.clipboard = cb;
Ok(json!({"repeats": n}))
}
fn select_all(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session_mut();
s.edit.selected_tracks = s.tracks.iter().filter(|t| !t.hidden).map(|t| t.id).collect();
let end = s.content_end();
s.edit.selection = soundcraft_time::Range::new(0, end);
Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end}))
}
/// Adds the members of every active edit group that touches `t`, then puts `t` in track order.
fn add_group_members(s: &Session, t: &mut Vec<TrackId>) {
let extra: Vec<TrackId> =
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
for m in extra {
if !t.contains(&m) {
t.push(m);
}
}
let order: Vec<TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
/// Selecting clips also selects their range and tracks (Grabber click).
fn select_clip_ids(s: &Session, ed: &mut soundcraft_model::EditState, c: Vec<ClipId>) {
let mut range: Option<soundcraft_time::Range> = None;
let mut trs = Vec::new();
for id in &c {
if let Some((tid, clip)) = s.find_clip(*id) {
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
if !trs.contains(&tid) {
trs.push(tid);
}
}
}
if let Some(r) = range {
ed.selection = r;
ed.selected_tracks = trs;
}
ed.selected_clips = c;
}
fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None };
let st = position_param(e, "edit.select", p, "start")?;
let en = position_param(e, "edit.select", p, "end")?;
let clips =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
let sources =
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
let exact = bool_or(p, "exact", false);
let s = e.session();
let mut ed = s.edit.clone();
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
add_group_members(s, &mut t);
}
ed.selected_tracks = t;
}
match (st, en) {
(Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)),
_ => {}
}
if let Some(c) = clips {
select_clip_ids(s, &mut ed, c);
} else if list_changes {
ed.selected_clips.clear();
}
if list_changes {
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
}
if ed.link_timeline_edit {
ed.timeline_selection = ed.selection;
}
let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources});
if ed != s.edit {
e.session_mut().edit = ed;
}
Ok(out)
}
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
let s = e.session_mut();
let len = s.edit.selection.len().max(1);
s.edit.selection = s.edit.selection.shifted(dir * len);
if s.edit.selection.start < 0 {
s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start);
}
Ok(json!({"selection": s.edit.selection}))
}
fn scale_selection(e: &mut Engine, f: f64) -> Result<Value> {
let s = e.session_mut();
let len = soundcraft_time::to_samples(s.edit.selection.len() as f64 * f);
s.edit.selection.end = s.edit.selection.start.saturating_add(len);
Ok(json!({"selection": s.edit.selection}))
}
/// Adds the nearest track above (`dir` < 0) or below the selected ones that the Edit window shows.
fn extend_tracks(e: &mut Engine, dir: i64) -> Result<Value> {
let s = e.session();
let shown = |t: &&soundcraft_model::Track| !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open);
let idxs: Vec<usize> = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect();
let next = match (idxs.iter().min(), idxs.iter().max()) {
(Some(&top), _) if dir < 0 => s.tracks.iter().take(top).rev().find(shown),
(_, Some(&bottom)) if dir > 0 => s.tracks.iter().skip(bottom + 1).find(shown),
(None, _) => s.tracks.iter().find(shown),
_ => None,
}
.map(|t| t.id);
let s = e.session_mut();
if let Some(t) = next {
if dir < 0 {
s.edit.selected_tracks.insert(0, t);
} else {
s.edit.selected_tracks.push(t);
}
}
Ok(json!({"tracks": s.edit.selected_tracks}))
}
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.duplicate", p)?;
let range = range_param(e, "edit.duplicate", p)?;
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(), 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 {
l.set_point(pt.at + range.len(), pt.value);
}
}
}
}
s.edit.selection = range.shifted(range.len());
Ok(json!({"clips": n}))
}
fn repeat(e: &mut Engine, p: &Value) -> Result<Value> {
let count = i64_or(p, "count", 1).clamp(1, 999);
let tracks = tracks_required(e, "edit.repeat", p)?;
let range = range_param(e, "edit.repeat", p)?;
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 {
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}))
}
fn shift(e: &mut Engine, p: &Value) -> Result<Value> {
let by = position_param(e, "edit.shift", p, "by")?.ok_or_else(|| bad("edit.shift", "`by` is required"))?;
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 }, &[]);
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 {
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);
}
}
}
}
Ok(json!({"inserted": range.len()}))
}
fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.separate", p)?;
let points: Vec<i64> = if let Some(at) = position_param(e, "edit.separate", p, "at")? {
vec![at]
} else {
let r = range_param(e, "edit.separate", p)?;
if r.is_empty() { vec![r.start] } else { vec![r.start, r.end] }
};
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
for at in &points {
n += ops::separate_at(s, *t, *at);
}
}
Ok(json!({"separated": n}))
}
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
let r = range_param(e, "edit.separate_on_grid", p)?;
let s = e.session_mut();
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
let mut n = 0;
for t in &tracks {
for at in &lines {
n += ops::separate_at(s, *t, *at);
}
}
Ok(json!({"separated": n}))
}
fn separate_at_transients(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.separate_at_transients", p)?;
let r = range_param(e, "edit.separate_at_transients", p)?;
let sens = f32_or(p, "sensitivity", 0.5).clamp(0.0, 1.0);
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let hits = crate::io::transients_in(s, *t, r, sens);
for at in hits {
n += ops::separate_at(s, *t, at);
}
}
Ok(json!({"separated": n}))
}
fn heal(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.heal", p)?;
let mut r = range_param(e, "edit.heal", p)?;
if r.is_empty() {
r = soundcraft_time::Range::new(r.start - 1, r.start + 1);
}
let s = e.session_mut();
let n: usize = tracks.iter().map(|t| ops::heal(s, *t, r)).sum();
Ok(json!({"healed": n}))
}
fn trim_to_selection(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.trim_to_selection", p)?;
let r = range_param(e, "edit.trim_to_selection", p)?;
let s = e.session_mut();
let n: usize = tracks.iter().map(|t| ops::trim_to(s, *t, r)).sum();
Ok(json!({"trimmed": n}))
}
fn trim_edge(e: &mut Engine, p: &Value, start: bool) -> Result<Value> {
let tracks = tracks_required(e, "edit.trim", p)?;
let at = position_param(e, "edit.trim", p, "at")?.unwrap_or(e.session().edit.selection.start);
let s = e.session_mut();
let n: usize = tracks.iter().map(|t| if start { ops::trim_start_to(s, *t, at) } else { ops::trim_end_to(s, *t, at) }).sum();
Ok(json!({"trimmed": n}))
}
fn trim_to_file(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let mut n = 0;
for id in ids {
if let Some((frames, lo, hi)) = trim_limits(s, id)
&& let Some(c) = s.find_clip_mut(id)
{
let off = c.source_offset();
let new_start = c.start - off;
c.trim_start_to(new_start.max(lo));
c.trim_end_to((new_start + frames).min(hi));
n += 1;
}
}
Ok(json!({"trimmed": n}))
}
fn trim_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
let r = range_param(e, "edit.trim_to_fill_selection", p)?;
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let mut n = 0;
for id in ids {
if let Some((frames, lo, hi)) = trim_limits(s, id)
&& let Some(c) = s.find_clip_mut(id)
{
let file_start = c.start - c.source_offset();
c.trim_start_to(r.start.max(file_start).max(lo));
c.trim_end_to(r.end.min(file_start + frames).min(hi));
n += 1;
}
}
Ok(json!({"trimmed": n}))
}
/// How far a trim may extend an audio clip: its source length in frames, and the earliest
/// start and latest end before it would run over the clip before or after it on the playlist
/// (an extension never overwrites, like the trim tool). An overlap the clip already has with
/// a neighbour (a crossfade) is kept rather than trimmed away.
fn trim_limits(s: &Session, id: ClipId) -> Option<(Samples, Samples, Samples)> {
let (track, c) = s.find_clip(id)?;
let frames = c.source().and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX))?;
let others = || s.track(track).into_iter().flat_map(|t| t.clips()).filter(|x| x.id != id);
let lo = others().filter(|x| x.start < c.start).map(|x| x.end()).max().map_or(Samples::MIN, |end| end.min(c.start));
let hi = others().filter(|x| x.start > c.start).map(|x| x.start).min().map_or(Samples::MAX, |start| start.max(c.end()));
Some((frames, lo, hi))
}
fn mute_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let any_unmuted = ids.iter().any(|id| s.find_clip(*id).is_some_and(|(_, c)| !c.muted));
for id in &ids {
if let Some(c) = s.find_clip_mut(*id) {
c.muted = any_unmuted;
}
}
Ok(json!({"muted": any_unmuted, "clips": ids.len()}))
}
fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.copy_to_new_playlist", p)?;
let r = range_param(e, "edit.copy_to_new_playlist", p)?;
let s = e.session_mut();
for t in &tracks {
let clips = ops::copy_range(s, *t, r);
let mut fresh = Vec::new();
for mut c in clips {
c.id = s.new_clip_id();
c.start += r.start;
fresh.push(c);
}
if let Some(tr) = s.track_mut(*t) {
let name = format!("{}.{:02}", tr.name, tr.playlists.len() + 1);
tr.playlists.push(soundcraft_model::Playlist { name, clips: fresh });
tr.active_playlist = tr.playlists.len() - 1;
}
}
Ok(json!({"tracks": tracks.len()}))
}
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
/// command in errors.
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
let tracks = tracks_required(e, cmd, p)?;
let r = range_param(e, cmd, p)?;
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
}
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
/// can be started.
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
}
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
let found = strip_silence_scan(e, p)?.run()?;
let tracks: Vec<Value> =
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
Ok(json!({"tracks": tracks}))
}
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
let mode = match p.get("mode") {
None => crate::io::StripMode::Strip,
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
Some(_) => return Err(bad("edit.strip_silence", "mode must be strip, extract or separate")),
};
let names = strip_names("edit.strip_silence", p)?;
let s = e.session_mut();
let mut kept = 0;
let mut named = 0;
for t in &tracks {
kept += crate::io::strip_silence(s, *t, r, thr, min_len, (pre, post), mode, names.is_some())?;
if let Some(names) = &names {
named += crate::io::name_stripped_clips(s, *t, r, names, named);
}
}
Ok(json!({"clips": kept, "named": named}))
}
/// The naming scheme in the `name`, `number`, `zeros` and `suffix` params of `cmd`
/// (`edit.strip_silence`, `clip.auto_rename`), or `None` when `name` is missing or blank.
pub(super) fn strip_names(cmd: &str, p: &Value) -> Result<Option<crate::io::StripNames>> {
use crate::io::StripNames;
let text = |k: &str| match p.get(k) {
None => Ok(None),
Some(Value::String(v)) => Ok(Some(v.clone())),
Some(_) => Err(bad(cmd, format!("`{k}` must be text"))),
};
let whole = |k: &str, max: u64| match p.get(k) {
None => Ok(None),
Some(v) => v.as_u64().filter(|n| *n <= max).map(Some).ok_or_else(|| bad(cmd, format!("`{k}` must be a whole number from 0 to {max}"))),
};
let (name, suffix) = (text("name")?, text("suffix")?);
let (number, zeros) = (whole("number", StripNames::MAX_NUMBER)?, whole("zeros", StripNames::MAX_ZEROS as u64)?);
let Some(name) = name.filter(|n| !n.trim().is_empty()) else {
if suffix.is_some() || number.is_some() || zeros.is_some() {
return Err(bad(cmd, "`number`, `zeros` and `suffix` need a `name`"));
}
return Ok(None);
};
let d = StripNames::default();
let suffix = suffix.unwrap_or(d.suffix);
let max_text = soundcraft_model::AutoName::MAX_TEXT_CHARS;
if name.chars().count() + suffix.chars().count() > max_text {
return Err(bad(cmd, format!("`name` and `suffix` together must be at most {max_text} characters")));
}
Ok(Some(StripNames { name, number: number.unwrap_or(d.number), zeros: zeros.and_then(|z| usize::try_from(z).ok()).unwrap_or(d.zeros), suffix }))
}
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
let shape = match p.get("shape") {
None => FadeShape::EqualPower,
Some(v) => v
.as_str()
.and_then(FadeShape::from_id)
.ok_or_else(|| bad("edit.fades_create", "shape must be linear|equal power|s-curve|exponential|logarithmic"))?,
};
if p.get("clip").is_some() || p.get("side").is_some() {
let curvature = match p.get("curvature") {
None => 0.0,
Some(value) => value
.as_f64()
.filter(|v| v.is_finite() && (-1.0..=1.0).contains(v))
.ok_or_else(|| bad("edit.fades_create", "curvature must be a finite number from -1 to 1"))? as f32,
};
if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) {
return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range"));
}
let id = p.get("clip").and_then(Value::as_u64).map(soundcraft_model::ClipId).ok_or_else(|| bad("edit.fades_create", "clip must be an id"))?;
let fade_in = match str_param(p, "side") {
Some("in") => true,
Some("out") => false,
_ => return Err(bad("edit.fades_create", "side must be in or out")),
};
let length = p
.get("length")
.and_then(Value::as_i64)
.filter(|len| *len > 0)
.ok_or_else(|| bad("edit.fades_create", "length must be a positive sample count"))?;
let (_, clip) = e.session().find_clip(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
let available = clip.length.min(clip.end().saturating_sub(clip.start));
if available <= 0 {
return Err(bad("edit.fades_create", "clip has no length"));
}
let fade = Fade { len: length.min(available), shape, curvature };
let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
if fade_in {
clip.fade_in = fade;
} else {
clip.fade_out = fade;
}
return Ok(json!({"fades": 1}));
}
if p.get("curvature").is_some() {
return Err(bad("edit.fades_create", "curvature requires a clip and side"));
}
let tracks = tracks_required(e, "edit.fades_create", p)?;
let r = range_param(e, "edit.fades_create", p)?;
let s = e.session_mut();
let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum();
if n == 0 {
return Err(bad("edit.fades_create", "the selection must cover a clip start, end or the boundary between two clips"));
}
Ok(json!({"fades": n}))
}
fn fades_delete(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.fades_delete", p)?;
let r = range_param(e, "edit.fades_delete", p)?;
let s = e.session_mut();
let n: usize = tracks.iter().map(|t| ops::delete_fades(s, *t, r)).sum();
Ok(json!({"deleted": n}))
}
fn fade_to(e: &mut Engine, p: &Value, to_start: bool) -> Result<Value> {
let tracks = tracks_required(e, "edit.fade_to", p)?;
let at = e.session().edit.selection.start;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let range = s.track(*t).and_then(|tr| {
tr.clips()
.iter()
.find(|c| c.range().contains(at))
.map(|c| if to_start { soundcraft_time::Range::new(c.start, at) } else { soundcraft_time::Range::new(at, c.end()) })
});
if let Some(r) = range {
n += ops::create_fades(s, *t, r, FadeShape::EqualPower);
}
}
Ok(json!({"fades": n}))
}
fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
let dir = i64_or(p, "direction", 1).signum();
let count = i64_or(p, "count", 1).clamp(1, 1000);
let s = e.session();
let value = if bool_or(p, "next", false) { s.edit.nudge.stepped(true) } else { s.edit.nudge };
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, &[]) };
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);
}
Ok(json!({"moved": n, "by": step}))
}
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("edit.move_clips", "no clips"));
}
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.saturating_sub(first.map_or(0, |c| c.start)),
_ => 0,
};
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 = 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)?;
if r.is_empty() {
return Err(bad("edit.consolidate", "selection is empty"));
}
let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count();
let mut n = 0;
for t in tracks {
if crate::io::consolidate(e, "edit.consolidate", t, r)? {
n += 1;
}
}
Ok(json!({"consolidated": n + midi}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind};
#[test]
fn targeted_fade_curvature_saves_reopens_and_undoes_without_changing_lengths() {
for side in ["in", "out"] {
let mut e = crate::demo::demo_engine();
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve"})).unwrap();
let before = e.session().clone();
let undo_len = e.undo_history().len();
e.execute(
"edit.fades_create",
&json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve", "curvature": -0.65}),
)
.unwrap();
let mut expected = before.clone();
let c = expected.find_clip_mut(clip.id).unwrap();
if side == "in" {
c.fade_in.curvature = -0.65;
} else {
c.fade_out.curvature = -0.65;
}
assert_eq!(e.session(), &expected);
let reopened = Session::from_json(&e.session().to_json().unwrap()).unwrap();
let reopened = reopened.find_clip(clip.id).unwrap().1;
let stored = expected.find_clip(clip.id).unwrap().1;
assert_eq!(reopened.fade_in, stored.fade_in);
assert_eq!(reopened.fade_out, stored.fade_out);
assert_eq!(e.undo_history().len(), undo_len + 1);
assert!(e.undo());
assert_eq!(e.session(), &before);
assert!(e.redo());
assert_eq!(e.session(), &expected);
}
}
#[test]
fn targeted_fades_fill_only_the_requested_side_and_undo_once() {
for side in ["in", "out"] {
let mut e = crate::demo::demo_engine();
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select_none", &json!({})).unwrap();
let before = e.session().clone();
let undo_len = e.undo_history().len();
assert_eq!(
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": i64::MAX, "shape": "s-curve"})).unwrap(),
json!({"fades": 1})
);
let mut expected = before.clone();
let c = expected.find_clip_mut(clip.id).unwrap();
let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 };
if side == "in" {
c.fade_in = fade;
} else {
c.fade_out = fade;
}
assert_eq!(e.session(), &expected);
assert_eq!(e.undo_history().len(), undo_len + 1);
assert!(e.undo());
assert_eq!(e.session(), &before);
assert!(e.redo());
assert_eq!(e.session(), &expected);
}
}
#[test]
fn targeted_fades_reject_invalid_parameters_without_an_edit() {
let mut e = crate::demo::demo_engine();
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
for params in [
json!({"clip": id, "side": "in"}),
json!({"clip": id, "length": 10}),
json!({"side": "out", "length": 10}),
json!({"clip": "bad", "side": "in", "length": 10}),
json!({"clip": u64::MAX, "side": "in", "length": 10}),
json!({"clip": id, "side": "bad", "length": 10}),
json!({"clip": id, "side": "in", "length": 0}),
json!({"clip": id, "side": "in", "length": -1}),
json!({"clip": id, "side": "in", "length": 1.5}),
json!({"clip": id, "side": "in", "length": "10"}),
json!({"clip": id, "side": "in", "length": u64::MAX}),
json!({"clip": id, "side": "in", "length": 10, "shape": "bad"}),
json!({"clip": id, "side": "in", "length": 10, "shape": null}),
json!({"clip": id, "side": "in", "length": 10, "start": 0}),
json!({"clip": id, "side": "in", "length": 10, "end": 100}),
json!({"clip": id, "side": "in", "length": 10, "tracks": []}),
json!({"clip": id, "side": "in", "length": 10, "curvature": 2}),
json!({"clip": id, "side": "in", "length": 10, "curvature": -2}),
json!({"clip": id, "side": "in", "length": 10, "curvature": 1e100}),
json!({"clip": id, "side": "in", "length": 10, "curvature": null}),
json!({"clip": id, "side": "in", "length": 10, "curvature": "0.5"}),
json!({"start": 0, "end": 100, "curvature": 0.5}),
] {
let before = e.session().clone();
let history = e.undo_history();
assert!(e.execute("edit.fades_create", &params).is_err(), "{params}");
assert_eq!(e.session(), &before, "{params}");
assert_eq!(e.undo_history(), history, "{params}");
}
e.session_mut().find_clip_mut(soundcraft_model::ClipId(id)).unwrap().length = 0;
let before = e.session().clone();
assert!(e.execute("edit.fades_create", &json!({"clip": id, "side": "in", "length": 1})).is_err());
assert_eq!(e.session(), &before);
}
#[test]
fn range_fades_refuse_a_shape_that_is_not_a_shape_name() {
for shape in [json!("bad"), json!(null), json!(true), json!(1)] {
let (mut e, t, _) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
let before = e.session().clone();
let history = e.undo_history();
let params = json!({"tracks": [t], "start": 9_500, "end": 10_500, "shape": shape});
assert!(e.execute("edit.fades_create", &params).is_err(), "{params}");
assert_eq!(e.session(), &before, "{params}");
assert_eq!(e.undo_history(), history, "{params}");
}
}
#[test]
fn targeted_fades_support_one_sample_clips() {
let mut e = crate::demo::demo_engine();
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id;
e.session_mut().find_clip_mut(id).unwrap().length = 1;
for side in ["in", "out"] {
e.execute("edit.fades_create", &json!({"clip": id.0, "side": side, "length": 1})).unwrap();
let clip = e.session().find_clip(id).unwrap().1;
assert_eq!(if side == "in" { clip.fade_in.len } else { clip.fade_out.len }, 1);
}
}
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
let mut e = Engine::default();
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200});
e.execute("edit.select", &sel).unwrap();
let rev = e.revision;
assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]]));
assert_eq!(e.revision, rev);
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
assert_ne!(e.revision, rev);
}
fn keys() -> (Engine, TrackId, impl Fn(i64) -> i64) {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let (tempo, sr) = (e.session().tempo.clone(), e.session().sample_rate);
(e, t, move |q| tempo.tick_to_samples(q * soundcraft_time::TICKS_PER_QUARTER, sr))
}
fn note(start: i64, length: i64, pitch: u8) -> soundcraft_midi::Note {
let q = soundcraft_time::TICKS_PER_QUARTER;
soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start: start * q, length: length * q }
}
#[test]
fn consolidating_an_empty_midi_selection_makes_an_empty_clip() {
let (mut e, t, at) = keys();
e.execute("edit.select", &json!({"tracks": [t.0], "start": at(4), "end": at(12)})).unwrap();
assert_eq!(e.execute("edit.consolidate", &json!({})).unwrap()["consolidated"], 1);
let clips = e.session().track(t).unwrap().clips().to_vec();
assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::<Vec<_>>(), vec![(at(4), at(12))]);
assert!(matches!(&clips[0].content, ClipContent::Midi { sequence } if sequence.notes.is_empty()));
}
#[test]
fn consolidating_midi_merges_the_clips_as_they_sound() {
let (mut e, t, at) = keys();
let s = e.session_mut();
let cc = soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(1), value: 64 };
let a = soundcraft_midi::Sequence { notes: vec![note(0, 1, 60), note(3, 2, 62), note(5, 1, 63)], ctrls: vec![] };
let b = soundcraft_midi::Sequence { notes: vec![note(0, 1, 64)], ctrls: vec![] };
let c = soundcraft_midi::Sequence { notes: vec![note(1, 1, 65)], ctrls: vec![cc] };
for (start, end, seq, muted) in [(0, 4, a, false), (6, 8, b, true), (8, 12, c, false)] {
let id = s.new_clip_id();
let mut clip = Clip::midi(id, "Keys", at(start), at(end) - at(start), seq);
clip.muted = muted;
ops::place_clip(s, t, clip);
}
e.execute("edit.select", &json!({"tracks": [t.0], "start": at(2), "end": at(10)})).unwrap();
e.execute("edit.consolidate", &json!({})).unwrap();
let clips = e.session().track(t).unwrap().clips().to_vec();
assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::<Vec<_>>(), vec![(0, at(2)), (at(2), at(10)), (at(10), at(12))]);
let ClipContent::Midi { sequence } = &clips[1].content else { panic!("not MIDI") };
assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]);
assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]);
}
/// A 60,000-frame source with `clips` placed on one track as (start, length, source offset).
fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec<ClipId>) {
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav);
let mut ids = Vec::new();
for &(start, len, off) in clips {
let id = s.new_clip_id();
ops::place_clip(&mut s, t, Clip::audio(id, "c", src, off, start, len));
ids.push(id);
}
(Engine::new(s), t, ids)
}
fn bounds(e: &Engine, t: TrackId) -> Vec<(Samples, Samples, Samples)> {
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end(), c.source_offset())).collect()
}
#[test]
fn trim_to_file_boundaries_stops_at_the_neighbours() {
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]);
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap();
assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]);
}
#[test]
fn trim_to_fill_selection_stops_at_the_neighbours() {
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]);
e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[1].0], "start": 5_000, "end": 45_000})).unwrap();
assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]);
}
#[test]
fn trims_without_neighbours_expand_fully() {
let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]);
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[0].0]})).unwrap();
assert_eq!(bounds(&e, t), vec![(0, 60_000, 0)]);
let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]);
e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[0].0], "start": 5_000, "end": 45_000})).unwrap();
assert_eq!(bounds(&e, t), vec![(5_000, 45_000, 5_000)]);
}
#[test]
fn trim_to_file_boundaries_keeps_an_existing_crossfade() {
let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]);
e.execute("edit.fades_create", &json!({"tracks": [t], "start": 9_500, "end": 10_500})).unwrap();
let faded = bounds(&e, t);
assert_eq!((faded[0].1, faded[1].0), (10_500, 9_500), "the fixture needs an overlapping crossfade");
e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap();
assert_eq!(bounds(&e, t), vec![faded[0], (9_500, 60_000, 9_500)]);
}
#[test]
fn restore_last_selection_swaps_back_to_the_previous_range_and_tracks() {
let mut e = Engine::default();
assert!(e.execute("edit.restore_last_selection", &json!({})).is_err(), "nothing to restore yet");
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 1_000, "end": 4_000})).unwrap();
e.execute("edit.select", &json!({"tracks": [ids[1]], "start": 7_000, "end": 9_000})).unwrap();
let tracks = |e: &Engine| e.session().edit.selected_tracks.iter().map(|t| t.0).collect::<Vec<_>>();
e.execute("edit.restore_last_selection", &json!({})).unwrap();
assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(1_000, 4_000));
assert_eq!(json!(tracks(&e)), json!([ids[0]]));
e.execute("edit.restore_last_selection", &json!({})).unwrap();
assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(7_000, 9_000));
assert_eq!(json!(tracks(&e)), json!([ids[1]]));
}
}