Files
soundcraft/crates/engine/src/cmd/setup_more.rs
T
aiongg 86b31e71d3 Sessions: follow stored source paths only on this computer or the session's share
Audio source and movie paths stored in a .scraft session go through
io::stored_path (craft-docpath) before they reach the file system: a
relative path is taken from the session's folder, and paths on other
computers, devices and URLs are refused. A refused source is reported
as missing with the reason; a refused movie is marked offline and the
Video track and Video window show why instead of opening it. Import
Session Data and crash recovery resolve the same way.
2026-10-16 17:32:35 +00:00

627 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click,
//! shortcuts, MIDI setup) and File › Import › Session Data.
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode;
use std::path::Path;
macro_rules! option {
($id:literal, $label:literal, $flag:literal) => {
cmd!(noundo $id, $label, ["Options"], None, "{value?: bool} (toggles when omitted)", always, |e, p| toggle_flag(e, p, $flag))
};
}
const MIDI_FILTER_KINDS: [&str; 8] = ["notes", "pitch_bend", "mono_aftertouch", "poly_aftertouch", "program", "cc", "sysex", "realtime"];
pub fn specs() -> Vec<CommandSpec> {
vec![
option!("options.destructive_punch", "DestructivePunch", "options.destructive_punch"),
option!("options.transport_online", "Transport Online", "options.transport_online"),
option!("options.dynamic_transport", "Dynamic Transport", "options.dynamic_transport"),
option!(
"options.marker_target_follows_selection",
"Marker Target Follows First Selected Track",
"options.marker_target_follows_first_selected_track"
),
option!("options.midi_thru", "MIDI Thru", "options.midi_thru"),
option!("options.auto_spot_clips", "Auto-Spot Clips", "options.auto_spot_clips"),
option!("options.keyboard_lock", "Edit/Tool Mode Keyboard Lock", "options.edit_tool_mode_keyboard_lock"),
option!("options.calibration_mode", "Calibration Mode", "options.calibration_mode"),
option!("options.midi_live_mode", "MIDI Live Mode", "options.midi_live_mode"),
option!("options.low_latency_monitoring", "Low Latency Monitoring", "options.low_latency_monitoring"),
cmd!(
"options.prepare_dpe_tracks",
"Prepare DPE Tracks",
["Options"],
None,
"{tracks?} — consolidates each record-armed (or given) audio track into one continuous clip for destructive punch",
always,
prepare_dpe
),
cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)),
cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| {
if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) {
e.session_mut().edit.delay_compensation = v;
}
engine_settings(e, p)
}),
cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation),
cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals),
cmd!(
"setup.io",
"I/O...",
["Setup"],
None,
"{action?: list|create_bus|rename_bus|delete_bus|create_output|rename_output|delete_output, name?, new_name?, format?: stereo, first_channel?: 0}",
always,
io_setup
),
cmd!(
"setup.current_timecode",
"Current Timecode Position...",
["Setup"],
None,
"{timecode: 'HH:MM:SS:FF'} — sets the session start so the insertion point reads this timecode",
always,
current_timecode
),
cmd!(noundo "setup.current_feet_frames", "Current Feet+Frames Position...", ["Setup"], None, "{feet_frames: 'F+FF'} — the feet+frames reading at the insertion point", always, current_feet_frames),
cmd!(noundo "setup.external_timecode_offset", "External Timecode Offset...", ["Setup"], None, "{frames?: ±, samples?: ±} — offset applied to incoming timecode", always, |e, p| offset_setting(e, p, "sync.external_offset")),
cmd!(noundo "setup.video_sync_offset", "Video Sync Offset...", ["Setup"], None, "{frames?: ±, samples?: ±} — offset between audio and video playback", always, |e, p| offset_setting(e, p, "video.sync_offset")),
cmd!(noundo "setup.click_countoff", "Click/Countoff...", ["Setup"], None, "{volume_db?: -60..12, accent_note?: 0..127, normal_note?: 0..127, accent_velocity?, normal_velocity?, countoff?: bool, countoff_bars?: 1..16, only_during_record?: bool}", always, click_countoff),
cmd!(query "setup.keyboard_shortcuts", "Keyboard Shortcuts...", ["Setup"], None, "{filter?} — every command shortcut", always, |_, p| {
let f = str_param(p, "filter").map(str::to_ascii_lowercase);
let rows: Vec<Value> = command_specs()
.iter()
.filter_map(|c| c.shortcut.map(|sc| (c, sc)))
.filter(|(c, _)| f.as_ref().is_none_or(|f| c.id.contains(f.as_str()) || c.label.to_ascii_lowercase().contains(f.as_str())))
.map(|(c, sc)| json!({"id": c.id, "label": c.label, "menu": c.menu.join(" > "), "shortcut": sc}))
.collect();
Ok(json!({"shortcuts": rows}))
}),
cmd!(noundo "setup.midi_studio", "MIDI Studio...", ["Setup", "MIDI"], None, "{devices?: [names]} — the MIDI devices in the studio", always, |e, p| {
let s = e.session_mut();
if let Some(a) = p.get("devices").and_then(Value::as_array) {
let names: Vec<&str> = a.iter().filter_map(Value::as_str).take(64).collect();
set_text(&mut s.edit, "midi.studio.devices", Some(&names.join("|")));
}
let devices: Vec<String> = text(&s.edit, "midi.studio.devices").map(|t| t.split('|').map(str::to_string).collect()).unwrap_or_default();
Ok(json!({"devices": devices}))
}),
cmd!(noundo "setup.midi_beat_clock", "MIDI Beat Clock...", ["Setup", "MIDI"], None, "{enabled?: bool, offset?: samples, destination?: name}", always, |e, p| {
let s = e.session_mut();
if let Some(v) = p.get("enabled").and_then(Value::as_bool) {
s.edit.set_flag("midi.beat_clock", v);
}
store_value(&mut s.edit, p, "offset", "midi.beat_clock.offset", -1e7, 1e7);
if let Some(d) = str_param(p, "destination") {
set_text(&mut s.edit, "midi.beat_clock.destination", Some(d));
}
Ok(json!({"enabled": s.edit.flag("midi.beat_clock"), "offset": s.edit.value("midi.beat_clock.offset", 0.0), "destination": text(&s.edit, "midi.beat_clock.destination")}))
}),
cmd!(noundo "setup.midi_input_filter", "MIDI Input Filter...", ["Setup", "MIDI"], None, "{filter?: [notes|pitch_bend|mono_aftertouch|poly_aftertouch|program|cc|sysex|realtime], allow?: [...]} — filtered kinds are not recorded", always, midi_filter),
cmd!(noundo "setup.midi_input_devices", "MIDI Input Devices...", ["Setup", "MIDI"], None, "{enable?: [names], disable?: [names]}", always, midi_inputs),
cmd!(noundo "setup.transcription_settings", "Transcription Settings...", ["Setup"], None, "{enabled?: bool, language?: 'en'}", always, |e, p| {
let s = e.session_mut();
if let Some(v) = p.get("enabled").and_then(Value::as_bool) {
s.edit.set_flag("transcription.enabled", v);
}
if let Some(l) = str_param(p, "language") {
set_text(&mut s.edit, "transcription.language", Some(l));
}
Ok(json!({"enabled": s.edit.flag("transcription.enabled"), "language": text(&s.edit, "transcription.language").unwrap_or_else(|| "en".into())}))
}),
cmd!(
"file.import_session_data",
"Session Data...",
["File", "Import"],
Some("Alt+Shift+I"),
"{path, tracks?: [names]} — imports tracks (clips, playlists, mixer, automation, audio) from another session",
always,
import_session_data
),
]
}
fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "options.prepare_dpe_tracks";
let s = e.session();
let tracks: Vec<TrackId> = if p.get("tracks").is_some() || p.get("track").is_some() {
tracks_param(e, id, p)?
} else {
s.tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id).collect()
};
let tracks: Vec<TrackId> = tracks.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind == TrackKind::Audio)).collect();
if tracks.is_empty() {
return Err(bad(id, "record-enable (or pass) the audio tracks to prepare"));
}
let end = s.content_end();
if end <= 0 {
return Err(bad(id, "there is no audio to prepare"));
}
let r = soundcraft_time::Range::new(0, end);
let mut n = 0;
for t in tracks {
// Each track keeps its own length (its material plus a second), as before.
let tr = crate::io::bounded_to_track(e.session(), t, r);
if crate::io::consolidate(e, id, t, tr)? {
n += 1;
}
}
e.session_mut().edit.set_flag("options.dpe_prepared", true);
Ok(json!({"prepared": n}))
}
fn engine_settings(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) {
let b = b.clamp(16, 8192).next_power_of_two().min(8192);
s.edit.values.insert("engine.buffer_size".into(), b as f64);
}
if let Some(d) = str_param(p, "device") {
set_text(&mut s.edit, "engine.device", Some(d));
}
Ok(json!({
"device": text(&s.edit, "engine.device"),
"buffer_size": s.edit.value("engine.buffer_size", 1024.0),
"sample_rate": s.sample_rate.hz(),
"bit_depth": s.bit_depth,
"delay_compensation": s.edit.delay_compensation,
}))
}
fn disk_allocation(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "setup.disk_allocation";
let t = track_param(e, id, p, "track")?;
let s = e.session_mut();
if let Some(path) = str_param(p, "path") {
let key = t.map_or_else(|| "disk.root".to_string(), |t| format!("disk.track.{}", t.0));
set_text(&mut s.edit, &key, Some(path));
}
let root = text(&s.edit, "disk.root").unwrap_or_else(|| "Audio Files".into());
let rows: Vec<Value> = s
.tracks
.iter()
.filter(|t| t.kind == TrackKind::Audio)
.map(|t| json!({"track": t.id, "name": t.name, "path": text(&s.edit, &format!("disk.track.{}", t.id.0)).unwrap_or_else(|| root.clone())}))
.collect();
Ok(json!({"root": root, "tracks": rows}))
}
fn peripherals(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
if let Some(m) = p.get("settings").and_then(Value::as_object) {
for (k, v) in m.iter().take(256) {
let key: String = k.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-')).take(64).collect();
if key.is_empty() {
continue;
}
let key = format!("peripherals.{key}");
match v {
Value::Bool(b) => s.edit.set_flag(&key, *b),
Value::Number(n) => {
if let Some(x) = n.as_f64().filter(|x| x.is_finite()) {
s.edit.values.insert(key, x);
}
}
Value::String(t) => set_text(&mut s.edit, &key, Some(t)),
_ => {}
}
}
}
let flags: Vec<&String> = s.edit.flags.iter().filter(|f| f.starts_with("peripherals.")).collect();
Ok(json!({"values": values_with_prefix(&s.edit, "peripherals."), "flags": flags}))
}
fn io_state(s: &Session) -> Value {
json!({"busses": s.busses, "outputs": s.outputs})
}
fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "setup.io";
let action = str_param(p, "action").unwrap_or("list");
if action == "list" {
return Ok(io_state(e.session()));
}
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(id, "`name` required"))?.to_string();
let new_name = str_param(p, "new_name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string);
let format = match str_param(p, "format") {
Some(f) => ChannelFormat::from_id(f).ok_or_else(|| bad(id, format!("unknown format `{f}`")))?,
None => ChannelFormat::Stereo,
};
let s = e.session_mut();
match action {
"create_bus" => {
if s.bus_by_name(&name).is_some() {
return Err(bad(id, format!("a bus named `{name}` exists")));
}
s.add_bus(&name, format);
}
"rename_bus" => {
let to = new_name.ok_or_else(|| bad(id, "`new_name` required"))?;
if s.bus_by_name(&to).is_some() {
return Err(bad(id, format!("a bus named `{to}` exists")));
}
let b = s.busses.iter_mut().find(|b| b.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(id, format!("no bus `{name}`")))?;
b.name = to;
}
"delete_bus" => {
let bid: BusId = s.bus_by_name(&name).map(|b| b.id).ok_or_else(|| bad(id, format!("no bus `{name}`")))?;
s.busses.retain(|b| b.id != bid);
for t in &mut s.tracks {
if t.mixer.input == Route::Bus(bid) {
t.mixer.input = Route::None;
}
if t.mixer.output == Route::Bus(bid) {
t.mixer.output = Route::None;
}
for slot in &mut t.mixer.sends {
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
*slot = None;
}
}
}
}
"create_output" => {
if s.outputs.iter().any(|o| o.name.eq_ignore_ascii_case(&name)) {
return Err(bad(id, format!("an output named `{name}` exists")));
}
let first = i64_or(p, "first_channel", 0).clamp(0, 1023) as u16;
s.outputs.push(OutputPath { name, first_channel: first, format });
}
"rename_output" => {
let to = new_name.ok_or_else(|| bad(id, "`new_name` required"))?;
let o = s.outputs.iter_mut().find(|o| o.name.eq_ignore_ascii_case(&name)).ok_or_else(|| bad(id, format!("no output `{name}`")))?;
let old = std::mem::replace(&mut o.name, to.clone());
for t in &mut s.tracks {
if t.mixer.output == Route::Hardware(old.clone()) {
t.mixer.output = Route::Hardware(to.clone());
}
}
}
"delete_output" => {
let before = s.outputs.len();
s.outputs.retain(|o| !o.name.eq_ignore_ascii_case(&name));
if s.outputs.len() == before {
return Err(bad(id, format!("no output `{name}`")));
}
}
other => return Err(bad(id, format!("unknown action `{other}`"))),
}
Ok(io_state(s))
}
fn current_timecode(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "setup.current_timecode";
let tc = str_param(p, "timecode").ok_or_else(|| bad(id, "`timecode` required (HH:MM:SS:FF)"))?;
let tc = Timecode::parse(tc).map_err(|err| bad(id, err.to_string()))?;
let s = e.session_mut();
let at = s.edit.selection.start.max(0);
let abs = tc.to_samples(s.frame_rate, s.sample_rate);
s.timecode_start = abs - at;
Ok(json!({"timecode_start": s.timecode_start, "at": at}))
}
fn current_feet_frames(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "setup.current_feet_frames";
let text_v = str_param(p, "feet_frames").ok_or_else(|| bad(id, "`feet_frames` required (F+FF)"))?;
let (ft, fr) = text_v.split_once('+').ok_or_else(|| bad(id, "use F+FF, e.g. 12+08"))?;
let ft: i64 = ft.trim().parse().map_err(|_| bad(id, "bad feet"))?;
let fr: i64 = fr.trim().parse().map_err(|_| bad(id, "bad frames"))?;
if !(0..=10_000_000).contains(&ft) || !(0..16).contains(&fr) {
return Err(bad(id, "feet must be 0.. and frames 0..15"));
}
let s = e.session_mut();
let at = s.edit.selection.start.max(0);
let frames_at = s.frame_rate.frames_at(at, s.sample_rate);
let start = ft * 16 + fr - frames_at;
s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64);
Ok(json!({"feet_frames_start": start}))
}
fn offset_setting(e: &mut Engine, p: &Value, key: &str) -> Result<Value> {
let s = e.session_mut();
let sr = s.sample_rate;
let per_frame = s.frame_rate.samples_per_frame(sr);
if let Some(f) = p.get("frames").and_then(Value::as_f64).filter(|v| v.is_finite()) {
s.edit.values.insert(key.to_string(), (f.clamp(-10_000.0, 10_000.0) * per_frame).round());
} else if let Some(n) = p.get("samples").and_then(Value::as_f64).filter(|v| v.is_finite()) {
let lim = sr.as_f64() * 3600.0;
s.edit.values.insert(key.to_string(), n.clamp(-lim, lim).round());
}
let samples = s.edit.value(key, 0.0);
Ok(json!({"samples": samples, "frames": samples / per_frame.max(1e-9)}))
}
fn click_countoff(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
store_value(&mut s.edit, p, "volume_db", "click.volume_db", -60.0, 12.0);
store_value(&mut s.edit, p, "accent_note", "click.accent_note", 0.0, 127.0);
store_value(&mut s.edit, p, "normal_note", "click.normal_note", 0.0, 127.0);
store_value(&mut s.edit, p, "accent_velocity", "click.accent_velocity", 1.0, 127.0);
store_value(&mut s.edit, p, "normal_velocity", "click.normal_velocity", 1.0, 127.0);
if let Some(v) = p.get("countoff").and_then(Value::as_bool) {
s.edit.countoff = v;
}
if let Some(b) = p.get("countoff_bars").and_then(Value::as_u64) {
s.edit.countoff_bars = b.clamp(1, 16) as u32;
}
if let Some(v) = p.get("only_during_record").and_then(Value::as_bool) {
s.edit.set_flag("click.only_during_record", v);
}
Ok(json!({
"volume_db": s.edit.value("click.volume_db", -6.0),
"accent_note": s.edit.value("click.accent_note", 84.0),
"normal_note": s.edit.value("click.normal_note", 72.0),
"accent_velocity": s.edit.value("click.accent_velocity", 127.0),
"normal_velocity": s.edit.value("click.normal_velocity", 100.0),
"countoff": s.edit.countoff,
"countoff_bars": s.edit.countoff_bars,
"only_during_record": s.edit.flag("click.only_during_record"),
}))
}
fn kinds(p: &Value, key: &str) -> Vec<&'static str> {
p.get(key)
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(Value::as_str).filter_map(|k| MIDI_FILTER_KINDS.iter().copied().find(|x| *x == k)).collect())
.unwrap_or_default()
}
fn midi_filter(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
for k in kinds(p, "filter") {
s.edit.set_flag(&format!("midi.filter.{k}"), true);
}
for k in kinds(p, "allow") {
s.edit.set_flag(&format!("midi.filter.{k}"), false);
}
let filtered: Vec<&str> = MIDI_FILTER_KINDS.iter().copied().filter(|k| s.edit.flag(&format!("midi.filter.{k}"))).collect();
Ok(json!({"filtered": filtered}))
}
fn device_key(name: &str) -> String {
let n: String = name.chars().filter(|c| !c.is_control() && *c != '=').take(64).collect();
format!("midi.input.{}", n.trim())
}
fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
for (key, on) in [("enable", true), ("disable", false)] {
for n in p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).take(64).collect::<Vec<_>>()).unwrap_or_default() {
if !n.trim().is_empty() {
s.edit.set_flag(&device_key(n), on);
}
}
}
let enabled: Vec<&str> = s.edit.flags.iter().filter_map(|f| f.strip_prefix("midi.input.")).collect();
Ok(json!({"enabled": enabled}))
}
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "file.import_session_data";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
let text_v = crate::io::read_text(&path)?;
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let names: Option<Vec<String>> =
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let picked: Vec<soundcraft_model::Track> = match &names {
Some(ns) => {
let mut v = Vec::new();
for n in ns {
v.push(other.track_by_name(n).cloned().ok_or_else(|| bad(id, format!("`{path}` has no track named `{n}`")))?);
}
v
}
None => other.tracks.iter().filter(|t| t.kind != TrackKind::Master).cloned().collect(),
};
if picked.is_empty() {
return Err(bad(id, "nothing to import"));
}
let mut needed: Vec<SourceId> =
picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect();
// A Sampler's sample is session audio too.
needed.extend(picked.iter().filter_map(|t| t.instrument.as_ref().and_then(|i| i.sample)));
needed.sort_by_key(|x| x.0);
needed.dedup();
let sr = e.session().sample_rate.hz();
let s = e.session_mut();
let mut files = crate::io::DecodedFiles::new();
let mut map: std::collections::BTreeMap<u64, SourceId> = Default::default();
let mut missing = Vec::new();
for src in needed.iter().filter_map(|sid| other.source(*sid)) {
let mut ns = src.clone();
ns.id = SourceId(s.alloc());
match crate::io::stored_path(&src.path, Path::new(&path)) {
Ok(abs) => {
ns.path = abs.to_string_lossy().into_owned();
match crate::io::decode_file(&mut files, &abs, sr) {
Ok(audio) => {
ns.frames = audio.buffer.frames() as u64;
ns.sample_rate = sr;
ns.unsaved = true;
s.pool.insert(ns.id, audio);
}
Err(_) => {
ns.unsaved = false;
missing.push(ns.path.clone());
}
}
}
Err(why) => {
ns.unsaved = false;
missing.push(format!("{}: {why}", ns.path));
}
}
map.insert(src.id.0, ns.id);
s.sources.push(ns);
}
let mut out = Vec::new();
for mut tr in picked {
tr.id = TrackId(s.alloc());
tr.name = s.unique_track_name(&tr.name);
tr.folder = None;
tr.mixer.vca = None;
for pl in &mut tr.playlists {
for c in &mut pl.clips {
c.id = s.new_clip_id();
if let ClipContent::Audio { source, .. } = &mut c.content
&& let Some(n) = map.get(&source.0)
{
*source = *n;
}
}
}
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = ins.sample.and_then(|sid| map.get(&sid.0).copied());
}
// Busses are matched by name (created when missing).
let remap = |s: &mut Session, r: &Route| -> Route {
match r {
Route::Bus(b) => other.bus(*b).map_or(Route::None, |ob| Route::Bus(s.add_bus(&ob.name, ob.format))),
x => x.clone(),
}
};
tr.mixer.input = remap(s, &tr.mixer.input);
tr.mixer.output = remap(s, &tr.mixer.output);
for slot in tr.mixer.sends.iter_mut().flatten() {
slot.target = remap(s, &slot.target);
}
let pos = s.tracks.iter().position(|t| t.kind == TrackKind::Master).unwrap_or(s.tracks.len());
out.push(tr.id);
s.tracks.insert(pos, tr);
}
s.edit.selected_tracks = out.clone();
Ok(json!({"tracks": out, "sources": map.len(), "missing": missing}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn option_flags_toggle() {
let mut e = Engine::default();
assert_eq!(e.execute("options.midi_thru", &json!({})).unwrap()["value"], true);
assert!(e.session().edit.flag("options.midi_thru"));
e.execute("options.midi_thru", &json!({"value": false})).unwrap();
assert!(!e.session().edit.flag("options.midi_thru"));
}
#[test]
fn io_bus_lifecycle() {
let mut e = crate::demo::demo_engine();
e.execute("setup.io", &json!({"action": "create_bus", "name": "Drums"})).unwrap();
e.execute("setup.io", &json!({"action": "rename_bus", "name": "Verb", "new_name": "Hall"})).unwrap();
assert!(e.session().bus_by_name("Hall").is_some());
e.execute("setup.io", &json!({"action": "delete_bus", "name": "Hall"})).unwrap();
assert!(
e.session().tracks.iter().all(|t| t
.mixer
.sends
.iter()
.flatten()
.all(|s| matches!(s.target, Route::None | Route::Main | Route::Hardware(_))))
);
assert!(e.session().tracks.iter().all(|t| {
!matches!(t.mixer.input, Route::Bus(_))
|| e.session()
.bus(match t.mixer.input {
Route::Bus(b) => b,
_ => BusId(0),
})
.is_some()
}));
e.execute("setup.io", &json!({"action": "create_output", "name": "Out 3-4", "first_channel": 2})).unwrap();
assert_eq!(e.session().outputs.len(), 2);
assert!(e.execute("setup.io", &json!({"action": "nope", "name": "x"})).is_err());
assert!(e.can_undo());
}
#[test]
fn current_timecode_sets_start() {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap();
let s = e.session();
let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode);
assert_eq!(shown, "01:00:00:00");
}
#[test]
fn click_countoff_settings() {
let mut e = Engine::default();
let r = e.execute("setup.click_countoff", &json!({"volume_db": -12.0, "countoff_bars": 4, "countoff": true})).unwrap();
assert_eq!(r["volume_db"], -12.0);
assert_eq!(e.session().edit.countoff_bars, 4);
assert!(e.session().edit.countoff);
}
#[test]
fn shortcuts_listed() {
let mut e = Engine::default();
let r = e.execute("setup.keyboard_shortcuts", &json!({})).unwrap();
assert!(r["shortcuts"].as_array().unwrap().iter().any(|x| x["id"] == "edit.undo"));
}
#[test]
fn engine_and_midi_settings() {
let mut e = Engine::default();
let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
assert_eq!(r["buffer_size"], 512.0);
assert_eq!(r["device"], "Interface");
let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap();
assert_eq!(r["filtered"], json!(["sysex"]));
let r = e.execute("setup.midi_input_devices", &json!({"enable": ["Keys"]})).unwrap();
assert_eq!(r["enabled"], json!(["Keys"]));
let r = e.execute("setup.video_sync_offset", &json!({"frames": 2})).unwrap();
assert_eq!(r["samples"], 3200.0);
}
#[test]
fn import_session_data_brings_tracks_and_audio() {
let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
let mut donor = crate::demo::demo_engine();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = Engine::default();
let r = e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Kick", "Keys"]})).unwrap();
assert_eq!(r["tracks"].as_array().unwrap().len(), 2);
assert_eq!(r["missing"], json!([]));
let kick = e.session().track_by_name("Kick").unwrap();
let src = kick.clips()[0].source().unwrap();
assert!(e.session().pool.get(src).is_some());
assert_eq!(e.session().track_by_name("Keys").unwrap().clips().len(), 1);
assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_a_samplers_sample() {
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("hit.wav");
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let mut donor = Engine::default();
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = crate::demo::demo_engine();
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Pads"]})).unwrap();
let sample = e.session().track_by_name("Pads").and_then(|t| t.instrument.as_ref()).and_then(|i| i.sample).unwrap();
let src = e.session().source(sample).unwrap();
assert_eq!(src.name, "hit");
assert!(e.session().pool.get(sample).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}