Files
soundcraft/crates/engine/src/cmd/track.rs
T
Sly LaMenace d2298ff974 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
2026-11-01 11:32:53 +00:00

1236 lines
57 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.
//! 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, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
/// Returns the track command specifications exposed to UI and automation clients.
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, rows?: [objects with the same fields] (1–128 tracks total)}",
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}))
}
),
]
}
/// Creates either one legacy row or several validated rows as one command.
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = if let Some(rows) = p.get("rows") {
let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?;
if rows.is_empty() || rows.len() > 128 {
return Err(bad("track.new", "`rows` must contain 1–128 rows"));
}
let mut total = 0_i64;
for row in rows {
if !row.is_object() || row.get("rows").is_some() {
return Err(bad("track.new", "each row must be an object without nested rows"));
}
total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128));
}
if total > 128 {
return Err(bad("track.new", "a single creation is limited to 128 tracks"));
}
let mut ids = Vec::new();
for row in rows {
ids.extend(new_track_row(e, row)?);
}
ids
} else {
new_track_row(e, p)?
};
e.session_mut().edit.selected_tracks = ids.clone();
Ok(json!({"tracks": ids}))
}
/// All rows run inside one command, so failures roll back and Undo covers the whole creation.
fn new_track_row(e: &mut Engine, p: &Value) -> Result<Vec<TrackId>> {
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);
}
Ok(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();
let mut mono_sources = std::collections::BTreeMap::new();
let mut copied_samples = 0usize;
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();
if let ClipContent::Audio { source, .. } = &mut c.content {
let audio =
s.pool.get(*source).ok_or_else(|| bad("track.split_into_mono", format!("audio unavailable for clip `{}`", c.name)))?;
let channels = audio.buffer.channels.len();
if channels == 0 {
return Err(bad("track.split_into_mono", format!("audio has no channels for clip `{}`", c.name)));
}
// Match the renderer's channel wrapping when a track is wider than its source.
// An already-mono source can be shared without copying it.
if channels > 1 {
let key = (*source, ch % channels);
let mono = if let Some(&id) = mono_sources.get(&key) {
id
} else {
let samples =
audio.buffer.channels.get(key.1).ok_or_else(|| bad("track.split_into_mono", "source channel unavailable"))?;
copied_samples = copied_samples
.checked_add(samples.len())
.filter(|&n| n <= crate::io::MAX_IMPORT_SAMPLES)
.ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?;
// Keep the full channel: the cloned clips retain their offsets, stretch,
// gain and fades, and every reader sees genuine mono media (including saves).
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
let original = s.source(*source);
let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str()));
let time_reference = original.map_or(0, |source| source.time_reference);
let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav);
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
source.time_reference = time_reference;
}
mono_sources.insert(key, id);
id
};
*source = mono;
}
}
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 }];
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,
}
}
/// Whether a bus is still routed through by a track that survives deleting
/// `deleted`, ignoring the output slots of `folder`'s own members, which the
/// delete reroutes to the parent folder's bus. A folder owns its input bus
/// only when this is false: `track.input` or a session file can point a
/// folder at a bus that other tracks already use (#914).
fn bus_used_beyond_folder(s: &Session, bus: soundcraft_model::BusId, deleted: &[TrackId], folder: TrackId) -> bool {
let route = Route::Bus(bus);
s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| {
track.mixer.input == route
|| track.mixer.sends.iter().flatten().any(|send| send.target == route)
|| (track.mixer.output == route && !in_folder(s, track.id, folder))
})
}
/// 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}))
}
/// The nearest ancestor folder of `folder` that is not itself being deleted (walks at most 64 levels).
fn surviving_parent(s: &Session, deleted: &[TrackId], folder: TrackId) -> Option<TrackId> {
let mut parent = s.track(folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if deleted.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
parent
}
/// Clear inputs and sends that used the removed bus; outputs that used it move to `replacement`.
fn reroute_deleted_bus(s: &mut Session, bus: soundcraft_model::BusId, replacement: &Route) {
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;
}
}
}
}
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 parent = surviving_parent(s, &tracks, *folder);
let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main);
// A bus that surviving tracks route through is shared, not the
// folder's own: keep it, and every route to it, when the folder
// goes (#914).
(!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement))
})
.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 parent = surviving_parent(s, &tracks, folder);
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);
reroute_deleted_bus(s, bus, &replacement);
}
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"))?;
// Validate the stored name, including its existing 64-character limit.
let name: String = name.chars().take(64).collect();
let s = e.session();
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 bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) {
(TrackKind::Folder, Route::Bus(bus)) => Some(*bus),
_ => None,
});
let track = s.track(t).ok_or_else(|| bad("track.rename", "unknown track"))?;
if track.name == name
&& bus.is_none_or(|id| {
let expected = unique_folder_bus_name(s, &format!("{name} Bus"), Some(id));
s.bus(id).is_none_or(|bus| bus.name == expected)
})
{
return Ok(json!({"name": name}));
}
let s = e.session_mut();
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 deleting_a_folder_keeps_a_bus_other_tracks_still_use() {
let mut e = crate::demo::demo_engine();
// #914: the demo session's Verb bus is shared: an aux takes it as its input
// and several tracks send to it. Point a new folder's input at it, the way
// `track.input` or a session file can, then delete the folder.
let verb = e.session().bus_by_name("Verb").unwrap().id;
let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap();
let folder = TrackId(folder["tracks"][0].as_u64().unwrap());
e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap();
assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb));
e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap();
assert!(e.session().track(folder).is_none(), "the folder itself is deleted");
assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive");
let aux = e.session().track_by_name("Verb").unwrap();
assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus");
let snare = e.session().track_by_name("Snare").unwrap();
assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive");
}
#[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));
}
}