Files
soundcraft/crates/engine/src/cmd/track.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

1116 lines
50 KiB
Rust

//! Track menu commands.
use super::{
CommandSpec, always, bad, bool_or, has_selection, has_tracks, i64_or, range_param, str_param, track_param, tracks_param, tracks_required,
};
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"track.new",
"New...",
["Track"],
Some("Cmd+Shift+N"),
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}",
always,
new_track
),
cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group),
cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group),
cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group),
cmd!("track.group_modify", "Modify Group", [], None, "{group: id|name, name?, tracks?, edit?, mix?}", always, modify_group),
cmd!(
"track.duplicate",
"Duplicate...",
["Track"],
Some("Alt+Shift+D"),
"{tracks?, playlists?: true, automation?: true, inserts?: true, sends?: true}",
has_selection,
duplicate
),
cmd!("track.split_into_mono", "Split into Mono", ["Track"], None, "{tracks?}", has_selection, split_mono),
cmd!("track.make_inactive", "Make Inactive", ["Track"], None, "{tracks?, inactive?: bool}", has_selection, |e, p| flag(
e,
p,
"track.make_inactive",
|t, v| t.inactive = v,
|t| t.inactive
)),
cmd!(
"track.move_to_new_folder",
"Move to New Folder...",
["Track"],
Some("Cmd+Alt+Shift+N"),
"{tracks?, name?}",
has_selection,
move_to_folder
),
cmd!("track.move_to_folder", "Move to Folder", [], None, "{tracks?, folder}", has_selection, move_into_folder),
cmd!("track.remove_from_folder", "Remove from Folder", [], None, "{tracks?}", has_selection, remove_from_folder),
cmd!(
"track.change_width",
"Change Track Width",
["Track", "Change Track Width"],
None,
"{tracks?, format: mono|stereo|…}",
has_selection,
change_width
),
cmd!("track.delete", "Delete", ["Track"], None, "{tracks?}", has_selection, delete),
cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename),
cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments),
cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color),
cmd!(
"track.height",
"Track Height",
[],
None,
"{tracks?, height: micro|mini|small|medium|large|jumbo|extreme | points}",
has_selection,
height
),
cmd!("track.height_up", "Increase Track Height", [], Some("Ctrl+Up"), "{tracks?} — one preset taller", has_selection, |e, p| height_step(
e, p, true
)),
cmd!("track.height_down", "Decrease Track Height", [], Some("Ctrl+Down"), "{tracks?} — one preset shorter", has_selection, |e, p| {
height_step(e, p, false)
}),
cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|<automation id>}", has_selection, view),
cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag(
e,
p,
"track.hide",
|t, v| t.hidden = v,
|t| t.hidden
)),
cmd!("track.pin", "Pin Track", [], None, "{tracks?}", has_selection, |e, p| flag(e, p, "track.pin", |t, v| t.pinned = v, |t| t.pinned)),
cmd!("track.move", "Move Track", [], None, "{track, to: index}", has_tracks, move_track),
cmd!("track.freeze", "Freeze", ["Track"], None, "{tracks?}", has_selection, |e, p| flag(
e,
p,
"track.freeze",
|t, v| t.frozen = v,
|t| t.frozen
)),
cmd!("track.commit", "Commit...", ["Track"], Some("Alt+Shift+C"), "{tracks?}", has_selection, commit),
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.session_mut();
let mut n = 0;
for t in &mut s.tracks {
if t.mixer.record_arm {
t.mixer.input_monitor = true;
n += 1;
}
}
Ok(json!({"tracks": n}))
}),
cmd!(noundo "track.scroll_to", "Scroll to Track...", ["Track"], Some("Cmd+Alt+F"), "{track}", has_tracks, |e, p| {
let t = track_param(e, "track.scroll_to", p, "track")?.ok_or_else(|| bad("track.scroll_to", "`track` required"))?;
let s = e.session_mut();
s.edit.selected_tracks = vec![t];
Ok(json!({"track": t}))
}),
cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))),
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, super::track_more::clear_trim),
cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click),
cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)),
cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)),
cmd!("track.playlist_select", "Select Playlist", [], None, "{track?, index | name}", has_selection, select_playlist),
cmd!("track.playlist_delete_unused", "Delete Unused Playlists", [], None, "{track?}", has_selection, |e, p| {
let tracks = tracks_required(e, "track.playlist_delete_unused", p)?;
let s = e.session_mut();
let mut n = 0;
for t in tracks {
if let Some(tr) = s.track_mut(t)
&& let Some(active) = tr.playlists.get(tr.active_playlist).cloned()
{
n += tr.playlists.len() - 1;
tr.playlists = vec![active];
tr.active_playlist = 0;
}
}
Ok(json!({"deleted": n}))
}),
cmd!(noundo "track.folder_toggle", "Open/Close Folder", [], None, "{track, open?: bool}", has_tracks, |e, p| {
let t = track_param(e, "track.folder_toggle", p, "track")?.ok_or_else(|| bad("track.folder_toggle", "`track` required"))?;
let v = p.get("open").and_then(Value::as_bool);
let s = e.session_mut();
let tr = s.track_mut(t).ok_or_else(|| bad("track.folder_toggle", "no track"))?;
tr.folder_open = v.unwrap_or(!tr.folder_open);
Ok(json!({"open": tr.folder_open}))
}),
cmd!("track.vca", "Assign to VCA", [], None, "{tracks?, vca: VCA track id|name | null}", has_selection, |e, p| {
let tracks = tracks_required(e, "track.vca", p)?;
let vca = match p.get("vca") {
Some(Value::Null) => None,
_ => track_param(e, "track.vca", p, "vca")?,
};
if let Some(v) = vca
&& e.session().track(v).is_none_or(|t| t.kind != TrackKind::Vca)
{
return Err(bad("track.vca", "`vca` must be a VCA Master track"));
}
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.mixer.vca = vca.map(|v| v.0);
}
}
Ok(json!({"vca": vca}))
}),
cmd!(
"track.playlist_promote",
"Promote to Main Playlist",
[],
None,
"{track, playlist: index, start?, end?} (copy that range from an alternate playlist into the active one)",
has_tracks,
|e, p| {
let t = track_param(e, "track.playlist_promote", p, "track")?.ok_or_else(|| bad("track.playlist_promote", "`track` required"))?;
let idx =
p.get("playlist").and_then(Value::as_u64).ok_or_else(|| bad("track.playlist_promote", "`playlist` index required"))? as usize;
let r = range_param(e, "track.playlist_promote", p)?;
if r.is_empty() {
return Err(bad("track.playlist_promote", "give `start` and `end`"));
}
let s = e.session_mut();
let (src, active) = {
let tr = s.track(t).ok_or_else(|| bad("track.playlist_promote", "no track"))?;
(tr.playlists.get(idx).cloned().ok_or_else(|| bad("track.playlist_promote", "no such playlist"))?, tr.active_playlist)
};
if idx == active {
return Err(bad("track.playlist_promote", "that playlist is already active"));
}
// Copy the clips of the source playlist inside the range (via a temporary active switch).
if let Some(tr) = s.track_mut(t) {
tr.active_playlist = idx;
}
let clips = crate::edit::copy_range(s, t, r);
if let Some(tr) = s.track_mut(t) {
tr.active_playlist = active;
}
let _ = src;
let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false).unwrap_or_default();
Ok(json!({"clips": ids}))
}
),
cmd!("track.input", "Track Input", [], None, "{tracks?, input: none|bus name|hardware name}", has_selection, |e, p| route(e, p, true)),
cmd!("track.output", "Track Output", [], None, "{tracks?, output: main|none|bus name}", has_selection, |e, p| route(e, p, false)),
cmd!("track.timebase", "Track Timebase", [], None, "{tracks?, ticks: bool}", has_selection, |e, p| flag(
e,
p,
"track.timebase",
|t, v| t.ticks_timebase = v,
|t| t.ticks_timebase
)),
cmd!(
"track.elastic",
"Elastic Audio",
[],
None,
"{tracks?, algorithm?: polyphonic|rhythmic|monophonic|varispeed|x-form|none}",
has_selection,
|e, p| {
let tracks = tracks_required(e, "track.elastic", p)?;
let alg = str_param(p, "algorithm").filter(|a| *a != "none").map(str::to_string);
let s = e.session_mut();
for t in tracks {
if let Some(tr) = s.track_mut(t) {
tr.elastic = alg.clone();
}
}
Ok(json!({"algorithm": alg}))
}
),
]
}
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
let count = i64_or(p, "count", 1).clamp(1, 128);
let kind = match str_param(p, "kind") {
Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?,
None => TrackKind::Audio,
};
let default_fmt = if matches!(kind, TrackKind::Master | TrackKind::Aux | TrackKind::Instrument | TrackKind::Folder) {
ChannelFormat::Stereo
} else {
ChannelFormat::Mono
};
let format = match str_param(p, "format") {
Some(f) => ChannelFormat::from_id(f).ok_or_else(|| bad("track.new", format!("unknown format `{f}`")))?,
None => default_fmt,
};
let name = str_param(p, "name").map(str::to_string);
let ticks = str_param(p, "timebase").map(|t| t == "ticks");
let instrument = str_param(p, "instrument").map(str::to_string);
let s = e.session_mut();
let mut ids = Vec::new();
for _ in 0..count {
let id = s.add_track(kind, format, name.as_deref());
if let Some(t) = s.track_mut(id) {
if let Some(tb) = ticks {
t.ticks_timebase = tb;
}
if kind == TrackKind::Instrument {
t.instrument = Some(soundcraft_model::Insert::new(instrument.clone().unwrap_or_else(|| "subtractive_synth".into())));
}
}
if kind == TrackKind::Folder {
let folder_name = s.track(id).map(|track| track.name.clone()).unwrap_or_else(|| "Folder".into());
let bus_name = unique_folder_bus_name(s, &format!("{folder_name} Bus"), None);
let bus = s.add_bus(&bus_name, format);
if let Some(track) = s.track_mut(id) {
track.mixer.input = Route::Bus(bus);
}
}
ids.push(id);
}
s.edit.selected_tracks = ids.clone();
Ok(json!({"tracks": ids}))
}
fn group(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.group", p)?;
let s = e.session_mut();
let id = GroupId(s.alloc());
let letter = (b'a'..=b'z').map(char::from).find(|c| !s.groups.iter().any(|g| g.letter == *c)).unwrap_or('z');
let name = str_param(p, "name").map_or_else(|| format!("Group {}", s.groups.len() + 1), str::to_string);
let color = s.next_track_color();
s.groups.push(Group {
id,
name,
letter,
members: tracks,
edit: bool_or(p, "edit", true),
mix: bool_or(p, "mix", true),
active: true,
color,
attributes: vec!["volume".into(), "mute".into(), "solo".into(), "record".into()],
});
Ok(json!({"group": id}))
}
fn find_group(e: &Engine, p: &Value, cmd: &str) -> Result<GroupId> {
let s = e.session();
let g = match (p.get("group").and_then(Value::as_u64), str_param(p, "group")) {
(Some(n), _) => s.groups.iter().find(|g| g.id.0 == n),
(_, Some(name)) => s.groups.iter().find(|g| g.name == name || g.letter.to_string() == name),
_ => None,
};
g.map(|g| g.id).ok_or_else(|| bad(cmd, "unknown `group`"))
}
fn delete_group(e: &mut Engine, p: &Value) -> Result<Value> {
let g = find_group(e, p, "track.ungroup")?;
e.session_mut().groups.retain(|x| x.id != g);
Ok(json!({}))
}
fn toggle_group(e: &mut Engine, p: &Value) -> Result<Value> {
let g = find_group(e, p, "track.group_toggle")?;
let s = e.session_mut();
let mut active = false;
if let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) {
gr.active = !gr.active;
active = gr.active;
}
Ok(json!({"active": active}))
}
/// Change an existing group's name, membership and Edit/Mix linking. Omitted fields keep their value.
fn modify_group(e: &mut Engine, p: &Value) -> Result<Value> {
const CMD: &str = "track.group_modify";
let g = find_group(e, p, CMD)?;
// Absent `tracks` leaves membership alone; present, it replaces it. Only an array counts, so a
// mistyped param can't silently swap the members for whatever happens to be selected.
let members = match p.get("tracks") {
None => None,
Some(Value::Array(_)) => {
let mut t = tracks_param(e, CMD, p)?;
// A track listed twice would take every linked move twice over.
let mut seen = std::collections::HashSet::new();
t.retain(|id| seen.insert(*id));
if t.is_empty() {
return Err(bad(CMD, "a group needs at least one track"));
}
Some(t)
}
Some(_) => return Err(bad(CMD, "`tracks` must be an array of track ids or names")),
};
let name = match str_param(p, "name") {
None => None,
Some(n) => {
let n = n.trim();
if n.is_empty() {
return Err(bad(CMD, "`name` must not be empty"));
}
// `find_group` resolves by name, so duplicates would make a group unaddressable.
if e.session().groups.iter().any(|x| x.name == n && x.id != g) {
return Err(bad(CMD, format!("a group named `{n}` already exists")));
}
let mut n = n.to_string();
soundcraft_model::truncate_chars(&mut n, soundcraft_model::MAX_NAME_CHARS);
Some(n)
}
};
let s = e.session_mut();
let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) else { return Err(bad(CMD, "unknown `group`")) };
if let Some(n) = name {
gr.name = n;
}
if let Some(m) = members {
gr.members = m;
}
gr.edit = bool_or(p, "edit", gr.edit);
gr.mix = bool_or(p, "mix", gr.mix);
Ok(json!({"group": g, "name": gr.name, "tracks": gr.members, "edit": gr.edit, "mix": gr.mix}))
}
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.duplicate", p)?;
let (pl, auto, ins, snd) =
(bool_or(p, "playlists", true), bool_or(p, "automation", true), bool_or(p, "inserts", true), bool_or(p, "sends", true));
let s = e.session_mut();
let mut out = Vec::new();
for t in tracks {
let Some(idx) = s.track_index(t) else { continue };
let Some(mut nt) = s.tracks.get(idx).cloned() else { continue };
nt.id = soundcraft_model::TrackId(s.alloc());
nt.name = s.unique_track_name(&format!("{}.dup", nt.name));
if !pl {
for p in &mut nt.playlists {
p.clips.clear();
}
} else {
for p in &mut nt.playlists {
for c in &mut p.clips {
c.id = s.new_clip_id();
}
}
}
if !auto {
nt.automation.clear();
}
if !ins {
nt.mixer.inserts.iter_mut().for_each(|i| *i = None);
}
if !snd {
nt.mixer.sends.iter_mut().for_each(|i| *i = None);
}
out.push(nt.id);
s.tracks.insert((idx + 1).min(s.tracks.len()), nt);
}
s.edit.selected_tracks = out.clone();
Ok(json!({"tracks": out}))
}
fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.split_into_mono", p)?;
let s = e.session_mut();
let mut out = Vec::new();
for t in tracks {
let Some(src) = s.track(t).cloned() else { continue };
if src.channels() < 2 || src.kind != TrackKind::Audio {
continue;
}
for ch in 0..src.channels() {
let suffix = match (src.channels(), ch) {
(2, 0) => "L".to_string(),
(2, 1) => "R".to_string(),
_ => format!("{}", ch + 1),
};
let id = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("{}.{suffix}", src.name)));
let mut clips = src.clips().to_vec();
for c in &mut clips {
c.id = s.new_clip_id();
// Mono tracks read channel 0 of the source; record the channel in the name for now.
c.name = format!("{}.{suffix}", c.name);
}
if let Some(tr) = s.track_mut(id) {
tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }];
tr.comments = format!("source_channel={ch}");
if let Some(pl) = tr.playlist_mut() {
pl.clips = clips;
}
}
out.push(id);
}
}
Ok(json!({"tracks": out}))
}
fn flag(
e: &mut Engine,
p: &Value,
cmd: &str,
set: fn(&mut soundcraft_model::Track, bool),
get: fn(&soundcraft_model::Track) -> bool,
) -> Result<Value> {
let tracks = tracks_required(e, cmd, p)?;
let explicit = p.get("inactive").or_else(|| p.get("hidden")).or_else(|| p.get("value")).or_else(|| p.get("ticks")).and_then(Value::as_bool);
let s = e.session_mut();
let target = explicit.unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
set(tr, target);
}
}
Ok(json!({"value": target, "tracks": tracks}))
}
/// A routing folder owns its summing bus; never reuse a user bus with the same name.
fn unique_folder_bus_name(s: &Session, base: &str, own: Option<soundcraft_model::BusId>) -> String {
for suffix in 1..=s.busses.len().saturating_add(2) {
let candidate = if suffix == 1 { base.to_string() } else { format!("{base} {suffix}") };
if !s.busses.iter().any(|bus| bus.name == candidate && Some(bus.id) != own) {
return candidate;
}
}
format!("{base} {}", s.busses.len().saturating_add(3))
}
fn move_to_folder(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.move_to_new_folder", p)?;
let name = str_param(p, "name").map(str::to_string);
let s = e.session_mut();
let first_idx = tracks.iter().filter_map(|t| s.track_index(*t)).min().unwrap_or(0);
let fid = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, Some(name.as_deref().unwrap_or("Folder")));
// Place the folder above its first member.
if let Some(i) = s.track_index(fid) {
let f = s.tracks.remove(i);
s.tracks.insert(first_idx.min(s.tracks.len()), f);
}
// A routing folder sums its members through its own bus.
let fname = s.track(fid).map(|x| x.name.clone()).unwrap_or_else(|| "Folder".into());
let bus_name = unique_folder_bus_name(s, &format!("{fname} Bus"), None);
let bus = s.add_bus(&bus_name, ChannelFormat::Stereo);
if let Some(f) = s.track_mut(fid) {
f.mixer.input = Route::Bus(bus);
}
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.folder = Some(fid);
if tr.mixer.output == Route::Main {
tr.mixer.output = Route::Bus(bus);
}
}
}
place_under(s, fid, &tracks);
Ok(json!({"folder": fid, "bus": bus}))
}
/// Whether `track` sits inside `folder`, directly or through nested folders.
fn in_folder(s: &Session, track: TrackId, folder: TrackId) -> bool {
let mut current = s.track(track).and_then(|track| track.folder);
// The depth cap guards against a cyclic folder chain in a hostile session file.
for _ in 0..64 {
match current {
Some(parent) if parent == folder => return true,
Some(parent) => current = s.track(parent).and_then(|track| track.folder),
None => return false,
}
}
false
}
/// Keep a folder's members together immediately below it, preserving their relative order.
fn place_under(s: &mut Session, folder: TrackId, tracks: &[TrackId]) {
// Moving a folder also moves its complete subtree as one stable block.
let moved: Vec<_> = s
.tracks
.iter()
.filter(|track| track.id != folder && (tracks.contains(&track.id) || tracks.iter().any(|root| in_folder(s, track.id, *root))))
.cloned()
.collect();
let moved_ids: Vec<_> = moved.iter().map(|track| track.id).collect();
s.tracks.retain(|track| !moved_ids.contains(&track.id));
let ids: Vec<TrackId> = s.tracks.iter().map(|track| track.id).collect();
let Some(folder_index) = ids.iter().position(|track| *track == folder) else { return };
let mut insert_at = folder_index + 1;
while ids.get(insert_at).is_some_and(|track| in_folder(s, *track, folder)) {
insert_at += 1;
}
for (offset, track) in moved.into_iter().enumerate() {
s.tracks.insert((insert_at + offset).min(s.tracks.len()), track);
}
}
fn folder_bus(s: &Session, folder: TrackId) -> Option<Route> {
s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_)))
}
/// The summing bus owned by a routing folder.
fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::BusId> {
let folder = s.track(folder).filter(|track| track.kind == TrackKind::Folder)?;
match folder.mixer.input {
Route::Bus(id) => Some(id),
_ => None,
}
}
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
}
fn move_into_folder(e: &mut Engine, p: &Value) -> Result<Value> {
let folder = track_param(e, "track.move_to_folder", p, "folder")?.ok_or_else(|| bad("track.move_to_folder", "`folder` required"))?;
let tracks = tracks_required(e, "track.move_to_folder", p)?;
let session = e.session_mut();
if !session.track(folder).is_some_and(Track::is_folder) {
return Err(bad("track.move_to_folder", "`folder` is not a folder track"));
}
// A folder cannot go inside itself or one of its descendants.
let tracks: Vec<TrackId> = tracks.into_iter().filter(|track| *track != folder && !in_folder(session, folder, *track)).collect();
let tracks = root_tracks(session, tracks);
if tracks.is_empty() {
return Err(bad("track.move_to_folder", "nothing to move into that folder"));
}
let bus = folder_bus(session, folder);
for track in &tracks {
let old_bus = session.track(*track).and_then(|track| track.folder).and_then(|parent| folder_bus(session, parent));
if let Some(moved) = session.track_mut(*track) {
moved.folder = Some(folder);
if let Some(bus) = &bus
&& (moved.mixer.output == Route::Main || Some(&moved.mixer.output) == old_bus.as_ref())
{
moved.mixer.output = bus.clone();
}
}
}
place_under(session, folder, &tracks);
Ok(json!({"folder": folder, "moved": tracks.len()}))
}
fn remove_from_folder(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.remove_from_folder", p)?;
let session = e.session_mut();
let tracks = root_tracks(session, tracks);
let mut removed = 0;
for track in &tracks {
let Some(folder) = session.track(*track).and_then(|track| track.folder) else { continue };
let bus = folder_bus(session, folder);
let parent = session.track(folder).and_then(|folder| folder.folder);
let parent_bus = parent.and_then(|parent| folder_bus(session, parent));
if let Some(moved) = session.track_mut(*track) {
moved.folder = parent;
if bus.as_ref() == Some(&moved.mixer.output) {
moved.mixer.output = parent_bus.unwrap_or(Route::Main);
}
removed += 1;
}
let moved: Vec<_> =
session.tracks.iter().filter(|candidate| candidate.id == *track || in_folder(session, candidate.id, *track)).cloned().collect();
let moved_ids: Vec<_> = moved.iter().map(|candidate| candidate.id).collect();
session.tracks.retain(|candidate| !moved_ids.contains(&candidate.id));
if let Some(folder_index) = session.track_index(folder) {
let mut insert_at = folder_index + 1;
while session.tracks.get(insert_at).is_some_and(|candidate| in_folder(session, candidate.id, folder)) {
insert_at += 1;
}
for (offset, moved) in moved.into_iter().enumerate() {
session.tracks.insert((insert_at + offset).min(session.tracks.len()), moved);
}
}
}
Ok(json!({"removed": removed}))
}
fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.change_width", p)?;
let f = str_param(p, "format").and_then(ChannelFormat::from_id).ok_or_else(|| bad("track.change_width", "`format` required"))?;
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.format = f;
tr.mixer.pan = soundcraft_model::Mixer::new(f).pan;
}
}
Ok(json!({"format": f}))
}
fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.delete", p)?;
let s = e.session_mut();
let removed_busses: Vec<_> = tracks
.iter()
.filter_map(|folder| {
let bus = owned_folder_bus(s, *folder)?;
let mut parent = s.track(*folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
})
.collect();
// Promote children of deleted folders and repair only routes that used the deleted
// folder's own bus. Custom outputs remain untouched.
let mut promoted = Vec::new();
for child in &s.tracks {
if tracks.contains(&child.id) {
continue;
}
let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue };
let mut parent = s.track(folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent))));
}
for (child, old_bus, parent, parent_bus) in promoted {
if let Some(child) = s.track_mut(child) {
child.folder = parent;
if old_bus.as_ref() == Some(&child.mixer.output) {
child.mixer.output = parent_bus.unwrap_or(Route::Main);
}
}
}
s.tracks.retain(|t| !tracks.contains(&t.id));
s.edit.selected_tracks.retain(|t| !tracks.contains(t));
for g in &mut s.groups {
g.members.retain(|t| !tracks.contains(t));
}
for (bus, replacement) in removed_busses {
s.busses.retain(|candidate| candidate.id != bus);
for track in &mut s.tracks {
if track.mixer.input == Route::Bus(bus) {
track.mixer.input = Route::None;
}
if track.mixer.output == Route::Bus(bus) {
track.mixer.output = replacement.clone();
}
for send in &mut track.mixer.sends {
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
*send = None;
}
}
}
}
Ok(json!({"deleted": tracks.len()}))
}
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?;
let s = e.session_mut();
if s.tracks.iter().any(|x| x.name == name && x.id != t) {
return Err(bad("track.rename", format!("a track named `{name}` already exists")));
}
let name: String = name.chars().take(64).collect();
let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) {
(TrackKind::Folder, Route::Bus(bus)) => Some(*bus),
_ => None,
});
if let Some(track) = s.track_mut(t) {
track.name.clone_from(&name);
}
if let Some(bus) = bus {
let bus_name = unique_folder_bus_name(s, &format!("{name} Bus"), Some(bus));
if let Some(bus) = s.busses.iter_mut().find(|candidate| candidate.id == bus) {
bus.name = bus_name;
}
}
Ok(json!({"name": name}))
}
fn comments(e: &mut Engine, p: &Value) -> Result<Value> {
let t = track_param(e, "track.comments", p, "track")?.ok_or_else(|| bad("track.comments", "`track` required"))?;
let c = str_param(p, "comments").unwrap_or_default().to_string();
if let Some(tr) = e.session_mut().track_mut(t) {
tr.comments = c;
}
Ok(json!({}))
}
fn color(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.color", p)?;
let c = match p.get("color") {
Some(Value::Array(a)) if a.len() == 3 => {
let v: Vec<u8> = a.iter().map(|x| x.as_u64().unwrap_or(0).min(255) as u8).collect();
[v.first().copied().unwrap_or(0), v.get(1).copied().unwrap_or(0), v.get(2).copied().unwrap_or(0)]
}
Some(v) if v.is_u64() => soundcraft_model::TRACK_COLORS.get(v.as_u64().unwrap_or(0) as usize % 16).copied().unwrap_or([128, 128, 128]),
_ => return Err(bad("track.color", "`color` must be [r,g,b] or a palette index")),
};
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.color = c;
}
}
Ok(json!({"color": c}))
}
fn height(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.height", p)?;
let h = match p.get("height").and_then(Value::as_f64) {
Some(points) => TrackHeight::from_points(points as f32),
None => str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?,
};
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.height = h;
}
}
Ok(json!({"height": h}))
}
fn height_step(e: &mut Engine, p: &Value, taller: bool) -> Result<Value> {
let tracks = tracks_required(e, if taller { "track.height_up" } else { "track.height_down" }, p)?;
let mut steps = Vec::new();
for tr in tracks.iter().filter_map(|t| e.session().track(*t)) {
let h = tr.height.step(taller);
if h != tr.height {
steps.push((tr.id, h));
}
}
if !steps.is_empty() {
let s = e.session_mut();
for (t, h) in &steps {
if let Some(tr) = s.track_mut(*t) {
tr.height = *h;
}
}
}
Ok(json!({"changed": steps.len()}))
}
fn view(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.view", p)?;
let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string();
let ok = matches!(v.as_str(), "waveform" | "blocks" | "notes" | "regions" | "clip_gain" | "playlists")
|| soundcraft_model::AutoParam::parse(&v).is_some();
if !ok {
return Err(bad("track.view", format!("unknown view `{v}`")));
}
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.view = v.clone();
}
}
Ok(json!({"view": v}))
}
fn move_track(e: &mut Engine, p: &Value) -> Result<Value> {
let t = track_param(e, "track.move", p, "track")?.ok_or_else(|| bad("track.move", "`track` required"))?;
let to = i64_or(p, "to", 0).max(0) as usize;
let s = e.session_mut();
if let Some(i) = s.track_index(t) {
let tr = s.tracks.remove(i);
s.tracks.insert(to.min(s.tracks.len()), tr);
}
Ok(json!({}))
}
fn commit(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.commit", p)?;
let mut out = Vec::new();
for t in tracks {
if let Some(id) = crate::io::commit_track(e, t)? {
out.push(id);
}
}
Ok(json!({"tracks": out}))
}
fn bypass_inserts(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.bypass_inserts", p)?;
let s = e.session_mut();
let target = p
.get("bypass")
.and_then(Value::as_bool)
.unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(|tr| tr.mixer.inserts.iter().flatten().all(|i| i.bypass))));
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
for i in tr.mixer.inserts.iter_mut().flatten() {
i.bypass = target;
}
}
}
Ok(json!({"bypass": target}))
}
fn mute_sends(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.mute_sends", p)?;
let s = e.session_mut();
let target = p.get("mute").and_then(Value::as_bool).unwrap_or(true);
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
for snd in tr.mixer.sends.iter_mut().flatten() {
snd.mute = target;
}
}
}
Ok(json!({"mute": target}))
}
fn create_click(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session_mut();
let id = s.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("Click"));
if let Some(t) = s.track_mut(id) {
t.comments = "click".into();
}
s.edit.click = true;
Ok(json!({"track": id}))
}
fn new_playlist(e: &mut Engine, p: &Value, dup: bool) -> Result<Value> {
let tracks = tracks_required(e, "track.playlist_new", p)?;
let name = str_param(p, "name").map(str::to_string);
let s = e.session_mut();
for t in &tracks {
let mut clips = Vec::new();
if dup && let Some(tr) = s.track(*t) {
clips = tr.clips().to_vec();
}
for c in &mut clips {
c.id = s.new_clip_id();
}
if let Some(tr) = s.track_mut(*t) {
let n = name.clone().unwrap_or_else(|| format!("{}.{:02}", tr.name, tr.playlists.len() + 1));
tr.playlists.push(Playlist { name: n, clips });
tr.active_playlist = tr.playlists.len() - 1;
}
}
Ok(json!({"tracks": tracks}))
}
fn select_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.playlist_select", p)?;
let idx = p.get("index").and_then(Value::as_u64).map(|i| i as usize);
let name = str_param(p, "name").map(str::to_string);
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
let i = idx.or_else(|| name.as_ref().and_then(|n| tr.playlists.iter().position(|p| &p.name == n)));
match i {
Some(i) if i < tr.playlists.len() => tr.active_playlist = i,
_ => return Err(bad("track.playlist_select", "no such playlist")),
}
}
}
Ok(json!({}))
}
pub(crate) fn parse_route(s: &soundcraft_model::Session, v: &str) -> Route {
match v.to_ascii_lowercase().as_str() {
"" | "none" | "no input" | "no output" => Route::None,
"main" | "out 1-2" | "master" => Route::Main,
_ => s.bus_by_name(v).map_or_else(|| Route::Hardware(v.to_string()), |b| Route::Bus(b.id)),
}
}
fn route(e: &mut Engine, p: &Value, input: bool) -> Result<Value> {
let key = if input { "input" } else { "output" };
let tracks = tracks_required(e, "track.route", p)?;
let v = str_param(p, key).ok_or_else(|| bad("track.route", format!("`{key}` required")))?.to_string();
let create_bus = bool_or(p, "create_bus", true);
let s = e.session_mut();
let mut r = parse_route(s, &v);
if let Route::Hardware(name) = &r
&& create_bus
&& !name.to_ascii_lowercase().starts_with("in ")
{
let fmt = tracks
.first()
.and_then(|t| s.track(*t))
.map_or(ChannelFormat::Stereo, |t| if t.kind == TrackKind::Aux { t.format } else { ChannelFormat::Stereo });
r = Route::Bus(s.add_bus(&name.clone(), fmt));
}
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
if input {
tr.mixer.input = r.clone();
} else {
tr.mixer.output = r.clone();
}
}
}
Ok(json!({key: r}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comp_from_alternate_playlist() {
let mut e = crate::demo::demo_engine();
let kick = e.session().track_by_name("Kick").map(|t| t.id.0).unwrap();
// Duplicate the playlist, then clear a range on the new active playlist and promote it back from the first.
e.execute("track.playlist_duplicate", &json!({"track": kick})).unwrap();
e.execute("edit.clear", &json!({"tracks": [kick], "start": {"seconds": 10.0}, "end": {"seconds": 12.0}})).unwrap();
let gap = |e: &Engine| {
e.session().track(soundcraft_model::TrackId(kick)).unwrap().playlist().unwrap().clip_at(e.session().sample_rate.samples(11.0)).is_none()
};
assert!(gap(&e));
e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 0, "start": {"seconds": 10.0}, "end": {"seconds": 12.0}})).unwrap();
assert!(!gap(&e));
assert!(e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 1, "start": 0, "end": 10})).is_err());
}
#[test]
fn group_modify_changes_name_membership_and_linking() {
let mut e = crate::demo::demo_engine();
let g = e.execute("track.group", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
// Every field at once, the way the dialog submits it.
let r = e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm", "tracks": ["Kick", "Bass"], "edit": false})).unwrap();
assert_eq!(r["name"], "Rhythm");
let gr = e.session().groups.iter().find(|x| x.id.0 == g).unwrap().clone();
assert_eq!(gr.name, "Rhythm");
assert_eq!(gr.members, vec![e.session().track_by_name("Kick").unwrap().id, e.session().track_by_name("Bass").unwrap().id]);
assert!(!gr.edit);
assert!(gr.mix, "an omitted field keeps its value");
assert_eq!(gr.letter, 'a', "the letter is the group's stable id and never changes");
// Omitting `tracks` leaves membership alone rather than falling back to the selection.
e.execute("edit.select", &json!({"tracks": ["Pad"], "exact": true})).unwrap();
e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm Section"})).unwrap();
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members, gr.members);
// Resolving the group by its new name keeps working, so names stay unique and non-empty.
e.execute("track.group", &json!({"tracks": ["Pad"], "name": "Pads"})).unwrap();
assert!(e.execute("track.group_modify", &json!({"group": "Pads", "name": "Rhythm Section"})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "name": " "})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": []})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": "Kick"})).is_err(), "`tracks` must be an array");
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": ["Kick", "Kick"]})).is_ok());
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members.len(), 1, "a repeated track is listed once");
assert!(e.execute("track.group_modify", &json!({"group": 9999, "name": "x"})).is_err());
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().name, "Rhythm Section", "a rejected call changes nothing");
}
#[test]
fn folder_routes_members_through_its_bus() {
let mut e = crate::demo::demo_engine();
let r = e.execute("track.move_to_new_folder", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap();
assert!(r["bus"].is_u64());
let s = e.session();
let kick = s.track_by_name("Kick").unwrap();
let snare = s.track_by_name("Snare").unwrap();
let folder = s.tracks.iter().find(|track| track.kind == TrackKind::Folder).unwrap();
assert!(matches!(kick.mixer.output, Route::Bus(_)));
assert_eq!(folder.mixer.input, kick.mixer.output);
assert_eq!(folder.mixer.input, snare.mixer.output);
assert_eq!(s.track_index(folder.id).unwrap() + 1, s.track_index(kick.id).unwrap());
assert_eq!(s.track_index(kick.id).unwrap() + 1, s.track_index(snare.id).unwrap());
}
#[test]
fn empty_folder_owns_a_unique_bus_and_renames_it() {
let mut e = crate::demo::demo_engine();
let first = e.execute("track.new", &json!({"kind": "folder", "name": "Guitars"})).unwrap();
let second = e.execute("track.new", &json!({"kind": "folder", "name": "Vocals"})).unwrap();
let first = TrackId(first["tracks"][0].as_u64().unwrap());
let second = TrackId(second["tracks"][0].as_u64().unwrap());
let first_bus = match e.session().track(first).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("folder did not receive a bus"),
};
let second_bus = match e.session().track(second).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("folder did not receive a bus"),
};
assert_ne!(first_bus, second_bus);
e.session_mut().add_bus("Percussion Bus", ChannelFormat::Stereo);
e.execute("track.rename", &json!({"track": first.0, "name": "Percussion"})).unwrap();
let renamed = e.session().busses.iter().find(|bus| bus.id == first_bus).unwrap();
assert_eq!(renamed.name, "Percussion Bus 2");
}
#[test]
fn tracks_can_move_between_nested_folders_and_leave() {
let mut e = crate::demo::demo_engine();
let outer = e.execute("track.new", &json!({"kind": "folder", "name": "Band"})).unwrap();
let inner = e.execute("track.new", &json!({"kind": "folder", "name": "Drums"})).unwrap();
let outer = TrackId(outer["tracks"][0].as_u64().unwrap());
let inner = TrackId(inner["tracks"][0].as_u64().unwrap());
e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap();
e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap();
let inner_bus = match e.session().track(inner).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("folder did not receive a bus"),
};
let outer_bus = match e.session().track(outer).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("folder did not receive a bus"),
};
assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err());
let kick = e.session().track_by_name("Kick").unwrap();
assert_eq!(kick.folder, Some(inner));
assert_eq!(kick.mixer.output, e.session().track(inner).unwrap().mixer.input);
assert_eq!(e.session().track_index(outer).unwrap() + 1, e.session().track_index(inner).unwrap());
assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap());
e.execute("track.remove_from_folder", &json!({"tracks": ["Kick"]})).unwrap();
let kick = e.session().track_by_name("Kick").unwrap();
assert_eq!(kick.folder, Some(outer));
assert_eq!(kick.mixer.output, e.session().track(outer).unwrap().mixer.input);
e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap();
e.execute("track.remove_from_folder", &json!({"tracks": [inner.0]})).unwrap();
let inner_track = e.session().track(inner).unwrap();
let kick = e.session().track_by_name("Kick").unwrap();
assert_eq!(inner_track.folder, None);
assert_eq!(inner_track.mixer.output, Route::Main);
assert_eq!(kick.folder, Some(inner));
assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap());
e.execute("track.delete", &json!({"tracks": [inner.0]})).unwrap();
let kick = e.session().track_by_name("Kick").unwrap();
assert_eq!(kick.folder, None);
assert_eq!(kick.mixer.output, Route::Main);
assert!(e.session().bus(inner_bus).is_none(), "the deleted folder's owned bus must be removed");
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
}
#[test]
fn heights_take_points_and_step_through_the_presets() {
let mut e = crate::demo::demo_engine();
let height = |e: &Engine, name: &str| e.session().track_by_name(name).map(|t| t.height);
e.execute("track.height", &json!({"tracks": ["Kick"], "height": 57})).unwrap();
assert_eq!(height(&e, "Kick"), Some(TrackHeight::Custom(57)));
e.execute("track.height_up", &json!({"tracks": ["Kick", "Snare"]})).unwrap();
assert_eq!((height(&e, "Kick"), height(&e, "Snare")), (Some(TrackHeight::Medium), Some(TrackHeight::Large)));
e.execute("track.height", &json!({"tracks": ["Kick"], "height": "extreme"})).unwrap();
let steps = e.undo_history().len();
assert_eq!(e.execute("track.height_up", &json!({"tracks": ["Kick"]})).unwrap()["changed"], 0);
assert_eq!(e.undo_history().len(), steps);
e.execute("track.height_down", &json!({"tracks": ["Kick"]})).unwrap();
assert_eq!(height(&e, "Kick"), Some(TrackHeight::Jumbo));
}
#[test]
fn vca_null_clears_the_assignment() {
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Member"})).unwrap();
e.execute("track.new", &json!({"kind": "vca", "name": "Master"})).unwrap();
let (member, master) = (e.session().tracks[0].id, e.session().tracks[1].id);
let vca = |e: &Engine| e.session().track(member).unwrap().mixer.vca;
e.execute("track.vca", &json!({"tracks": [member.0], "vca": master.0})).unwrap();
assert_eq!(vca(&e), Some(master.0));
// #237: null is documented as "no VCA"; anything else that is not a VCA is still an error.
for invalid in [json!(true), json!(999), json!("Missing"), json!(member.0)] {
assert!(e.execute("track.vca", &json!({"tracks": [member.0], "vca": invalid})).is_err());
assert_eq!(vca(&e), Some(master.0));
}
e.execute("track.vca", &json!({"tracks": [member.0], "vca": null})).unwrap();
assert_eq!(vca(&e), None);
assert!(e.undo());
assert_eq!(vca(&e), Some(master.0));
}
}