//! 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 { 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!( noundo "track.change_width_show_hide", "Show / Hide...", ["Track", "Change Track Width"], None, "{formats?: [string], visible?: bool} — shows or hides track width formats in the Change Track Width menu", always, change_width_show_hide ), 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|}", 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!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| { let s = e.runtime_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 { 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> { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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) -> 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 { 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 = 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 { 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 { 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) -> Vec { 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 { 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 = 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 { 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 { 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})) } const ALLOWED_WIDTH_FORMATS: &[&str] = &[ "mono", "stereo", "lcr", "quad", "lcrs", "5.0", "5.1", "6.0", "6.1", "7.0", "7.1", "7.0_sdds", "7.1_sdds", "5.0.2", "5.1.2", "5.0.4", "5.1.4", "7.0.2", "7.1.2", "7.0.4", "7.1.4", "7.0.6", "7.1.6", "9.0.4", "9.1.4", "9.0.6", "9.1.6", "ambisonics_1", "ambisonics_2", "ambisonics_3", ]; /// Normalizes track width format name aliases to their canonical representation. fn normalize_width_format(s: &str) -> Option<&'static str> { let lower = s.trim().to_ascii_lowercase(); match lower.as_str() { "7.0 sdds" | "7.0_sdds" => Some("7.0_sdds"), "7.1 sdds" | "7.1_sdds" => Some("7.1_sdds"), "1st order ambisonics" | "ambisonics_1" => Some("ambisonics_1"), "2nd order ambisonics" | "ambisonics_2" => Some("ambisonics_2"), "3rd order ambisonics" | "ambisonics_3" => Some("ambisonics_3"), other => ALLOWED_WIDTH_FORMATS.iter().copied().find(|&f| f == other), } } /// Shows or hides track width formats in the Change Track Width menu. fn change_width_show_hide(e: &mut Engine, p: &Value) -> Result { let id = "track.change_width_show_hide"; let visible = p.get("visible").and_then(Value::as_bool).unwrap_or(true); let mut updated = Vec::new(); if let Some(v) = p.get("formats") { let arr = v.as_array().ok_or_else(|| bad(id, "`formats` must be an array of strings"))?; if arr.len() > 64 { return Err(bad(id, "`formats` array cannot exceed 64 entries")); } let mut canonical = Vec::new(); for (i, item) in arr.iter().enumerate() { let s = item.as_str().ok_or_else(|| bad(id, format!("format at index {i} must be a string")))?; let trimmed = s.trim(); if trimmed.is_empty() { return Err(bad(id, format!("format at index {i} cannot be empty"))); } if trimmed.chars().count() > 32 { return Err(bad(id, format!("format at index {i} exceeds maximum length of 32 characters"))); } let canon = normalize_width_format(trimmed).ok_or_else(|| bad(id, format!("unknown width format: '{trimmed}'")))?; canonical.push(canon); } for canon in canonical { e.session_mut().edit.set_flag(&format!("track.width_visible.{canon}"), visible); updated.push(canon.to_string()); } } Ok(json!({ "visible": visible, "formats": updated, })) } /// 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 { 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 { 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 { 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 { 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 { 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 = 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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)); } #[test] fn change_width_show_hide_validates_and_stores() { let mut e = Engine::default(); let r = e .execute( "track.change_width_show_hide", &json!({ "formats": ["mono", "stereo", "5.1", "7.1 SDDS"], "visible": true }), ) .unwrap(); assert_eq!(r["visible"], true); assert_eq!(r["formats"], json!(["mono", "stereo", "5.1", "7.1_sdds"])); assert!(e.session().edit.flag("track.width_visible.mono")); assert!(e.session().edit.flag("track.width_visible.7.1_sdds")); // Hide let r_hide = e .execute( "track.change_width_show_hide", &json!({ "formats": ["mono"], "visible": false }), ) .unwrap(); assert_eq!(r_hide["visible"], false); assert!(!e.session().edit.flag("track.width_visible.mono")); // Invalid format rejected without partial update assert!(!e.session().edit.flag("track.width_visible.quad")); assert!(e.execute("track.change_width_show_hide", &json!({"formats": ["quad", "invalid_format"]})).is_err()); assert!(!e.session().edit.flag("track.width_visible.quad")); // Non-string entry rejected assert!(e.execute("track.change_width_show_hide", &json!({"formats": [123]})).is_err()); // Empty string rejected assert!(e.execute("track.change_width_show_hide", &json!({"formats": [" "]})).is_err()); // Overlong string (> 32 chars) rejected let long_str = "A".repeat(33); assert!(e.execute("track.change_width_show_hide", &json!({"formats": [long_str]})).is_err()); // Oversized array (> 64 items) rejected let large_arr: Vec<_> = (0..65).map(|_| "mono").collect(); assert!(e.execute("track.change_width_show_hide", &json!({"formats": large_arr})).is_err()); } }