Files
soundcraft/crates/engine/src/cmd/edit_more.rs
T
Dylan M. TaylorandDylan Taylor fe4bb3fe0c style: replace flagged glob imports with explicit uses (SonarCloud rust:S2208); move release workflow's write permission to the job (S8233) (#988)
* security: move the release workflow's write permission to the job level (SonarCloud githubactions:S8233)

* style: replace wildcard imports with explicit uses (SonarCloud rust:S2208)

Rewrites every glob `use` Sonar flags into explicit imports, so what each
command module consumes is visible at the top of the file.

* release: the git-sources gate moves into craft-common-actions (PR #78)

The check is shared at release.yml@main now; keep only the reusable call.

---------

Co-authored-by: Dylan Taylor <dylanmtaylor@users.noreply.github.com>
2026-10-23 14:53:00 -04:00

1202 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers,
//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide.
use super::{
CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required,
};
use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist};
use crate::edit as ops;
use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
use soundcraft_time::{Range, Samples};
#[derive(Clone, Copy, PartialEq)]
enum Lanes {
All,
Pan,
Plugin,
}
impl Lanes {
fn hit(self, p: &AutoParam) -> bool {
match self {
Lanes::All => true,
Lanes::Pan => matches!(p, AutoParam::Pan(_)),
Lanes::Plugin => matches!(p, AutoParam::Plugin { .. }),
}
}
}
#[derive(Clone, Copy, PartialEq)]
enum Dest {
Target,
New,
Duplicate,
}
fn has_clip_gain_clipboard(e: &Engine) -> std::result::Result<(), String> {
if e.clipboard.clip_gain.is_empty() { Err("no clip gain has been copied".into()) } else { Ok(()) }
}
fn has_clip_effects_clipboard(e: &Engine) -> std::result::Result<(), String> {
if e.clipboard.clip_effects.is_empty() { Err("no clip effects have been copied".into()) } else { Ok(()) }
}
fn has_automation_clipboard(e: &Engine) -> std::result::Result<(), String> {
if e.clipboard.automation.iter().flatten().any(|l| !l.points.is_empty()) { Ok(()) } else { Err("no automation has been copied".into()) }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"edit.cut_pan_automation",
"Pan Automation",
["Edit", "Cut Special"],
None,
"{tracks?, start?, end?} — cuts pan automation only to the clipboard",
has_range,
|e, p| auto_copy(e, p, Lanes::Pan, true, "edit.cut_pan_automation")
),
cmd!(
"edit.cut_plugin_automation",
"Plugin Automation",
["Edit", "Cut Special"],
None,
"{tracks?, start?, end?} — cuts plug-in automation only",
has_range,
|e, p| auto_copy(e, p, Lanes::Plugin, true, "edit.cut_plugin_automation")
),
cmd!(noundo "edit.copy_all_automation", "All Automation", ["Edit", "Copy Special"], None, "{tracks?, start?, end?} — copies automation only (no clips)", has_range, |e, p| auto_copy(e, p, Lanes::All, false, "edit.copy_all_automation")),
cmd!(noundo "edit.copy_pan_automation", "Pan Automation", ["Edit", "Copy Special"], None, "{tracks?, start?, end?}", has_range, |e, p| auto_copy(e, p, Lanes::Pan, false, "edit.copy_pan_automation")),
cmd!(noundo "edit.copy_plugin_automation", "Plugin Automation", ["Edit", "Copy Special"], None, "{tracks?, start?, end?}", has_range, |e, p| auto_copy(e, p, Lanes::Plugin, false, "edit.copy_plugin_automation")),
cmd!(noundo "edit.copy_clip_gain", "Clip Gain", ["Edit", "Copy Special"], None, "{tracks?, start?, end?} — copies the clip gain curve under the selection", has_range, copy_clip_gain),
cmd!(
"edit.paste_clip_gain",
"Paste Clip Gain",
[],
None,
"{tracks?, at?} — pastes copied clip gain onto the clips under the paste range",
has_clip_gain_clipboard,
paste_clip_gain
),
cmd!(noundo "edit.copy_clip_effects", "Clip Effects", ["Edit", "Copy Special"], None, "{clips?} — copies the first clip's clip-effect settings", has_selection, |e, p| clip_effects(e, p, true, false, "edit.copy_clip_effects")),
cmd!(
"edit.cut_clip_effects",
"Clip Effects",
["Edit", "Cut Special"],
None,
"{clips?} — copies then removes clip-effect settings",
has_selection,
|e, p| clip_effects(e, p, true, true, "edit.cut_clip_effects")
),
cmd!(
"edit.clear_clip_effects",
"Clip Effects",
["Edit", "Clear Special"],
None,
"{clips?} — removes clip-effect settings (and bypass)",
has_selection,
|e, p| clip_effects(e, p, false, true, "edit.clear_clip_effects")
),
cmd!("edit.paste_clip_effects", "Paste Clip Effects", [], None, "{clips?}", has_clip_effects_clipboard, paste_clip_effects),
cmd!(
"edit.paste_merge_midi",
"Merge MIDI",
["Edit", "Paste Special"],
None,
"{tracks?, at?} — merges copied MIDI notes into existing MIDI clips instead of replacing them",
has_clipboard,
paste_merge_midi
),
cmd!(
"edit.paste_merge_markers",
"Merge Markers",
["Edit", "Paste Special"],
None,
"{at?} — adds the copied markers at the insertion point, skipping any that already exist there",
has_marker_clipboard,
paste_merge_markers
),
cmd!(
"edit.paste_to_current_automation",
"To Current Automation Type",
["Edit", "Paste Special"],
None,
"{tracks?, at?, param?} — pastes copied automation into the shown lane (track view) or `param`, rescaled to its range",
has_automation_clipboard,
paste_to_current_automation
),
cmd!(noundo "edit.play_timeline", "Play Timeline", ["Edit", "Selection"], None, "{} — plays the timeline selection", always, |e, _| {
let s = e.session();
let r = if s.edit.timeline_selection.is_empty() { s.edit.selection } else { s.edit.timeline_selection };
e.transport_requests.push(TransportRequest::Locate(r.start));
e.transport_requests.push(TransportRequest::Play);
Ok(json!({"start": r.start, "end": r.end}))
}),
cmd!(
"edit.duplicate_extend",
"Duplicate and Extend Edit",
["Edit", "Selection"],
None,
"{tracks?, start?, end?} — duplicates the selection and extends the selection over the copy",
has_range,
duplicate_extend
),
cmd!(noundo "edit.extend_up_to_members", "Extend Edit Up to Members", ["Edit", "Selection"], None, "{} — adds the parent folder of each selected member and its members", has_selection, |e, _| extend_members(e, true)),
cmd!(noundo "edit.extend_down_to_members", "Extend Edit Down to Members", ["Edit", "Selection"], None, "{} — adds the members of selected folder tracks (recursively)", has_selection, |e, _| extend_members(e, false)),
cmd!(noundo "edit.marker_lane_move_up", "Move Edit Selection To Marker Lane Above", ["Edit", "Selection"], None, "{} — the selection's marker ruler lane (1..5) moves up", always, |e, _| marker_lane(e, -1, false)),
cmd!(noundo "edit.marker_lane_move_down", "Move Edit Selection To Marker Lane Below", ["Edit", "Selection"], None, "{}", always, |e, _| marker_lane(e, 1, false)),
cmd!(noundo "edit.marker_lane_extend_up", "Extend Edit Selection To Marker Lane Above", ["Edit", "Selection"], None, "{} — adds the marker ruler lane above to the selection", always, |e, _| marker_lane(e, -1, true)),
cmd!(noundo "edit.marker_lane_extend_down", "Extend Edit Selection To Marker Lane Below", ["Edit", "Selection"], None, "{}", always, |e, _| marker_lane(e, 1, true)),
cmd!(
"edit.space_clips",
"Space Clips…",
["Edit"],
None,
"{clips?, gap?: samples|{seconds} (default 0), mode?: end_to_start|start_to_start}",
has_selection,
space_clips
),
cmd!(
"edit.snap_next",
"Next",
["Edit", "Snap to"],
None,
"{clips?} — moves each clip so its end meets the start of the next clip",
has_selection,
|e, p| snap(e, p, true)
),
cmd!(
"edit.snap_previous",
"Previous",
["Edit", "Snap to"],
None,
"{clips?} — moves each clip so its start meets the end of the previous clip",
has_selection,
|e, p| snap(e, p, false)
),
cmd!(
"edit.copy_to_target_playlist",
"Target Playlist",
["Edit", "Copy Selection to..."],
Some("Alt+Shift+Up"),
"{tracks?, start?, end?} — copies the selection into each track's target playlist",
has_range,
|e, p| to_playlist(e, p, Dest::Target, false, "edit.copy_to_target_playlist")
),
cmd!(
"edit.copy_to_duplicate_playlist",
"Duplicate of Main Playlist",
["Edit", "Copy Selection to..."],
None,
"{tracks?, start?, end?}",
has_range,
|e, p| to_playlist(e, p, Dest::Duplicate, false, "edit.copy_to_duplicate_playlist")
),
cmd!(
"edit.move_to_target_playlist",
"Target Playlist",
["Edit", "Move Selection to..."],
Some("Alt+Shift+T"),
"{tracks?, start?, end?} — copies to the target playlist and clears it from the main",
has_range,
|e, p| to_playlist(e, p, Dest::Target, true, "edit.move_to_target_playlist")
),
cmd!(
"edit.move_to_new_playlist",
"New Playlist",
["Edit", "Move Selection to..."],
Some("Alt+Shift+N"),
"{tracks?, start?, end?}",
has_range,
|e, p| to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist")
),
cmd!(
"edit.move_to_duplicate_playlist",
"Duplicate of Main Playlist",
["Edit", "Move Selection to..."],
None,
"{tracks?, start?, end?}",
has_range,
|e, p| to_playlist(e, p, Dest::Duplicate, true, "edit.move_to_duplicate_playlist")
),
cmd!(
"edit.tce_to_timeline",
"TCE Edit to Timeline Selection",
["Edit"],
None,
"{clip?, start?, end?} — time-stretches the clip to fill the timeline selection (pitch preserved)",
has_selection,
tce
),
cmd!(
"edit.automation_coalesce_volume_to_clip_gain",
"Coalesce Volume Automation to Clip Gain",
["Edit", "Automation"],
None,
"{tracks?} — folds the whole volume curve into clip gain; the fader goes to 0 dB",
has_selection,
coalesce_volume_to_clip_gain
),
cmd!(
"edit.automation_coalesce_clip_gain_to_volume",
"Coalesce Clip Gain to Volume Automation",
["Edit", "Automation"],
None,
"{tracks?} — folds all clip gain into volume automation",
has_selection,
coalesce_clip_gain_to_volume
),
cmd!(
"edit.automation_trim_to_all",
"Trim to All Enabled",
["Edit", "Automation"],
None,
"{tracks?, start?, end?} — offsets every enabled lane in the selection so it starts at the current control value",
has_range,
|e, p| trim_glide(e, p, false, false, "edit.automation_trim_to_all")
),
cmd!(
"edit.automation_glide_to_all",
"Glide to All Enabled",
["Edit", "Automation"],
None,
"{tracks?, start?, end?} — ramps every enabled lane from its value at the start to the current value at the end",
has_range,
|e, p| trim_glide(e, p, true, false, "edit.automation_glide_to_all")
),
cmd!("edit.automation_glide_pan", "Glide Pan Only", ["Edit", "Automation"], None, "{tracks?, start?, end?}", has_range, |e, p| trim_glide(
e,
p,
true,
true,
"edit.automation_glide_pan"
)),
]
}
fn shuffle(s: &Session) -> bool {
s.edit.edit_mode == EditMode::Shuffle
}
fn nonempty_range(e: &Engine, p: &Value, id: &str) -> Result<Range> {
let r = range_param(e, id, p)?;
if r.is_empty() { Err(bad(id, "the selection is empty")) } else { Ok(r) }
}
pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result<Value> {
auto_copy(e, p, Lanes::All, true, "edit.cut_all_automation")
}
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 { automation_only: true, ..Clipboard::of_range(e.session(), r) };
let mut lanes_n = 0;
for t in &tracks {
cb.tracks.push(Vec::new());
let lanes: Vec<AutomationLane> = e
.session()
.track(*t)
.map(|tr| {
tr.automation
.iter()
.filter(|l| which.hit(&l.param) && !l.points.is_empty())
.map(|l| {
let mut out = AutomationLane::new(l.param.clone());
out.enabled = l.enabled;
let (_, _, def) = l.param.range();
out.set_point(0, l.value_at(r.start, def));
for pt in l.points.iter().filter(|pt| pt.at > r.start && pt.at < r.end) {
out.set_point(pt.at - r.start, pt.value);
}
out.set_point(r.len() - 1, l.value_at(r.end - 1, def));
out
})
.collect()
})
.unwrap_or_default();
lanes_n += lanes.len();
cb.automation.push(lanes);
}
if cut {
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
for l in tr.automation.iter_mut().filter(|l| which.hit(&l.param)) {
l.clear_range(r.start, r.end);
}
}
}
}
e.clipboard = cb;
Ok(json!({"lanes": lanes_n, "length": r.len()}))
}
pub(super) fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.copy_clip_gain";
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let s = e.session();
let mut out = Vec::new();
for t in &tracks {
let mut env: Vec<(Samples, f32)> = Vec::new();
for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) {
let Some(i) = c.range().intersect(&r) else { continue };
env.push((i.start - r.start, clip_db(c, i.start - c.start)));
for &(o, _) in &c.gain_env {
let at = c.start + o;
if at > i.start && at < i.end - 1 {
env.push((at - r.start, clip_db(c, o)));
}
}
env.push((i.end - 1 - r.start, clip_db(c, i.end - 1 - c.start)));
}
env.sort_by_key(|x| x.0);
env.dedup_by_key(|x| x.0);
out.push(env);
}
let n = out.iter().map(Vec::len).sum::<usize>();
e.clipboard = Clipboard { length: r.len(), clip_gain: out, ..Clipboard::default() };
Ok(json!({"points": n, "length": r.len()}))
}
fn paste_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.paste_clip_gain";
let tracks = tracks_required(e, id, p)?;
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start);
let cb = e.clipboard.clone();
let paste = Range::new(at, at.saturating_add(cb.length.max(1)));
let s = e.session_mut();
let mut n = 0;
for (i, t) in tracks.iter().enumerate() {
let Some(env) = cb.clip_gain.get(i % cb.clip_gain.len().max(1)) else { continue };
let Some(pl) = s.track_mut(*t).and_then(|tr| tr.playlist_mut()) else { continue };
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&paste)) {
let Some(ov) = c.range().intersect(&paste) else { continue };
let (a, b) = (ov.start - c.start, ov.end - c.start);
let mut pts: Vec<(Samples, f32)> = Vec::new();
// Keep the existing curve outside the paste range, anchored at its edges.
let mut old: Vec<(Samples, f32)> = c.gain_env.iter().map(|&(o, _)| (o, clip_db(c, o))).collect();
old.push((0, clip_db(c, 0)));
old.push((c.length, clip_db(c, c.length)));
pts.extend(old.into_iter().filter(|(o, _)| *o < a || *o > b));
if a > 0 {
pts.push((a - 1, clip_db(c, a - 1)));
}
if b < c.length {
pts.push((b, clip_db(c, b)));
}
for &(o, v) in env {
let rel = at + o - c.start;
if rel >= a && rel < b {
pts.push((rel, v));
}
}
set_clip_env(c, pts);
n += 1;
}
}
Ok(json!({"clips": n}))
}
fn fx_prefix(c: ClipId) -> String {
format!("clip_fx.{}.", c.0)
}
fn clip_effects(e: &mut Engine, p: &Value, copy: bool, remove: bool, id: &str) -> Result<Value> {
let ids = clip_ids_param(e, p);
let first = *ids.first().ok_or_else(|| bad(id, "select a clip"))?;
if copy {
let pre = fx_prefix(first);
let fx: Vec<(String, f64)> =
e.session().edit.values.iter().filter_map(|(k, v)| k.strip_prefix(&pre).map(|name| (name.to_string(), *v))).collect();
if fx.is_empty() {
return Err(bad(id, "the clip has no clip effects"));
}
e.clipboard = Clipboard { clip_effects: fx, ..Clipboard::default() };
}
let mut removed = 0;
if remove {
let s = e.session_mut();
for c in &ids {
let pre = fx_prefix(*c);
let before = s.edit.values.len();
s.edit.values.retain(|k, _| !k.starts_with(&pre));
removed += before - s.edit.values.len();
s.edit.set_flag(&format!("clip_fx.bypass.{}", c.0), false);
}
}
Ok(json!({"clips": ids.len(), "copied": e.clipboard.clip_effects.len(), "removed": removed}))
}
fn paste_clip_effects(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("edit.paste_clip_effects", "select a clip"));
}
let fx = e.clipboard.clip_effects.clone();
let s = e.session_mut();
for c in &ids {
let pre = fx_prefix(*c);
s.edit.values.retain(|k, _| !k.starts_with(&pre));
for (k, v) in &fx {
s.edit.values.insert(format!("{pre}{k}"), *v);
}
}
Ok(json!({"clips": ids.len()}))
}
fn paste_merge_midi(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.paste_merge_midi";
let tracks = tracks_required(e, id, p)?;
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
let cb = e.clipboard.clone();
let s = e.session_mut();
let tempo = s.tempo.clone();
let sr = s.sample_rate;
let mut merged = 0;
let mut placed = 0;
for (i, t) in tracks.iter().enumerate() {
let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue };
for c in clips {
let pos = at.saturating_add(c.start);
let ClipContent::Midi { sequence } = &c.content else {
let mut nc = c.clone();
nc.id = s.new_clip_id();
nc.start = pos;
ops::place_clip(s, *t, nc);
placed += 1;
continue;
};
let target = s.track(*t).and_then(|tr| {
let host = tr.clips().iter().find(|x| matches!(x.content, ClipContent::Midi { .. }) && x.range().contains(pos))?;
let next_start = tr.clips().iter().filter(|x| x.start > host.start && x.id != host.id).map(|x| x.start).min();
Some((host.id, host.start, next_start))
});
match target {
Some((hid, hstart, next_start)) => {
let diff = tempo.samples_to_ticks(pos, sr) - tempo.samples_to_ticks(hstart, sr);
let want_end = pos.saturating_add(c.length);
let limit = next_start.unwrap_or(Samples::MAX);
if let Some(h) = s.find_clip_mut(hid)
&& let ClipContent::Midi { sequence: hs } = &mut h.content
{
for n in &sequence.notes {
let mut n = *n;
n.start = n.start.saturating_add(diff);
hs.notes.push(n);
}
for cc in &sequence.ctrls {
let mut cc = *cc;
cc.tick = cc.tick.saturating_add(diff);
hs.ctrls.push(cc);
}
hs.sort();
if want_end > h.end() {
h.trim_end_to(want_end.min(limit));
}
merged += 1;
}
}
None => {
let mut nc = c.clone();
nc.id = s.new_clip_id();
nc.start = pos;
ops::place_clip(s, *t, nc);
placed += 1;
}
}
}
}
Ok(json!({"merged": merged, "placed": placed}))
}
fn map_value(v: f32, from: &AutoParam, to: &AutoParam) -> f32 {
let (a0, a1, _) = from.range();
let (b0, b1, _) = to.range();
let finite = |x: f32, y: f32| x > f32::MIN && y < f32::MAX && y > x;
if from == to || !finite(a0, a1) || !finite(b0, b1) {
return v;
}
b0 + (v - a0) / (a1 - a0) * (b1 - b0)
}
fn paste_to_current_automation(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.paste_to_current_automation";
let tracks = tracks_required(e, id, p)?;
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
let forced = match str_param(p, "param") {
Some(x) => Some(AutoParam::parse(x).ok_or_else(|| bad(id, format!("unknown automation param `{x}`")))?),
None => None,
};
let cb = e.clipboard.clone();
let s = e.session_mut();
let mut written = 0;
for (i, t) in tracks.iter().enumerate() {
let Some(lanes) = cb.automation.get(i % cb.automation.len().max(1)) else { continue };
let Some(src) = lanes.iter().max_by_key(|l| l.points.len()).filter(|l| !l.points.is_empty()) else { continue };
let Some(tr) = s.track_mut(*t) else { continue };
let dst = forced.clone().unwrap_or_else(|| AutoParam::parse(&tr.view).unwrap_or(AutoParam::Volume));
let lane = tr.lane_mut(&dst);
lane.clear_range(at, at.saturating_add(cb.length));
for pt in &src.points {
lane.set_point(at.saturating_add(pt.at), map_value(pt.value, &src.param, &dst));
written += 1;
}
}
Ok(json!({"points": written}))
}
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(), 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 {
l.set_point(pt.at.saturating_add(r.len()), pt.value);
}
}
}
}
s.edit.selection = Range::new(r.start, r.end.saturating_add(r.len()));
if s.edit.link_timeline_edit {
s.edit.timeline_selection = s.edit.selection;
}
Ok(json!({"clips": n, "selection": s.edit.selection}))
}
fn extend_members(e: &mut Engine, up: bool) -> Result<Value> {
let s = e.session_mut();
let mut set: std::collections::BTreeSet<u64> = s.edit.selected_tracks.iter().map(|t| t.0).collect();
let mut roots: Vec<u64> = Vec::new();
for t in &s.edit.selected_tracks {
let Some(tr) = s.track(*t) else { continue };
if up {
if let Some(f) = tr.folder {
set.insert(f.0);
roots.push(f.0);
}
} else if tr.is_folder() {
roots.push(tr.id.0);
}
}
// Add members (recursively, bounded by the track count).
let mut guard = 0;
while let Some(f) = roots.pop() {
guard += 1;
if guard > s.tracks.len().saturating_mul(2) + 8 {
break;
}
for tr in s.tracks.iter().filter(|x| x.folder.is_some_and(|p| p.0 == f)) {
if set.insert(tr.id.0) && tr.is_folder() {
roots.push(tr.id.0);
}
}
}
s.edit.selected_tracks = s.tracks.iter().filter(|t| set.contains(&t.id.0)).map(|t| t.id).collect();
Ok(json!({"tracks": s.edit.selected_tracks}))
}
/// Marker ruler lanes (1..5) in the edit selection, kept in `selection.marker_lane.top/bottom`.
fn marker_lane(e: &mut Engine, dir: i64, extend: bool) -> Result<Value> {
let s = e.session_mut();
let top = s.edit.value("selection.marker_lane.top", 1.0).clamp(1.0, 5.0) as i64;
let bottom = (s.edit.value("selection.marker_lane.bottom", top as f64).clamp(1.0, 5.0) as i64).max(top);
let (top, bottom) = match (extend, dir < 0) {
(false, true) => ((top - 1).max(1), (top - 1).max(1)),
(false, false) => ((bottom + 1).min(5), (bottom + 1).min(5)),
(true, true) => ((top - 1).max(1), bottom),
(true, false) => (top, (bottom + 1).min(5)),
};
s.edit.values.insert("selection.marker_lane.top".into(), top as f64);
s.edit.values.insert("selection.marker_lane.bottom".into(), bottom as f64);
let r = s.edit.selection;
let markers: Vec<u32> = s
.markers
.iter()
.filter(|m| (top..=bottom).contains(&i64::from(m.ruler)) && (r.contains(m.start) || m.start == r.start))
.map(|m| m.number)
.collect();
Ok(json!({"lanes": [top, bottom], "markers": markers}))
}
fn space_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.space_clips";
let ids = clip_ids_param(e, p);
let gap = position_param(e, id, p, "gap")?.unwrap_or(0);
let s2s = match str_param(p, "mode") {
None | Some("end_to_start") => false,
Some("start_to_start") => true,
Some(m) => return Err(bad(id, format!("unknown mode `{m}`"))),
};
if ids.len() < 2 {
return Err(bad(id, "select two or more clips"));
}
let s = e.session_mut();
let track_ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
let mut moved = 0;
for t in track_ids {
let mut sel: Vec<Clip> = s
.track(t)
.map_or_else(Vec::new, |tr| tr.clips().iter().filter(|c| ids.contains(&c.id) && !c.time_locked && !c.edit_locked).cloned().collect());
if sel.len() < 2 {
continue;
}
sel.sort_by_key(|c| (c.start, c.id.0));
if let Some(pl) = s.track_mut(t).and_then(|tr| tr.playlist_mut()) {
pl.clips.retain(|c| !sel.iter().any(|x| x.id == c.id));
}
let mut cur = sel.first().map_or(0, |c| c.start);
for mut c in sel {
c.start = cur.max(0);
cur = if s2s { c.start.saturating_add(gap) } else { c.end().saturating_add(gap) };
ops::place_clip(s, t, c);
moved += 1;
}
}
Ok(json!({"clips": moved, "gap": gap}))
}
fn snap(e: &mut Engine, p: &Value, next: bool) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let mut order: Vec<(ClipId, Samples)> = ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start))).collect();
// Snap the clip nearest the destination first so neighbours stay put.
order.sort_by_key(|x| if next { -x.1 } else { x.1 });
let mut moved = 0;
for (id, _) in order {
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
// Selected neighbours are real destinations. Ignoring them made Snap to Next
// pile every selected clip onto the same later clip, and Snap to Previous
// leave the gap between the selected clips in place.
let others = s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id).cloned().collect::<Vec<_>>()).unwrap_or_default();
let delta = if next {
others.iter().filter(|x| x.start >= c.end()).map(|x| x.start).min().map(|st| st - c.end())
} else {
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, &[]);
}
}
Ok(json!({"moved": moved}))
}
fn to_playlist(e: &mut Engine, p: &Value, dest: Dest, mv: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let sh = shuffle(e.session());
let s = e.session_mut();
let mut out = Vec::new();
for t in &tracks {
if s.track(*t).is_none_or(|tr| !tr.kind.has_playlist()) {
continue;
}
let idx = match dest {
Dest::Target => {
let i = target_playlist(s, *t).ok_or_else(|| bad(id, "designate a target playlist first (Track › Designate as Target Playlist)"))?;
if s.track(*t).is_some_and(|tr| tr.active_playlist == i) {
return Err(bad(id, "the target playlist is the main playlist"));
}
i
}
Dest::New | Dest::Duplicate => {
let mut clips: Vec<Clip> =
if dest == Dest::Duplicate { s.track(*t).map(|tr| tr.clips().to_vec()).unwrap_or_default() } else { Vec::new() };
for c in &mut clips {
c.id = s.new_clip_id();
}
let Some(tr) = s.track_mut(*t) else { continue };
let name = format!("{}.{:02}", tr.name, tr.playlists.len() + 1);
tr.playlists.push(Playlist { name, clips });
tr.playlists.len() - 1
}
};
let clips = ops::copy_range(s, *t, r);
let main = s.track(*t).map_or(0, |tr| tr.active_playlist);
if let Some(tr) = s.track_mut(*t) {
tr.active_playlist = idx;
}
for mut c in clips {
c.id = s.new_clip_id();
c.start = c.start.saturating_add(r.start);
ops::place_clip(s, *t, c);
}
if let Some(tr) = s.track_mut(*t) {
tr.active_playlist = main;
}
if mv {
ops::clear_range(s, *t, r, sh);
}
out.push(json!({"track": t, "playlist": idx}));
}
Ok(json!({"tracks": out}))
}
fn tce(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.tce_to_timeline";
let target = {
let st = position_param(e, id, p, "start")?;
let en = position_param(e, id, p, "end")?;
match (st, en) {
(Some(a), Some(b)) => Range::new(a, b),
_ => e.session().edit.timeline_selection,
}
};
if target.is_empty() || target.start < 0 {
return Err(bad(id, "make a timeline selection first"));
}
let ids = clip_ids_param(e, p);
let Some((t, c)) = ids.iter().find_map(|cid| e.session().find_clip(*cid).filter(|(_, c)| c.is_audio()).map(|(t, c)| (t, c.clone()))) else {
return Err(bad(id, "select an audio clip"));
};
if c.time_locked || c.edit_locked {
return Err(bad(id, "the clip is locked"));
}
let s = e.session_mut();
let Some(src) = render_stretched(s, id, &c, target.len())? else { return Err(bad(id, "the clip's audio is not loaded")) };
let ratio = target.len() as f64 / c.length.max(1) as f64;
let scale = |x: Samples| soundcraft_time::to_samples(x as f64 * ratio);
let mut nc = c.clone();
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.start = target.start;
nc.length = target.len();
nc.stretch = 1.0;
nc.gain_env = c.gain_env.iter().map(|&(o, v)| (scale(o), v)).collect();
nc.fade_in.len = scale(c.fade_in.len).clamp(0, nc.length);
nc.fade_out.len = scale(c.fade_out.len).clamp(0, nc.length);
nc.sync_point = scale(c.sync_point).clamp(0, nc.length);
replace_clip(s, t, c.id, nc);
s.edit.selected_clips = vec![c.id];
Ok(json!({"clip": c.id, "source": src, "ratio": ratio}))
}
fn volume_at(lane: Option<&AutomationLane>, fader: f32, at: Samples) -> f32 {
match lane {
Some(l) if !l.points.is_empty() => l.value_at(at, fader),
_ => fader,
}
}
fn coalesce_volume_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.automation_coalesce_volume_to_clip_gain", p)?;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
if !tr.kind.has_playlist() || tr.kind.is_midi() {
continue;
}
let lane = tr.lane(&AutoParam::Volume).cloned();
let fader = tr.mixer.volume_db;
let Some(pl) = tr.playlist_mut() else { continue };
for c in pl.clips.iter_mut().filter(|c| c.is_audio()) {
let mut offs: Vec<Samples> = vec![0, c.length];
offs.extend(c.gain_env.iter().map(|x| x.0));
if let Some(l) = &lane {
offs.extend(l.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| pt.at - c.start));
}
offs.sort_unstable();
offs.dedup();
let pts: Vec<(Samples, f32)> = offs.iter().map(|&o| (o, clip_db(c, o) + volume_at(lane.as_ref(), fader, c.start + o))).collect();
set_clip_env(c, pts);
n += 1;
}
tr.automation.retain(|l| l.param != AutoParam::Volume);
tr.mixer.volume_db = 0.0;
}
Ok(json!({"clips": n}))
}
fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.automation_coalesce_clip_gain_to_volume", p)?;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let clips: Vec<Clip> = tr.clips().iter().filter(|c| c.is_audio() && (c.gain_db != 0.0 || !c.gain_env.is_empty())).cloned().collect();
if clips.is_empty() {
continue;
}
let lane = tr.lane(&AutoParam::Volume).cloned();
let fader = tr.mixer.volume_db;
let mut times: Vec<Samples> = lane.as_ref().map(|l| l.points.iter().map(|pt| pt.at).collect()).unwrap_or_default();
for c in &clips {
times.push(c.start);
times.push(c.end() - 1);
times.extend(c.gain_env.iter().map(|x| c.start + x.0));
}
times.sort_unstable();
times.dedup();
let gain_at = |at: Samples| clips.iter().find(|c| c.range().contains(at)).map_or(0.0, |c| clip_db(c, at - c.start));
let pts: Vec<(Samples, f32)> = times.iter().map(|&at| (at, volume_at(lane.as_ref(), fader, at) + gain_at(at))).collect();
let l = tr.lane_mut(&AutoParam::Volume);
l.points.clear();
for (at, v) in pts {
l.set_point(at.max(0), v);
}
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| clips.iter().any(|x| x.id == c.id)) {
c.gain_db = 0.0;
c.gain_env.clear();
n += 1;
}
}
}
Ok(json!({"clips": n}))
}
fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let s = e.session_mut();
let mut lanes = 0;
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let mut params: Vec<AutoParam> = tr
.automation
.iter()
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
.map(|l| l.param.clone())
.collect();
if pan_only {
for i in 0..tr.mixer.pan.len().min(2) {
let pp = AutoParam::Pan(i as u8);
if !params.contains(&pp) {
params.push(pp);
}
}
}
for param in params {
let cur = super::mix::current_value(tr, &param);
let (lo, hi, _) = param.range();
let l = tr.lane_mut(&param);
let before = l.value_at(r.start - 1, cur);
let after = l.value_at(r.end, cur);
let from = l.value_at(r.start, cur);
if glide {
l.clear_range(r.start, r.end.saturating_add(2));
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end, cur);
l.set_point(r.end.saturating_add(1), after);
} else {
let delta = cur - from;
let last = l.value_at(r.end - 1, cur);
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end - 1, last);
l.set_point(r.end, after);
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
pt.value = (pt.value + delta).clamp(lo, hi);
}
}
lanes += 1;
}
}
Ok(json!({"lanes": lanes}))
}
fn has_marker_clipboard(e: &Engine) -> std::result::Result<(), String> {
if e.clipboard.markers.is_empty() { Err("no markers on the clipboard".into()) } else { Ok(()) }
}
fn paste_merge_markers(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.paste_merge_markers";
if e.clipboard.markers.is_empty() {
return Err(bad(id, "no markers on the clipboard (copy a range that contains markers)"));
}
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
let copied = e.clipboard.markers.clone();
let s = e.session_mut();
let mut added = 0;
for m in copied {
let start = at.saturating_add(m.start);
if s.markers.iter().any(|x| x.start == start && x.name == m.name && x.ruler == m.ruler) {
continue;
}
let new = s.add_marker(&m.name, m.kind, start, start.saturating_add(m.end - m.start));
if let Some(x) = s.markers.iter_mut().find(|x| x.id == new) {
x.comments = m.comments;
x.color = m.color;
x.ruler = m.ruler;
}
added += 1;
}
Ok(json!({"added": added}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, SourceId, TrackKind};
#[test]
fn merge_markers_pastes_copied_markers_once() {
let mut e = crate::demo::demo_engine();
let first = e.session().markers.first().cloned().expect("demo has markers");
let r = json!({"start": first.start.saturating_sub(10).max(0), "end": first.start + 10});
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": r["start"], "end": r["end"]})).unwrap();
e.execute("edit.copy", &json!({})).unwrap();
let before = e.session().markers.len();
let at = 5_000_000;
let out = e.execute("edit.paste_merge_markers", &json!({"at": at})).unwrap();
assert_eq!(out["added"], 1);
assert_eq!(e.session().markers.len(), before + 1);
assert!(e.session().markers.iter().any(|m| m.name == first.name && m.start == at + first.start - r["start"].as_i64().unwrap()));
// Pasting again at the same place adds nothing.
assert_eq!(e.execute("edit.paste_merge_markers", &json!({"at": at})).unwrap()["added"], 0);
}
fn session_with_clips(starts: &[(Samples, Samples)]) -> (Engine, soundcraft_model::TrackId, Vec<ClipId>) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut ids = Vec::new();
for (st, len) in starts {
let id = s.new_clip_id();
ops::place_clip(&mut s, t, Clip::audio(id, "c", SourceId(1), 0, *st, *len));
ids.push(id);
}
s.edit.selected_tracks = vec![t];
(Engine::new(s), t, ids)
}
fn starts(e: &Engine, t: soundcraft_model::TrackId) -> Vec<Samples> {
e.session().track(t).map(|tr| tr.clips().iter().map(|c| c.start).collect()).unwrap_or_default()
}
#[test]
fn space_clips_butts_with_gap() {
let (mut e, t, ids) = session_with_clips(&[(0, 100), (500, 100), (2000, 50)]);
e.execute("edit.space_clips", &json!({"clips": ids.iter().map(|c| c.0).collect::<Vec<_>>(), "gap": 10})).unwrap();
assert_eq!(starts(&e, t), vec![0, 110, 220]);
assert!(e.can_undo());
}
#[test]
fn snap_next_and_previous() {
let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
e.execute("edit.snap_previous", &json!({"clip": ids[1].0})).unwrap();
assert_eq!(starts(&e, t), vec![0, 100, 1000]);
e.execute("edit.snap_next", &json!({"clip": ids[1].0})).unwrap();
assert_eq!(starts(&e, t), vec![0, 900, 1000]);
}
#[test]
fn cut_all_automation_can_be_pasted() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 100, "value": -6.0})).unwrap();
e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 400, "value": -12.0})).unwrap();
let cut = e.execute("edit.cut_all_automation", &json!({"start": 50, "end": 250})).unwrap();
assert!(cut["lanes"].as_u64().unwrap() >= 1, "{cut}");
assert!(e.clipboard.automation.iter().any(|lanes| !lanes.is_empty()));
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
assert!(lane.points.iter().all(|p| p.at != 100), "the cut point is gone: {:?}", lane.points);
e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap();
let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
assert!(vol.points.iter().any(|p| p.at >= 1000 && (p.value + 6.0).abs() < 1e-2), "paste wrote nothing at -6 dB: {:?}", vol.points);
}
#[test]
fn snap_selected_neighbours_keeps_both_clips() {
let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
e.execute("edit.snap_next", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
assert_eq!(starts(&e, t), vec![800, 900, 1000]);
let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]);
e.execute("edit.snap_previous", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap();
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
assert_eq!(starts(&e, t), vec![0, 100, 1000]);
}
#[test]
fn copy_and_paste_automation_only() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
e.execute("automation.set_point", &json!({"track": t.0, "param": "pan", "at": 100, "value": -1.0})).unwrap();
e.execute("automation.set_point", &json!({"track": t.0, "param": "pan", "at": 200, "value": 1.0})).unwrap();
e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 150, "value": -6.0})).unwrap();
e.execute("edit.cut_pan_automation", &json!({"start": 50, "end": 250})).unwrap();
assert_eq!(e.clipboard.automation[0].len(), 1, "only the pan lane is cut");
assert!(e.session().track(t).unwrap().lane(&AutoParam::Pan(0)).unwrap().points.is_empty());
assert_eq!(e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().points.len(), 1);
// Paste pan into the volume lane, rescaled from -1..1 to -144..12.
e.execute("track.view", &json!({"view": "volume"})).unwrap();
e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap();
let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
assert!((vol.value_at(1150, 0.0) - 12.0).abs() < 1e-3, "{:?}", vol.points);
assert!((vol.value_at(1050, 0.0) + 144.0).abs() < 1e-3);
}
#[test]
fn clip_gain_copy_paste() {
let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000)]);
e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap();
e.execute("edit.copy_clip_gain", &json!({"start": 0, "end": 1000})).unwrap();
e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap();
let c = e.session().track(t).unwrap().clips()[1].clone();
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn cut_clip_gain_can_be_pasted() {
let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]);
e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap();
e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap();
// An older copy sits in the clipboard: the paste must get the cut gain, not this one.
e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap();
e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap();
e.execute("edit.cut_clip_gain", &json!({})).unwrap();
let src = e.session().track(t).unwrap().clips()[0].clone();
assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}");
e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap();
let c = e.session().track(t).unwrap().clips()[1].clone();
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn move_to_new_playlist_clears_main() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
e.execute("edit.move_to_new_playlist", &json!({"start": 200, "end": 400})).unwrap();
let tr = e.session().track(t).unwrap();
assert_eq!(tr.playlists.len(), 2);
assert_eq!(tr.active_playlist, 0);
assert_eq!(tr.playlists[1].clips.len(), 1);
assert_eq!(tr.playlists[1].clips[0].range(), Range::new(200, 400));
assert!(tr.clips().iter().all(|c| !c.range().overlaps(&Range::new(200, 400))));
// Target playlist needs a designation first.
assert!(e.execute("edit.copy_to_target_playlist", &json!({"start": 0, "end": 100})).is_err());
e.execute("track.playlist_select", &json!({"index": 1})).unwrap();
e.execute("track.designate_target_playlist", &json!({})).unwrap();
e.execute("track.playlist_select", &json!({"index": 0})).unwrap();
e.execute("edit.copy_to_target_playlist", &json!({"start": 0, "end": 100})).unwrap();
assert_eq!(e.session().track(t).unwrap().playlists[1].clips.len(), 2);
}
#[test]
fn coalesce_volume_into_clip_gain_and_back() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
e.execute("automation.set_point", &json!({"track": t.0, "at": 0, "value": -10.0})).unwrap();
e.execute("automation.set_point", &json!({"track": t.0, "at": 1000, "value": 0.0})).unwrap();
e.execute("edit.automation_coalesce_volume_to_clip_gain", &json!({})).unwrap();
let c = e.session().track(t).unwrap().clips()[0].clone();
assert!((clip_db(&c, 500) + 5.0).abs() < 1e-3);
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none());
e.execute("edit.automation_coalesce_clip_gain_to_volume", &json!({})).unwrap();
let tr = e.session().track(t).unwrap();
assert!((tr.lane(&AutoParam::Volume).unwrap().value_at(500, 0.0) + 5.0).abs() < 0.1);
assert_eq!(tr.clips()[0].gain_db, 0.0);
}
#[test]
fn glide_and_trim() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);
e.execute("automation.set_point", &json!({"track": t.0, "at": 0, "value": -10.0})).unwrap();
e.execute("mix.volume", &json!({"db": 0.0})).unwrap();
e.execute("edit.automation_glide_to_all", &json!({"start": 100, "end": 300})).unwrap();
let l = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
assert!((l.value_at(200, 0.0) + 5.0).abs() < 0.1);
assert!((l.value_at(600, 0.0) + 10.0).abs() < 1e-3, "after the glide the old curve resumes");
e.execute("edit.automation_trim_to_all", &json!({"start": 400, "end": 500})).unwrap();
let l = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone();
assert!((l.value_at(450, 0.0)).abs() < 1e-3);
assert!((l.value_at(700, 0.0) + 10.0).abs() < 1e-3);
}
#[test]
fn merge_midi_keeps_existing_notes() {
let mut e = crate::demo::demo_engine();
let keys = e.session().track_by_name("Keys").unwrap().id;
let clip = e.session().track(keys).unwrap().clips()[0].clone();
let before = match &clip.content {
ClipContent::Midi { sequence } => sequence.notes.len(),
_ => 0,
};
let r = Range::new(clip.start, clip.start + 48_000);
e.execute("edit.select", &json!({"tracks": [keys.0], "start": r.start, "end": r.end})).unwrap();
e.execute("edit.copy", &json!({})).unwrap();
let copied = match &e.clipboard.tracks[0][0].content {
ClipContent::Midi { sequence } => sequence.notes.len(),
_ => 0,
};
e.execute("edit.paste_merge_midi", &json!({"at": clip.start + 96_000})).unwrap();
let after = match &e.session().track(keys).unwrap().clips()[0].content {
ClipContent::Midi { sequence } => sequence.notes.len(),
_ => 0,
};
assert_eq!(after, before + copied);
}
#[test]
fn tce_fits_timeline() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..24_000).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = s.new_clip_id();
ops::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 24_000));
s.edit.selected_tracks = vec![t];
s.edit.selected_clips = vec![id];
s.edit.timeline_selection = Range::new(1000, 31_000);
let mut e = Engine::new(s);
let r = e.execute("edit.tce_to_timeline", &json!({})).unwrap();
assert!((r["ratio"].as_f64().unwrap() - 1.25).abs() < 1e-9);
let nc = e.session().find_clip(id).unwrap().1.clone();
assert_eq!((nc.start, nc.length), (1000, 30_000));
let nsrc = nc.source().unwrap();
assert_ne!(nsrc, src);
assert_eq!(e.session().source(nsrc).unwrap().frames, 30_000);
assert!(e.session().pool.get(nsrc).unwrap().buffer.channels[0].iter().any(|v| v.abs() > 0.1));
}
#[test]
fn extend_to_members() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
e.execute("track.move_to_new_folder", &json!({})).unwrap();
let folder = e.session().tracks.iter().find(|t| t.is_folder()).unwrap().id;
e.execute("edit.select", &json!({"tracks": [folder.0]})).unwrap();
e.execute("edit.extend_down_to_members", &json!({})).unwrap();
assert_eq!(e.session().edit.selected_tracks.len(), 3);
let member = e.session().tracks.iter().find(|t| t.folder == Some(folder)).unwrap().id;
e.execute("edit.select", &json!({"tracks": [member.0]})).unwrap();
e.execute("edit.extend_up_to_members", &json!({})).unwrap();
assert_eq!(e.session().edit.selected_tracks.len(), 3);
}
#[test]
fn marker_lane_selection() {
let mut e = Engine::default();
e.execute("edit.marker_lane_move_down", &json!({})).unwrap();
e.execute("edit.marker_lane_extend_down", &json!({})).unwrap();
let r = e.execute("edit.marker_lane_extend_up", &json!({})).unwrap();
assert_eq!(r["lanes"], json!([1, 3]));
let r = e.execute("edit.marker_lane_move_up", &json!({})).unwrap();
assert_eq!(r["lanes"], json!([1, 1]));
}
#[test]
fn duplicate_and_extend() {
let (mut e, t, _) = session_with_clips(&[(0, 100)]);
e.execute("edit.duplicate_extend", &json!({"start": 0, "end": 100})).unwrap();
assert_eq!(starts(&e, t), vec![0, 100]);
assert_eq!(e.session().edit.selection, Range::new(0, 200));
}
}