Files
soundcraft/crates/engine/src/cmd/file.rs
T
grovebit 52e19a871a Keep the app responsive while bouncing
Bounce Mix rendered the whole file inside one UI frame: the window stopped
drawing and nothing could cancel the bounce until the file was written,
which takes minutes for long or plugin-heavy sessions.

- engine: io::Bounce renders a slice at a time (step) and reports its
  progress; dropping an unfinished one cancels it, removing the partial
  file and keeping any older one. cmd::bounce_job builds it from
  file.bounce_mix's params; the command and stems still run it to
  completion.
- ui: the Bounce dialog starts the job and logic() renders it for 30 ms
  per call (32 blocks in the browser, where Instant panics), requesting
  another call each time, so it carries on while the window is hidden
  (eframe then runs logic alone). A modal shows the file, a progress bar
  and Cancel (or Esc); the status line reports the result, and the Task
  Manager lists the running bounce.
- It stays on the UI thread because hosted plugins must be created there.
2026-10-12 23:47:00 +01:00

683 lines
38 KiB
Rust

//! File menu commands.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Session, TrackKind};
use soundcraft_time::SampleRate;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "session.new", "New...", ["File"], Some("Cmd+N"), "{name?: 'Untitled', sample_rate?: 48000, bit_depth?: 24, template?: blank|demo}", always, new_session),
cmd!(noundo "session.open", "Open Session...", ["File"], Some("Cmd+O"), "{path}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.open", "`path` required"))?.to_string();
let missing = crate::io::open_session(e, &path)?;
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing}))
}),
cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }),
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| {
let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?;
refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string();
refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session_copy(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string();
let file = crate::io::session_file(&path);
refuse_replacing(e, "session.save_template", &file, p)?;
let mut s = e.session().clone();
for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } }
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?;
}
Ok(json!({"path": file}))
}),
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| {
let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?;
crate::io::open_session(e, &path)?;
Ok(json!({}))
}),
cmd!(
"file.import_audio",
"Audio...",
["File", "Import"],
Some("Cmd+Shift+I"),
"{path | paths: [..], track?: target track, at?: position, new_tracks?: true (default; false uses `track` or the selected audio track)}",
always,
import_audio
),
cmd!("file.import_midi", "MIDI...", ["File", "Import"], Some("Cmd+Alt+I"), "{path, at?}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.import_midi", "`path` required"))?.to_string();
let at = position_param(e, "file.import_midi", p, "at")?.unwrap_or(0);
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let tracks = crate::io::import_midi_bytes(e, &bytes, at, bool_or(p, "tempo_map", true))?;
Ok(json!({"tracks": tracks}))
}),
cmd!(
"file.import_video",
"Video...",
["File", "Import"],
Some("Cmd+Ctrl+I"),
"{path, at?} — adds a Video track clip for the movie's picture (H.264, ProRes, Motion JPEG) and imports its audio onto a new track",
always,
|e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.import_video", "`path` required"))?.to_string();
let at = position_param(e, "file.import_video", p, "at")?.unwrap_or(0).max(0);
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::import_video_bytes(e, &path, bytes, at)
}
),
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce),
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let format = match str_param(p, "format") {
Some("aiff") | Some("aif") => soundcraft_audio_io::FileFormat::Aiff,
Some("flac") => soundcraft_audio_io::FileFormat::Flac,
_ => soundcraft_audio_io::FileFormat::Wav,
};
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
};
let fold = fold_down_param(p, "file.bounce_stems")?;
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }, fold)?;
Ok(json!({"files": files}))
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None };
let bytes = crate::io::export_midi(e, only.as_deref())?;
crate::io::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") {
crate::io::write_file(path, text.as_bytes())?;
}
Ok(json!({"text": text}))
}),
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string();
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("file.export_clips", "no clips selected"));
}
let n = crate::io::export_clips(e, &ids, &dir)?;
Ok(json!({"written": n, "dir": dir}))
}),
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string();
refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?;
let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?;
let mut s = e.session().clone();
s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master);
let mut tmp = crate::Engine::new(s);
crate::io::save_session(&mut tmp, &path)?;
Ok(json!({"path": path}))
}),
cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
write_score(e, p, "file.export_sibelius", false)
}),
cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
write_score(e, p, "file.send_to_sibelius", false)
}),
cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| {
write_score(e, p, "file.print_score", true)
}),
cmd!(
"file.score_setup",
"Score Setup...",
["File"],
None,
"{title?, composer?, bars_per_system?: 1..16, show_track_names?: bool} — returns the current setup",
always,
|e, p| {
let mut setup = crate::score::ScoreSetup::from_session(e.session());
if let Some(t) = str_param(p, "title") {
setup.title = t.to_string();
}
if let Some(c) = str_param(p, "composer") {
setup.composer = c.to_string();
}
if let Some(b) = p.get("bars_per_system").and_then(serde_json::Value::as_f64) {
setup.bars_per_system = if b.is_finite() { b.clamp(1.0, 16.0) as u32 } else { 4 };
}
if let Some(b) = p.get("show_track_names").and_then(serde_json::Value::as_bool) {
setup.show_track_names = b;
}
setup.store(e.session_mut());
Ok(
json!({"title": setup.title, "composer": setup.composer, "bars_per_system": setup.bars_per_system, "show_track_names": setup.show_track_names}),
)
}
),
cmd!(noundo "file.export_clip_groups", "Export Clip Groups...", [], None, "{path, clips?} — writes a .scgrp clip group file with its audio", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_clip_groups", "`path` required"))?.to_string();
let ids = clip_ids_param(e, p);
let n = crate::clip_group_file::export(e, &ids, &path)?;
Ok(json!({"path": path, "clips": n}))
}),
cmd!("file.import_clip_groups", "Clip Groups...", ["File", "Import"], None, "{path, track?: first target track, at?}", always, |e, p| {
let id = "file.import_clip_groups";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
let at = position_param(e, id, p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
let first = tracks_param(e, id, p)?.first().copied();
let placed = crate::clip_group_file::import(e, &path, first, at)?;
e.session_mut().edit.selected_clips.clone_from(&placed);
Ok(json!({"clips": placed.iter().map(|c| c.0).collect::<Vec<_>>()}))
}),
cmd!(query "file.get_info", "Get Info...", ["File"], None, "{}", always, |e, _| Ok(crate::inspect::session(e, false))),
cmd!(noundo "app.quit", "Quit", [], Some("Cmd+Q"), "{}", always, |_, _| Ok(json!({"quit": true}))),
]
}
/// Writing a session over an existing file needs `replace: true` (see `io::replaces`).
fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> {
if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) {
return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display())));
}
Ok(())
}
fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Untitled").to_string();
let sr =
SampleRate::new(i64_or(p, "sample_rate", 48_000).clamp(0, i64::from(u32::MAX)) as u32).map_err(|err| bad("session.new", err.to_string()))?;
let mut s = if str_param(p, "template") == Some("demo") { crate::demo::demo_session() } else { Session::new(name.clone(), sr) };
s.name = name;
if let Some(b) = p.get("bit_depth") {
s.bit_depth = match b.to_string().trim_matches('"') {
"16" => soundcraft_model::BitDepthSetting::Int16,
"32" | "32f" => soundcraft_model::BitDepthSetting::Float32,
_ => soundcraft_model::BitDepthSetting::Int24,
};
}
e.replace_session(s);
e.path = None;
Ok(json!({"name": e.session().name, "sample_rate": e.session().sample_rate.hz()}))
}
fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
let mut paths: Vec<String> =
p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default();
if let Some(one) = str_param(p, "path") {
paths.push(one.to_string());
}
if paths.is_empty() {
return Err(bad("file.import_audio", "`path` or `paths` required"));
}
let at = position_param(e, "file.import_audio", p, "at")?.unwrap_or(0).max(0);
let target = import_audio_target(e, p)?;
let mut out = Vec::new();
for path in &paths {
let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string();
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
out.push(r);
}
Ok(json!({"imported": out}))
}
/// Destination for `file.import_audio`: an explicit `track` wins; otherwise `new_tracks`
/// (default true) creates a new track per file, and `new_tracks: false` uses the selected
/// audio track or errors when none is selected.
fn import_audio_target(e: &Engine, p: &Value) -> Result<Option<TrackId>> {
if let Some(t) = track_param(e, "file.import_audio", p, "track")? {
return Ok(Some(t));
}
if bool_or(p, "new_tracks", true) {
return Ok(None);
}
let s = e.session();
let selected = s.edit.selected_tracks.iter().find_map(|id| s.track(*id).filter(|t| t.kind == TrackKind::Audio).map(|t| t.id));
selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track"))
}
/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error.
fn bounce_bus(e: &Engine, p: &Value) -> Result<Option<soundcraft_model::BusId>> {
let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
if name.eq_ignore_ascii_case("main") {
return Ok(None);
}
e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`")))
}
/// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix.
fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
match str_param(p, "fold_down").map(str::trim) {
None | Some("") | Some("none") => Ok(false),
Some(f) if f.eq_ignore_ascii_case("stereo") => Ok(true),
Some(f) => Err(bad(id, format!("unknown fold_down `{f}` (use \"stereo\")"))),
}
}
fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let job = bounce_job(e, p)?;
let (path, seconds) = (job.path().to_string_lossy().into_owned(), e.session().sample_rate.seconds(job.range().len()));
let done = job.run()?;
Ok(json!({
"path": path,
"bytes": done.bytes,
"seconds": seconds,
"peak_db": done.peak_db,
"lufs": done.lufs,
"true_peak_db": done.true_peak_db,
}))
}
/// The bounce `file.bounce_mix` runs for `p`, not rendered yet.
pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
})?;
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
let normalize = bool_or(p, "normalize", false);
let fold = fold_down_param(p, "file.bounce_mix")?;
let bus = bounce_bus(e, p)?;
crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)
}
/// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`).
fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value> {
let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { tracks_param(e, id, p)? } else { Vec::new() };
let parts = crate::score::layout(e.session(), &tracks);
if parts.is_empty() {
return Err(bad(id, "no MIDI or instrument tracks to notate"));
}
let setup = crate::score::ScoreSetup::from_session(e.session());
let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") };
let path = match str_param(p, "path") {
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
};
crate::io::write_file(&path, text.as_bytes())?;
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Engine;
use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
use soundcraft_model::ChannelFormat;
fn tone_wav(dir: &std::path::Path, name: &str) -> String {
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
// Default / new_tracks:true: create a new track even when one is selected.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 2);
assert_eq!(e.session().track(target).unwrap().clips().len(), 0);
assert_eq!(e.session().tracks[1].clips().len(), 1);
// new_tracks:false with a selected audio track: import onto it.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
let target = e.session().tracks[0].id;
e.session_mut().edit.selected_tracks = vec![target];
e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().track(target).unwrap().clips().len(), 1);
// new_tracks:false with no selection and no track: clear error.
let mut e = Engine::default();
let err = e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap_err();
assert!(err.to_string().contains("new_tracks: false"), "{err}");
// Explicit track wins over new_tracks:true.
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap();
e.execute("file.import_audio", &json!({"path": wav, "track": "Target", "new_tracks": true})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips().len(), 1);
// Omitted new_tracks still creates a track on a blank session.
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].kind, TrackKind::Audio);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Mono);
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
let dir = std::env::temp_dir().join(format!("sc-bounce-ext-{}", std::process::id()));
assert!(std::fs::create_dir_all(&dir).is_ok());
for name in ["mix.mp3", "mix.ogg", "mix.xyz"] {
let path = dir.join(name);
let err = e.execute("file.bounce_mix", &json!({"path": path.to_string_lossy(), "start": 0, "end": 480})).map(|_| ()).unwrap_err();
assert!(err.to_string().contains("supported: wav"), "{name}: {err}");
assert!(!path.exists(), "{name} must not be written");
}
let ok = dir.join("mix.flac");
assert!(e.execute("file.bounce_mix", &json!({"path": ok.to_string_lossy(), "start": 0, "end": 480})).is_ok());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
#[test]
fn bounce_source_renders_the_named_bus() {
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
let track = e.session().tracks[0].id;
{
let tr = e.session_mut().track_mut(track).unwrap();
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
send.level_db = -12.0;
tr.mixer.sends[0] = Some(send);
}
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
let path = dir.join(name);
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
if let Some(source) = source {
params["source"] = json!(source);
}
let value = e.execute("file.bounce_mix", &params).unwrap();
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
(std::fs::read(&path).unwrap(), peak)
};
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
assert_eq!(main_bytes, named_main);
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
assert!(main_peak > -20.0, "{main_peak}");
assert_ne!(stem_bytes, main_bytes);
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
assert_ne!(empty_bytes, main_bytes);
assert!(empty_peak < -100.0, "{empty_peak}");
let missing = dir.join("missing.wav");
let err = e
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
.unwrap_err();
assert!(err.to_string().contains("unknown bus"), "{err}");
assert!(!missing.exists());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.is_dir() {
remove_media(&p);
} else if p.extension().is_some_and(|x| x != "scraft") {
let _ = std::fs::remove_file(&p);
}
}
}
#[test]
fn session_open_reports_missing_media() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
assert!(ok.execute("session.open", &json!({"path": path})).unwrap()["missing"].as_array().is_some_and(Vec::is_empty));
remove_media(&dir);
let mut r = Engine::default();
let res = r.execute("session.open", &json!({"path": path})).unwrap();
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_a_session_without_audio_creates_its_folder() {
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song").join("Song.scraft");
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn tone_engine() -> (Engine, u64) {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let track = e.session().tracks[0].id;
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = e.session_mut().new_clip_id();
crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
e.session_mut().edit.selected_tracks = vec![track];
(e, track.0)
}
#[test]
fn same_named_clips_export_as_separate_files() {
let (mut e, track) = tone_engine();
let first = e.session().tracks[0].clips()[0].id;
let src = e.session().tracks[0].clips()[0].source().unwrap();
let second = e.session_mut().new_clip_id();
crate::edit::place_clip(
e.session_mut(),
soundcraft_model::TrackId(track),
soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800),
);
for id in [first, second] {
e.session_mut().find_clip_mut(id).unwrap().name = "Take".into();
}
let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap();
assert_eq!(n["written"], 2);
let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
wavs.sort();
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
let p = json!({"dir": dir.to_string_lossy()});
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.execute("file.export_clips", &p).is_err());
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
let r = e.execute("file.export_clips", &p).unwrap();
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
let _ = std::fs::remove_dir_all(&dir);
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
let mut keys_seq = Sequence::default();
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
let mut drum_seq = Sequence::default();
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
let a = s.new_clip_id();
let b = s.new_clip_id();
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
Engine::new(s)
}
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
let smf = soundcraft_midi::read_smf(bytes).unwrap();
let mut out = Vec::new();
for t in &smf.tracks {
for c in &t.sequence.ctrls {
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
out.push((t.name.clone(), n, c.value));
}
}
}
out
}
#[test]
fn midi_export_keeps_controllers_and_honours_tracks() {
let mut e = midi_engine();
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let all = dir.join("all.mid");
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
let keys = dir.join("keys.mid");
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
assert_eq!(ctrls[0].0, "Keys");
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
let src = e.session().sources[0].id;
let audio = e.session().pool.get(src).unwrap();
assert_eq!(audio.buffer.channels.len(), 6);
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
let id = e.session().tracks[0].id;
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
assert_eq!(e.session().tracks[0].clips().len(), 1);
}
#[test]
fn repeated_consolidate_keeps_distinct_audio_files() {
let (mut e, track) = tone_engine();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let cid = e.session().tracks[0].clips()[0].id;
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
assert_ne!(consolidated[0], consolidated[1]);
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tone.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut opened = Engine::default();
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
let a = std::fs::read(&files[0]).unwrap();
let b = std::fs::read(&files[1]).unwrap();
assert_ne!(a, b, "the second consolidation replaced the first file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_copy_leaves_the_open_session_alone() {
let (mut e, _) = tone_engine();
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("Original.scraft");
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
assert!(e.is_dirty());
let copy_dir = dir.join("copy");
std::fs::create_dir_all(&copy_dir).unwrap();
let copy = copy_dir.join("Copy.scraft");
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
assert_eq!(e.session().name, "Original");
assert!(e.is_dirty());
assert_eq!(e.session().tracks.len(), 2);
let mut from_original = Engine::default();
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
assert_eq!(from_original.session().tracks.len(), 1);
let mut from_copy = Engine::default();
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(from_copy.session().tracks.len(), 2);
assert!(from_copy.session().track_by_name("Extra").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}