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

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

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

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

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

---------

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

626 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::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param};
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, 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);
}
}