Merge remote-tracking branch 'origin/main' into sampler

This commit is contained in:
Brandon Thomas 2026-10-11 21:02:27 -04:00
commit b9dec547df
38 changed files with 2060 additions and 349 deletions

36
Cargo.lock generated
View File

@ -2142,6 +2142,18 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@ -3513,6 +3525,7 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"zip",
]
[[package]]
@ -3635,8 +3648,12 @@ version = "0.5.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"soundcraft-engine",
"soundcraft-model",
"soundcraft-playback",
"soundcraft-ui-egui",
"wasm-bindgen",
@ -4096,6 +4113,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "uds_windows"
version = "1.2.1"
@ -5386,6 +5409,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [
"crc32fast",
"indexmap",
"memchr",
"nix",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"

View File

@ -38,6 +38,8 @@ proptest = "1"
# audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
cpal = "0.16"
# ui

View File

@ -107,7 +107,8 @@ where things are.
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
effects, and automatic plugin delay compensation.

View File

@ -17,10 +17,14 @@ path = "src/main.rs"
soundcraft-engine = { workspace = true }
soundcraft-ui-egui = { workspace = true }
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
js-sys = "0.3"
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }

View File

@ -7,7 +7,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - no native file dialogs: the demo session is loaded on start;
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
//! download (the demo session is still loaded on start);
//! - browsers only start audio after a user gesture, so the first click may be needed before
//! playback is heard.
//!

View File

@ -1,7 +1,11 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use soundcraft_engine::Engine;
use serde_json::json;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::JsCast as _;
@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
io::set_deliver(download);
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@ -36,7 +41,7 @@ pub fn start() {
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
let app = SoundApp::new(engine, Some(player), Services::default());
Ok(Box::new(WebShell(app)))
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
}),
)
.await;
@ -53,18 +58,182 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
struct WebShell(SoundApp);
/// What a file read from the browser is for.
#[derive(Debug, Clone, Copy)]
enum Purpose {
Session,
Audio,
Midi,
}
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
impl Purpose {
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
fn of(name: &str) -> Self {
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
if SESSION_EXTS.contains(&ext.as_str()) {
Self::Session
} else if MIDI_EXTS.contains(&ext.as_str()) {
Self::Midi
} else {
Self::Audio
}
}
/// The command whose path prompt this replaces in the browser.
fn for_command(cmd: &str) -> Option<Self> {
match cmd {
"session.open" => Some(Self::Session),
"file.import_audio" => Some(Self::Audio),
"file.import_midi" => Some(Self::Midi),
_ => None,
}
}
fn filter(self) -> (&'static str, &'static [&'static str]) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS),
Self::Audio => ("Audio", AUDIO_EXTS),
Self::Midi => ("MIDI", MIDI_EXTS),
}
}
}
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
/// handled on the next frame. The browser has no paths: files travel as bytes.
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
struct WebShell {
app: SoundApp,
inbox: Inbox,
}
impl WebShell {
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
/// browser can't read (#63, #64). Show the browser's file picker instead.
fn take_path_prompt(&mut self, ctx: &egui::Context) {
let purpose = match &self.app.dialogs.open {
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
_ => None,
};
let Some(purpose) = purpose else { return };
self.app.dialogs.open = None;
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let (label, exts) = purpose.filter();
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
let bytes = file.read().await;
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
ctx.request_repaint();
});
}
fn take_drops(&mut self, ctx: &egui::Context) {
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {
Purpose::Session => {
let _ = self.app.run("transport.stop", json!({}));
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
})
}
// Imports are edits: one undo step each, like the import commands.
Purpose::Audio => {
let before = e.session_arc();
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
e.push_undo("Import Audio", before);
format!("Imported {name}")
})
}
Purpose::Midi => {
let before = e.session_arc();
io::import_midi_bytes(e, bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
}
};
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
}
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
self.take_drops(ctx);
self.take_path_prompt(ctx);
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
for (purpose, name, bytes) in pending {
self.handle(purpose, &name, &bytes);
}
self.app.logic(ctx);
// A prompt opened during this frame becomes a picker on the next one.
self.take_path_prompt(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
}
}
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
/// hook on the web).
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
Some("scraftz") => "application/zip",
Some("wav") => "audio/wav",
Some("aif" | "aiff") => "audio/aiff",
Some("flac") => "audio/flac",
Some("mid" | "midi") => "audio/midi",
Some("svg") => "image/svg+xml",
Some("txt") => "text/plain",
Some("json" | "scraft") => "application/json",
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
_ => "application/octet-stream",
});
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click was dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}

View File

@ -20,11 +20,20 @@ mod logging;
mod native_menu;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
use std::sync::Arc;
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
fn set_title(ctx: &egui::Context, title: String) {
let id = egui::Id::new("window_title");
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
@ -48,6 +57,8 @@ impl eframe::App for App {
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
soundcraft_ui_egui::fit_window_to_screen(ctx);
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
@ -68,7 +79,7 @@ impl eframe::App for App {
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
set_title(ctx, title);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@ -91,7 +102,7 @@ impl eframe::App for App {
fn open_path(app: &mut SoundApp, p: &str) {
let lower = p.to_ascii_lowercase();
let r = if lower.ends_with(".scraft") {
app.run("session.open", serde_json::json!({"path": p}))
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
@ -283,3 +294,21 @@ fn main() -> eframe::Result {
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
}
#[cfg(test)]
mod tests {
use super::set_title;
#[test]
fn the_title_is_sent_only_when_it_changes() {
let ctx = egui::Context::default();
let titles = |title: &str| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
out.textures_delta.clear();
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
};
assert_eq!(titles("Song — SoundCraft"), 1);
assert_eq!(titles("Song — SoundCraft"), 0);
assert_eq!(titles("Song * — SoundCraft"), 1);
}
}

View File

@ -113,6 +113,11 @@ impl NativeMenu {
self.last_sync = Some(now);
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if let Some(label) = menus::live_label(app, entry.command.as_deref())
&& entry.item.text() != label
{
entry.item.set_text(label);
}
if enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;

View File

@ -22,6 +22,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
zip.workspace = true
[dev-dependencies]
proptest.workspace = true

View File

@ -356,7 +356,8 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
let exact = bool_or(p, "exact", false);
let s = e.session_mut();
let s = e.session();
let mut ed = s.edit.clone();
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
@ -370,11 +371,11 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
s.edit.selected_tracks = t;
ed.selected_tracks = t;
}
match (st, en) {
(Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)),
(Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)),
_ => {}
}
if let Some(c) = clips {
@ -390,21 +391,25 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
if let Some(r) = range {
s.edit.selection = r;
s.edit.selected_tracks = trs;
ed.selection = r;
ed.selected_tracks = trs;
}
s.edit.selected_clips = c;
ed.selected_clips = c;
} else if list_changes {
s.edit.selected_clips.clear();
ed.selected_clips.clear();
}
if list_changes {
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
s.edit.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
}
if s.edit.link_timeline_edit {
s.edit.timeline_selection = s.edit.selection;
if ed.link_timeline_edit {
ed.timeline_selection = ed.selection;
}
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips, "sources": s.edit.selected_sources}))
let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources});
if ed != s.edit {
e.session_mut().edit = ed;
}
Ok(out)
}
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
@ -760,3 +765,21 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
}
Ok(json!({"consolidated": n}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
let mut e = Engine::default();
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200});
e.execute("edit.select", &sel).unwrap();
let rev = e.revision;
assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]]));
assert_eq!(e.revision, rev);
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
assert_ne!(e.revision, rev);
}
}

View File

@ -42,10 +42,14 @@ pub fn specs() -> Vec<CommandSpec> {
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?;
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, "{}", always, |e, _| {
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!({}))
@ -103,13 +107,13 @@ pub fn specs() -> Vec<CommandSpec> {
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())?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
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") {
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(path, text.as_bytes())?;
}
Ok(json!({"text": text}))
}),
@ -289,7 +293,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let fold = fold_down_param(p, "file.bounce_mix")?;
let bus = bounce_bus(e, p)?;
let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, &bytes)?;
Ok(
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
)
@ -308,7 +312,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
};
std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
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}))
}
@ -466,6 +470,17 @@ mod tests {
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();

View File

@ -105,7 +105,15 @@ pub fn specs() -> Vec<CommandSpec> {
insert_params
),
cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move),
cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}", has_selection, send),
cmd!(
"mix.send",
"Assign Send",
[],
None,
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
has_selection,
send
),
cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| {
let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?;
if let Some(tr) = e.session_mut().track_mut(t)

View File

@ -53,6 +53,8 @@ pub struct CommandSpec {
pub run: Run,
pub journal: bool,
pub undoable: bool,
/// Only touches `edit.zoom`, through `Engine::zoom_mut`.
pub view: bool,
}
/// Serializable description of a command.
@ -82,18 +84,16 @@ impl CommandSpec {
}
}
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants.
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants.
#[macro_export]
macro_rules! cmd {
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
};
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
(query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) };
(noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) };
(view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) };
(@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view }
};
($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) };
}
pub fn command_specs() -> &'static [CommandSpec] {

View File

@ -17,6 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
run: insert_state,
journal: false,
undoable: false,
view: false,
}]
}

View File

@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
"comments": tr.comments,
});
let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, text.as_bytes())?;
Ok(json!({"path": path, "track": first}))
}

View File

@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)),
cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)),
cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0);
e.session_mut().edit.zoom.samples_per_px = v;
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)),
cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| {
let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?;
zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64))
}),
cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP);
e.zoom_mut().samples_per_px = v;
Ok(json!({"samples_per_px": v}))
}),
cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize;
let s = e.session_mut();
let z = e.zoom_mut();
if bool_or(p, "store", false) {
if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px }
} else if let Some(z) = s.edit.zoom.presets.get(i) {
s.edit.zoom.samples_per_px = *z;
if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px }
} else if let Some(v) = z.presets.get(i) {
z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP);
}
Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px}))
Ok(json!({"samples_per_px": z.samples_per_px}))
}),
cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session_mut();
let r = s.edit.selection;
let r = e.session().edit.selection;
if !r.is_empty() {
s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25);
s.edit.zoom.scroll = r.start;
let z = e.zoom_mut();
z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = r.start;
}
Ok(json!({}))
}),
cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session_mut();
let s = e.session();
let end = s.content_end().max(s.sample_rate.samples(30.0));
s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25);
s.edit.zoom.scroll = 0;
let z = e.zoom_mut();
z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = 0;
Ok(json!({}))
}),
cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
let to = position_param(e, "view.scroll", p, "to")?;
let by = f64_or(p, "by_px", 0.0);
let s = e.session_mut();
let spp = s.edit.zoom.samples_per_px;
s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0);
Ok(json!({"scroll": s.edit.zoom.scroll}))
let z = e.zoom_mut();
let by = soundcraft_time::to_samples(by * z.samples_per_px);
z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION);
Ok(json!({"scroll": z.scroll}))
}),
cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let s = e.session_mut();
let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) };
s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom}))
cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let z = e.zoom_mut();
let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) };
z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": z.waveform_zoom}))
}),
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
@ -88,18 +93,19 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
let s = e.session_mut();
let anchor = s.edit.selection.start;
let old = s.edit.zoom.samples_per_px;
let new = (old * f).clamp(0.25, 1_000_000.0);
// Keep the insertion point at the same screen x when it is visible.
let x = (anchor - s.edit.zoom.scroll) as f64 / old;
if (0.0..4000.0).contains(&x) {
s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0);
const MIN_SPP: f64 = 0.25;
const MAX_SPP: f64 = 1_000_000.0;
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
let insertion = e.session().edit.selection.start as f64;
let z = e.zoom_mut();
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
}
s.edit.zoom.samples_per_px = new;
Ok(json!({"samples_per_px": new}))
z.samples_per_px = new;
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
}
fn ruler(e: &mut Engine, p: &Value) -> Result<Value> {
@ -157,4 +163,55 @@ mod tests {
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
#[test]
fn zoom_by_holds_the_position_under_the_pointer() {
let mut e = Engine::default();
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
e.execute("view.scroll", &json!({"to": 48_000})).unwrap();
e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap();
assert_eq!(view(&e), (50.0, 78_000 - 300 * 50));
e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap();
assert_eq!(view(&e), (100.0, 48_000));
}
#[test]
fn zoom_and_scroll_stay_in_range() {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap();
e.execute("view.zoom_to_selection", &json!({})).unwrap();
assert_eq!(view(&e).0, MAX_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap();
assert_eq!(view(&e).0, MIN_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap();
assert_eq!(view(&e), (MAX_SPP, 0));
for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] {
assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}");
}
e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap();
assert_eq!(view(&e).1, MAX_POSITION);
e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap();
assert_eq!(view(&e).1, 0);
}
#[test]
fn view_commands_skip_undo_the_journal_and_session_copies() {
let mut e = Engine::default();
let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone();
let steps = e.undo_history().len();
for db in [-1.0, -2.0] {
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap();
}
assert_eq!(e.undo_history().len(), steps + 1);
let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len());
let before = state(&e);
e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap();
e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap();
assert_eq!(state(&e), before);
}
}

View File

@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value {
})).collect::<Vec<_>>(),
"selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips},
"edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(),
"zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll},
"transport": e.transport,
"undo": e.undo_label(), "redo": e.redo_label(),
})

View File

@ -510,7 +510,13 @@ pub fn sanitize_name(n: &str) -> String {
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
///
/// In the browser there are no folders: the session and all its audio go out as one bundle file
/// (`<name>.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]).
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
if cfg!(target_arch = "wasm32") {
return save_session_bundle(e, path);
}
let (fresh, written, p) = write_session(e.session(), path, false)?;
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
*e.session_mut() = fresh;
@ -709,16 +715,11 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
for src in s.sources.clone() {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
match std::fs::read(&p) {
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
Ok((_, mut buf)) => {
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
buf.sample_rate = s.sample_rate.hz();
}
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
Ok(bytes) => {
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
missing.push(format!("{}: {err}", p.display()));
}
Err(err) => missing.push(format!("{}: {err}", p.display())),
},
}
Err(_) => missing.push(p.display().to_string()),
}
}
@ -732,6 +733,173 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
Ok(missing)
}
/// Decode one source's audio file into the session pool, at the session rate.
fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> {
let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
buf.sample_rate = s.sample_rate.hz();
}
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
Ok(())
}
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
/// saves and opens sessions as bundles.
pub const BUNDLE_EXTENSION: &str = "scraftz";
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
const MAX_BUNDLE_ENTRIES: usize = 10_000;
const MAX_BUNDLE_BYTES: u64 = 1 << 30;
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
/// installs one that starts a download. Native builds write files and never install it.
pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>;
static DELIVER: std::sync::OnceLock<DeliverHook> = std::sync::OnceLock::new();
/// Install the delivery hook (once, at start-up of the web app).
pub fn set_deliver(h: DeliverHook) {
let _ = DELIVER.set(h);
}
/// Write one output file: to `path` natively; in the browser, delivered under its file name.
/// Without this, every export on the web failed with «operation not supported on this platform».
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io);
}
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
/// session with its source paths pointing into the bundle.
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
use std::io::Write as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
let mut s = session.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
let mut taken = std::collections::HashSet::new();
for src in &mut s.sources {
let Some(audio) = s.pool.get(src.id) else { continue };
let base = sanitize_name(&src.name);
let mut target = format!("Audio Files/{base}.wav");
let mut k = 1;
while taken.contains(&target) && k <= 9_999 {
target = format!("Audio Files/{base}_{k:02}.wav");
k += 1;
}
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(target.as_str(), opts).map_err(zerr)?;
w.write_all(&bytes).map_err(ioerr)?;
taken.insert(target.clone());
src.path = target;
src.unsaved = false;
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
let bytes = w.finish().map_err(zerr)?.into_inner();
Ok((bytes, s))
}
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone();
if let Some(stem) = stem {
s.name = stem;
}
let (bytes, fresh) = session_bundle(&s)?;
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
write_file(&file, &bytes)?;
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
*e.session_mut() = fresh;
e.path = Some(file);
e.mark_clean();
Ok(written)
}
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
/// [`open_session`].
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
for src in s.sources.clone() {
match files.remove(&src.path) {
Some(audio) => {
let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string);
if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) {
missing.push(format!("{}: {err}", src.path));
}
}
None => missing.push(src.path.clone()),
}
}
// Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing {
e.message(format!("missing media: {m}"));
}
Ok(missing)
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
use std::io::Read as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
if z.len() > MAX_BUNDLE_ENTRIES {
return Err(EngineError::Io("bundle: too many files".into()));
}
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
for i in 0..z.len() {
let mut f = z.by_index(i).map_err(zerr)?;
if f.is_dir() {
continue;
}
let rel = f.name().map_err(zerr)?.to_string();
let size = f.size();
total = total.saturating_add(size);
if total > MAX_BUNDLE_BYTES {
return Err(EngineError::Io("bundle: too large".into()));
}
let mut data = Vec::new();
// The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else {
files.insert(rel, data);
}
}
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
Ok((text, files))
}
/// The record-enabled audio tracks, each with the input channels it records (those its input
/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file
/// `<track>_<NN>.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved).
@ -1060,3 +1228,75 @@ mod stem_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod bundle_tests {
use super::*;
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let (bytes, fresh) = session_bundle(e.session()).unwrap();
assert!(bytes.starts_with(b"PK\x03\x04"));
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
assert!(missing.is_empty(), "{missing:?}");
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
for src in &e.session().sources {
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
}
assert!(!r.is_dirty());
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
}
#[test]
fn same_named_sources_get_distinct_files() {
let mut e = crate::demo::demo_engine();
let name = e.session().sources[0].name.clone();
let s = e.session_mut();
let n = s.sources.len();
s.sources[1].name = name;
let (bytes, fresh) = session_bundle(e.session()).unwrap();
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
assert_eq!(paths.len(), n);
let mut r = Engine::default();
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
}
#[test]
fn bare_session_opens_with_its_audio_reported_missing() {
let e = crate::demo::demo_engine();
let (_, fresh) = session_bundle(e.session()).unwrap();
let text = fresh.to_json().unwrap();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
assert_eq!(missing.len(), fresh.sources.len());
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
}
#[test]
fn hostile_bundles_are_errors_not_panics() {
let mut r = Engine::default();
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
}
// A valid zip without a session inside.
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
let only_audio = w.finish().unwrap().into_inner();
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
}
#[test]
fn write_file_writes_to_disk_natively() {
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
write_file(&p.to_string_lossy(), b"hello").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let _ = std::fs::remove_file(&p);
}
}

View File

@ -16,8 +16,9 @@ pub mod io;
pub mod score;
use serde_json::{Value, json};
use soundcraft_model::{Clip, Session, TrackId};
use soundcraft_model::{Clip, Session, TrackId, ZoomState};
use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
@ -108,9 +109,13 @@ pub struct Engine {
pub journal: Vec<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change (UI caches key on it).
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
pub revision: u64,
dirty: bool,
/// The document as last opened or saved, for `is_dirty`.
saved: Arc<Session>,
/// `is_dirty` as of a revision.
dirty_at: Cell<(u64, bool)>,
/// Gesture key for undo coalescing (see `execute_merged`).
merge_key: Option<String>,
/// Messages for the UI status line / agents.
@ -126,8 +131,10 @@ impl Default for Engine {
impl Engine {
pub fn new(session: Session) -> Self {
install_panic_hook();
let doc = Arc::new(session);
Engine {
doc: Arc::new(session),
saved: Arc::clone(&doc),
doc,
undo: Vec::new(),
redo: Vec::new(),
history_limit: 200,
@ -138,6 +145,7 @@ impl Engine {
path: None,
revision: 1,
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
messages: Vec::new(),
}
@ -158,23 +166,42 @@ impl Engine {
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
&mut Arc::make_mut(&mut self.doc).edit.zoom
}
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
self.transport_requests.clear();
self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
self.saved = Arc::clone(&self.doc);
self.undo.clear();
self.redo.clear();
self.revision = self.revision.wrapping_add(1);
self.dirty = false;
}
/// Whether the document changed since it was opened or saved. Selecting, locating and zooming
/// don't count, and undoing back to the saved state makes it clean again.
pub fn is_dirty(&self) -> bool {
self.dirty
if !self.dirty {
return false;
}
let (rev, dirty) = self.dirty_at.get();
if rev == self.revision {
return dirty;
}
let dirty = !same_document(&self.saved, &self.doc);
self.dirty_at.set((self.revision, dirty));
dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
self.saved = Arc::clone(&self.doc);
}
pub fn can_undo(&self) -> bool {
@ -227,7 +254,6 @@ impl Engine {
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
/// document is restored.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
self.merge_key = None;
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(reason) = (spec.enabled)(self) {
// A call that names its targets (tracks, clips, a range) does not need a selection;
@ -237,6 +263,10 @@ impl Engine {
return Err(EngineError::Disabled(id.to_string(), reason));
}
}
if spec.view {
return self.execute_view(spec, params);
}
self.merge_key = None;
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
@ -244,12 +274,10 @@ impl Engine {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
self.doc = before;
self.revision = rev.wrapping_add(1);
self.dirty = dirty;
return Err(EngineError::Internal(id.to_string(), msg));
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
}
};
match result {
@ -274,6 +302,16 @@ impl Engine {
}
}
fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result<Value> {
let zoom = self.doc.edit.zoom;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)))
.unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p))));
if result.is_err() && self.doc.edit.zoom != zoom {
Arc::make_mut(&mut self.doc).edit.zoom = zoom;
}
result
}
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
/// with the same `key` collapse into a single undo step.
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<Value> {
@ -318,6 +356,19 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@ -329,6 +380,10 @@ fn install_panic_hook() {
});
}
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
mod merge_tests {
use super::*;
@ -346,3 +401,27 @@ mod merge_tests {
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
}
}
#[cfg(test)]
mod dirty_tests {
use super::*;
#[test]
fn selecting_locating_and_zooming_leave_the_session_clean() {
let mut e = crate::demo::demo_engine();
for (id, p) in [
("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})),
("edit.select_none", json!({})),
("transport.locate", json!({"at": 96_000})),
("view.zoom_in", json!({})),
("view.scroll", json!({"to": 48_000})),
] {
e.execute(id, &p).unwrap();
assert!(!e.is_dirty(), "{id}");
}
e.execute("mix.mute", &json!({"track": "Kick"})).unwrap();
assert!(e.is_dirty());
e.undo();
assert!(!e.is_dirty());
}
}

View File

@ -45,6 +45,9 @@ const KEYS: &[&str] = &[
"numerator",
"denominator",
"factor",
"x",
"by_px",
"samples_per_px",
"sensitivity",
"rating",
"direction",

View File

@ -580,6 +580,10 @@ impl Player {
self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open())
}
pub fn any_editor_open(&self) -> bool {
self.side().editors.values().any(|e| e.is_open())
}
/// Shows a parameter change made elsewhere in the plugin's open editor.
pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) {
if let Some(e) = self.side().editors.get_mut(&(track, slot)) {

View File

@ -7,6 +7,7 @@
//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect`
//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs)
//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}`
//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch)
//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`
//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`
//! - `app.quit`
@ -91,6 +92,8 @@ fn mods(p: &Value) -> egui::Modifiers {
fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option<Value> {
let p = &req.params;
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let pos = egui::pos2(f("x"), f("y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
Some(match req.method.as_str() {
"engine.execute" | "command" => {
let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or("");
@ -147,11 +150,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
Err(e) => err(e),
},
"ui.move" => {
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
app.synthetic.push(egui::Event::PointerMoved(pos));
ok(json!({}))
}
"ui.click" => {
let pos = egui::pos2(f("x"), f("y"));
let button = match p.get("button").and_then(Value::as_str) {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
@ -166,9 +168,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
ok(json!({}))
}
"ui.drag" => {
let a = egui::pos2(f("x"), f("y"));
let b = egui::pos2(f("to_x"), f("to_y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y")));
let m = mods(p);
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
@ -179,6 +179,27 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
ok(json!({}))
}
"ui.scroll" => {
let line = p.get("unit").and_then(Value::as_str) == Some("line");
let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point };
let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32;
let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p));
let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers };
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO)));
app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta)));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO)));
ok(json!({}))
}
"ui.zoom" => {
let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else {
return Some(err("ui.zoom needs a positive factor (> 1 zooms in)"));
};
let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32;
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each)));
ok(json!({}))
}
"ui.key" => {
let name = p.get("key").and_then(Value::as_str).unwrap_or("");
let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) };

View File

@ -24,11 +24,14 @@ pub enum Dialog {
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
Session { frame_rate: String, bit_depth: String },
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
SaveChanges { then: (String, Value) },
}
#[derive(Default)]
pub struct Dialogs {
pub open: Option<Dialog>,
/// Runs once the session has been saved from `SaveChanges`.
after_save: Option<(String, Value)>,
}
impl Dialogs {
@ -48,6 +51,7 @@ impl Dialogs {
Dialog::Group { .. } => "group",
Dialog::Session { .. } => "session",
Dialog::ScoreSetup { .. } => "score_setup",
Dialog::SaveChanges { .. } => "save_changes",
})
}
@ -68,6 +72,7 @@ impl Dialogs {
/// Open the dialog for a command; false when the command has no dialog.
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
self.after_save = None;
let home = default_dir();
let name = e.session().name.clone();
self.open = Some(match id {
@ -85,10 +90,16 @@ impl Dialogs {
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
"session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() },
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: id.into(),
"session.save" if e.path.is_some() => return false,
"session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(),
title: "Save Session As".into(),
path: format!("{home}/{name}/{name}.scraft"),
// The browser has no folders: the name becomes the bundle file's name.
path: if home.is_empty() {
format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION)
} else {
format!("{home}/{name}/{name}.scraft")
},
key: "path".into(),
},
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
@ -113,6 +124,11 @@ impl Dialogs {
let s = soundcraft_engine::score::ScoreSetup::from_session(e.session());
Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names }
}
"clip.rename" => {
let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id));
let Some((_, clip)) = target else { return false };
Dialog::RenameClip { id: clip.id, name: clip.name.clone() }
}
_ => match path_dialog(id, &home, &name) {
Some(d) => d,
None => return false,
@ -122,6 +138,32 @@ impl Dialogs {
}
}
/// When `id` would close a session with unsaved changes, ask first; true when the question is
/// open instead. A take in progress is stopped first, so it counts as a change.
pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
{
return false;
}
if app.engine.transport.recording {
app.stop_play();
}
if !app.engine.is_dirty() {
return false;
}
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
true
}
/// Runs `id`, unless it asks to save first (`ask_to_save`).
pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value, String> {
if ask_to_save(app, id, &params) {
return Ok(Value::Null);
}
app.run(id, params)
}
/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus
/// Print Score, whose path is optional), with a default file name from the command.
pub fn takes_path(id: &str) -> bool {
@ -211,6 +253,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut save_first: Option<(String, Value)> = None;
let confirmed = matches!(d, Dialog::SaveChanges { .. });
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewSession { .. } => "New Session",
@ -225,6 +269,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Group { .. } => "Create Group",
Dialog::Session { .. } => "Session Setup",
Dialog::ScoreSetup { .. } => "Score Setup",
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
Dialog::SaveChanges { .. } => "Unsaved Changes",
}
.to_string();
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
@ -322,15 +368,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
Dialog::RenameTrack { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
name_field(ui, name);
if buttons(ui, "OK", enter) {
action = Some(("track.rename".into(), json!({"track": id.0, "name": name})));
}
}
Dialog::RenameClip { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
name_field(ui, name);
if buttons(ui, "OK", enter) {
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
}
@ -476,12 +520,54 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
));
}
}
Dialog::SaveChanges { then } => {
let name = &app.engine.session().name;
let revert = then.0 == "session.revert";
if revert || cfg!(target_arch = "wasm32") {
let (question, ok) = if revert {
(format!("Revert “{name}” to its last saved version?"), "Revert")
} else {
(format!("Close “{name}” without saving?"), "Don't Save")
};
ui.label(format!("{question} Your changes will be lost."));
if buttons(ui, ok, false) {
action = Some(then.clone());
}
} else {
ui.label(format!("Save changes to “{name}” before closing?"));
ui.separator();
ui.horizontal(|ui| {
if ui.button("Don't Save").clicked() {
action = Some(then.clone());
}
if buttons(ui, "Save", enter) {
save_first = Some(then.clone());
}
});
}
}
}
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
keep = false;
app.dialogs.after_save = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
}
if let Some(then) = save_first {
keep = false;
if !app.dialogs.open_for_command(&app.engine, "session.save") {
action = Some(("session.save".into(), json!({})));
}
app.dialogs.after_save = Some(then);
}
if let Some((cmd, params)) = &action
&& !confirmed
&& ask_to_save(app, cmd, params)
{
keep = false;
action = None;
}
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
if let Some((cmd, params)) = action {
keep = false;
match app.run(&cmd, params) {
@ -492,6 +578,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Err(e) => app.ui.status = e,
}
}
if saving
&& let Some((then, params)) = app.dialogs.after_save.take()
&& !app.engine.is_dirty()
&& let Err(e) = app.run(&then, params)
{
app.ui.status = e;
}
if keep {
app.dialogs.open = Some(d);
}
@ -512,6 +605,17 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
pressed
}
/// A name field that takes the focus with its text selected, so typing replaces the old name.
fn name_field(ui: &mut egui::Ui, name: &mut String) {
let mut out = egui::TextEdit::singleline(name).show(ui);
if !out.response.has_focus() {
out.response.request_focus();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
out.state.cursor.set_char_range(Some(all));
out.state.store(ui.ctx(), out.response.id);
}
}
/// The command the tempo-change dialog runs on OK.
fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
("event.tempo".into(), json!({"bpm": bpm, "at": at}))
@ -591,6 +695,18 @@ mod tests {
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
#[test]
fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() {
let mut e = soundcraft_engine::demo::demo_engine();
let mut d = Dialogs::default();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(!d.open_for_command(&e, "clip.rename"));
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap();
assert!(d.open_for_command(&e, "clip.rename"));
assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name));
}
#[test]
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
let (cmd, params) = tempo_change_action(96_000, 133.5);

File diff suppressed because it is too large Load Diff

View File

@ -234,7 +234,7 @@ pub struct SoundApp {
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: f64,
last_autosave: Option<std::time::Instant>,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
@ -297,7 +297,7 @@ impl SoundApp {
native_menu_bar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: 0.0,
last_autosave: None,
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
@ -511,15 +511,14 @@ impl SoundApp {
}
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
fn autosave(&mut self, now: f64) {
/// Timed by the wall clock: egui's stands still while the window is hidden.
fn autosave(&mut self) {
let Some(dir) = self.autosave_dir.clone() else { return };
if self.last_autosave == 0.0 {
self.last_autosave = now;
}
if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording {
let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now);
if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording {
return;
}
self.last_autosave = now;
self.last_autosave = Some(std::time::Instant::now());
self.capture_plugin_states();
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let path = dir.join(&name).join(format!("{name}.scraft"));
@ -786,6 +785,9 @@ impl SoundApp {
/// Per-frame logic: control channel, transport, document sync.
pub fn logic(&mut self, ctx: &egui::Context) {
if ctx.current_pass_index() > 0 {
return;
}
// Fonts set now take effect next frame, so draw only from the frame after.
if !self.fonts_ready {
if self.fonts_installed {
@ -802,8 +804,7 @@ impl SoundApp {
self.theme_applied = Some(light);
}
let now = ctx.input(|i| i.time);
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.last_frame = Some(now);
let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
control::drain(self, ctx);
self.apply_editor_edits();
@ -825,21 +826,17 @@ impl SoundApp {
}
}
self.write_automation(ctx);
self.autosave(now);
self.autosave();
if self.recorder.is_none()
&& !self.recorder_failed
&& self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)
{
self.ensure_input();
}
if self.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
// Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from.
// egui takes its predicted frame time off every delay, so add it back.
let editors = self.player.as_ref().is_some_and(Player::any_editor_open);
ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt));
control::collect_screenshots(self, ctx);
}
@ -905,6 +902,15 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
// Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it.
if self.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
/// UI state as JSON (control channel `ui.inspect`).
@ -992,6 +998,20 @@ mod tests {
assert_eq!(app.engine.transport.position, 0);
}
#[test]
fn a_hidden_window_still_autosaves() {
let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id()));
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.autosave_dir = Some(dir.clone());
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() };
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() };
let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx));
assert!(app.latest_autosave().is_some());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());

View File

@ -152,6 +152,7 @@ const DIALOG_COMMANDS: &[&str] = &[
"file.import_audio",
"file.import_midi",
"session.open",
"session.save",
"session.save_as",
"session.save_copy",
"track.group",
@ -162,6 +163,7 @@ const DIALOG_COMMANDS: &[&str] = &[
"event.quantize",
"event.transpose",
"edit.strip_silence",
"clip.rename",
];
/// Shared menu hierarchy for egui and native desktop hosts.
@ -222,7 +224,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
}
ui.separator();
if ui.button("Quit").clicked() {
let _ = app.run("app.quit", json!({}));
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
}
});
let extra = ui_aliases();
@ -262,8 +264,9 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
}
let id = command_for_path(&n.path, extra);
let (enabled, checked) = item_state(app, &n.path, id.as_deref());
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone());
let label = if checked { format!("✔ {label}") } else { label };
let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
if ui.add_enabled(enabled, btn).clicked()
&& let Some(id) = id
@ -394,6 +397,21 @@ const MENU_WINDOWS: &[(&str, &str)] = &[
("Event > MIDI Real-Time Properties", "window.rtp"),
];
/// The shortcut of an engine or window command.
pub fn shortcut_for(id: &str) -> Option<&'static str> {
soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut)
}
/// Undo and Redo name the step they would undo or redo; other items keep their catalog label.
pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
let e = &app.engine;
match id? {
"edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))),
"edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))),
_ => None,
}
}
/// Menu-style invocation: may open a dialog.
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
@ -420,12 +438,13 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
return;
}
let params = params_for(path, id);
let _ = app.run(id, params);
let _ = crate::dialogs::run_or_ask(app, id, params);
}
/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled.
/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its
/// dialog. Returns true when handled.
pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool {
wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)
(wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({}))
}
/// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path.
@ -639,4 +658,24 @@ mod tests {
invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
assert_eq!(app.dialogs.open_name(), Some("bounce"));
}
#[test]
fn save_asks_for_a_path_until_the_session_has_one() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as"));
app.dialogs.open = None;
assert!(invoke_shortcut_dialog(&mut app, "session.save"));
app.dialogs.open = None;
let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id()));
let path = dir.join("Song.scraft");
app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap();
app.run("track.new", json!({})).unwrap();
assert!(!invoke_shortcut_dialog(&mut app, "session.save"));
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(app.dialogs.open.is_none());
assert!(!app.engine.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0;
pub struct MidiEditorState {
pub selected: Vec<usize>,
pub drag: Option<NoteDrag>,
pub top_pitch: i32,
pub scroll: f32,
pub clip: Option<ClipId>,
/// Rubber-band selection start (screen position).
pub band: Option<egui::Pos2>,
@ -127,28 +127,27 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
// Vertical range: centre on the notes the first time.
let rows = (roll.height() / ROW_H).floor() as i32;
if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) {
if app.midi.clip != Some(cid) {
let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72);
let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48);
let mid = (hi + lo) / 2;
app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127);
app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H;
app.midi.clip = Some(cid);
}
if ui.rect_contains_pointer(roll) {
let dy = ui.input(|i| i.smooth_scroll_delta.y);
if dy.abs() > 0.5 {
app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127);
}
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
}
let top = app.midi.top_pitch;
app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0));
let scroll = app.midi.scroll;
let px_per_tick = roll.width() / len_ticks as f32;
let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick;
let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H;
let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll;
let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32;
let painter = ui.painter().with_clip_rect(roll.union(keys));
// Background rows and keys.
for r in 0..=rows {
let top = pitch_at(roll.min.y);
for r in 0..=rows + 1 {
let pitch = top - r;
let y = roll.min.y + r as f32 * ROW_H;
let y = y_of(pitch);
let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H));
painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) });
let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0));
@ -288,7 +287,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Audition on the instrument would go here (needs a live MIDI input path).
}
None => {
let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127);
let pitch = pitch_at(p.y).clamp(0, 127);
let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks;
let _ = app.run(
"midi.note_add",

View File

@ -404,7 +404,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark);
let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click());
if nresp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id.0]}));
crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video);
}
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);

View File

@ -38,7 +38,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
if resp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id]}));
crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true);
}
}
});

View File

@ -80,6 +80,10 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
continue;
}
let _ = app.run(id, json!({}));
} else if let Some(c) =
crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
{
let _ = app.run(c.0, json!({}));
}
}
}
@ -108,7 +112,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("window.search", json!({}));
true
}
Key::Equals if m.command => {
Key::Equals if m.command && !(mac && m.ctrl) => {
let _ = app.run("window.toggle_mix_edit", json!({}));
true
}

View File

@ -350,7 +350,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
if is_dir {
app.ui.workspace_dir = path;
} else if name.ends_with(".scraft") {
let _ = app.run("session.open", json!({"path": path}));
let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path}));
} else if name.ends_with(".mid") || name.ends_with(".midi") {
let _ = app.run("file.import_midi", json!({"path": path}));
} else {
@ -493,10 +493,13 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) {
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0)));
ui.label(c.label);
ui.label(egui::RichText::new(c.menu.join(" › ")).small());
let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › ")));
let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › ")));
for (shortcut, label, menu) in engine.chain(windows) {
let Some(shortcut) = shortcut else { continue };
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
ui.label(label);
ui.label(egui::RichText::new(menu).small());
ui.end_row();
}
});

View File

@ -0,0 +1,15 @@
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
#[test]
fn undo_and_redo_name_the_step_they_would_take() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let label = |app: &SoundApp, id| menus::live_label(app, Some(id));
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Can't Undo"));
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Undo Mute"));
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Can't Redo"));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Redo Mute"));
assert_eq!(label(&app, "edit.copy"), None);
}

View File

@ -0,0 +1,28 @@
use soundcraft_ui_egui::{Services, SoundApp};
use std::time::Duration;
fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
app.logic(ui.ctx());
if shown {
app.ui(ui);
}
});
out.textures_delta.clear();
out.viewport_output[&egui::ViewportId::ROOT].repaint_delay
}
#[test]
fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
for _ in 0..3 {
pass(&ctx, &mut app, true);
}
assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO);
app.run("transport.play", serde_json::json!({})).unwrap();
// Playing repaints at the meter rate (30 Hz in the Edit window, #207).
assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34));
pass(&ctx, &mut app, false);
assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3);
}

View File

@ -0,0 +1,30 @@
use egui::{Event, Key, Modifiers, vec2};
use egui_kittest::Harness;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
#[test]
fn typing_in_the_rename_clip_dialog_replaces_the_old_name() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].clone();
app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap();
menus::invoke_menu(&mut app, "clip.rename", "Clip > Rename...");
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..3 {
h.step();
}
h.input_mut().events.push(Event::Text("Intro".into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
let session = h.state().engine.session();
let renamed = session.tracks.iter().flat_map(|t| t.clips()).find(|c| c.id == clip.id).map(|c| c.name.clone());
assert_eq!(renamed.as_deref(), Some("Intro"));
}

View File

@ -0,0 +1,97 @@
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp, menus};
fn harness() -> Harness<'static, SoundApp> {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
)
}
fn steps(h: &mut Harness<'_, SoundApp>) {
for _ in 0..4 {
h.step();
}
}
fn click(h: &mut Harness<'_, SoundApp>, label: &str) {
h.get_by_label(label).click();
steps(h);
}
fn close_session(h: &mut Harness<'_, SoundApp>) {
menus::invoke_menu(h.state_mut(), "session.close", "File > Close Session");
steps(h);
}
fn mute_kick(h: &mut Harness<'_, SoundApp>) {
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
}
#[test]
fn closing_a_modified_session_asks_first() {
let mut h = harness();
let tracks = h.state().engine.session().tracks.len();
h.state_mut().run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})).unwrap();
close_session(&mut h);
assert!(h.state().dialogs.open.is_none(), "a selection alone is not a change");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
mute_kick(&mut h);
close_session(&mut h);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
click(&mut h, "Cancel");
assert!(h.state().dialogs.open.is_none());
assert_eq!(h.state().engine.session().tracks.len(), tracks);
close_session(&mut h);
click(&mut h, "Don't Save");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
mute_kick(&mut h);
menus::invoke_menu(h.state_mut(), "app.quit", "");
steps(&mut h);
assert!(!h.state().quit_requested);
click(&mut h, "Don't Save");
assert!(h.state().quit_requested);
}
#[test]
fn save_in_the_question_saves_before_closing() {
let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut h = harness();
mute_kick(&mut h);
close_session(&mut h);
click(&mut h, "Save");
match &mut h.state_mut().dialogs.open {
Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path),
other => panic!("expected Save As, got {other:?}"),
}
steps(&mut h);
click(&mut h, "OK");
assert!(h.state().engine.session().tracks.is_empty());
h.state_mut().run("session.open", json!({"path": path})).unwrap();
assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
close_session(&mut h);
click(&mut h, "Save");
assert!(h.state().dialogs.open.is_none());
assert!(h.state().engine.session().tracks.is_empty());
h.state_mut().run("session.open", json!({"path": path})).unwrap();
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -0,0 +1,95 @@
use egui::{Pos2, RawInput, Rect, pos2, vec2};
use serde_json::{Value, json};
use soundcraft_time::to_samples;
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
use std::sync::mpsc::{Sender, channel};
struct Rig {
ctx: egui::Context,
app: SoundApp,
tx: Sender<ControlRequest>,
width: f32,
}
impl Rig {
fn new(width: f32) -> Rig {
let (tx, rx) = channel();
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx);
let mut r = Rig { ctx: egui::Context::default(), app, tx, width };
r.frames(6);
r
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() };
self.app.raw_input_hook(&mut raw);
self.ctx
.run_ui(raw, |ui| {
self.app.logic(ui.ctx());
self.app.ui(ui);
})
.textures_delta
.clear();
}
}
fn send(&mut self, method: &str, params: Value) {
let (req, reply) = ControlRequest::new(method, params);
self.tx.send(req).unwrap();
self.frames(40);
assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}");
}
fn view(&self) -> (f64, i64) {
let z = self.app.engine.session().edit.zoom;
(z.samples_per_px, z.scroll)
}
fn timeline(&self) -> Rect {
let [x0, y0, x1, y1] = self.app.edit_layout.timeline;
Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))
}
}
#[test]
fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() {
let mut r = Rig::new(1400.0);
let (tl, (spp, scroll)) = (r.timeline(), r.view());
let (x, y) = (tl.center().x, tl.center().y);
assert!(r.app.edit_layout.content_h - tl.height() > 60.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0}));
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, 0.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0}));
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, 60.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1}));
assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y);
r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0}));
assert_eq!(r.view(), (spp, 0));
r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap();
let scroll_y = r.app.edit_layout.scroll_y;
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24}));
assert_eq!(r.view().1, 3);
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
}
#[test]
fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() {
let mut r = Rig::new(1400.0);
let tl = r.timeline();
let (x, y) = (tl.min.x + 300.0, tl.center().y);
let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0;
let (spp, at) = (r.view().0, under(&r));
r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0}));
assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0);
assert!((under(&r) - at).abs() < 8.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true}));
assert!(r.view().0 < spp / 4.0);
assert!((under(&r) - at).abs() < 8.0);
let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true}));
assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
}

View File

@ -0,0 +1,43 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
}
#[test]
fn window_shortcuts_open_and_close_windows() {
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (os, cmd) in [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(os);
for key in [Key::Num1, Key::Num3, Key::Num4, Key::Num5, Key::Num9] {
press(&ctx, &mut app, key, cmd);
}
press(&ctx, &mut app, Key::M, Modifiers { alt: true, ..cmd });
let ui = &app.ui;
assert!(
ui.show_transport && ui.show_big_counter && ui.show_automation && ui.show_memory_locations && ui.show_video && ui.narrow_mix,
"{os:?}"
);
press(&ctx, &mut app, Key::W, cmd);
assert!(!app.ui.show_transport && !app.ui.show_big_counter && !app.ui.show_memory_locations, "{os:?}");
}
}
#[test]
fn cmd_ctrl_equals_opens_the_score_editor_on_a_mac() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(OperatingSystem::Mac);
press(&ctx, &mut app, Key::Equals, Modifiers { ctrl: true, ..MAC_CMD });
assert!(app.ui.show_score);
assert_eq!(app.ui.window, MainWindow::Edit);
press(&ctx, &mut app, Key::Equals, MAC_CMD);
assert_eq!(app.ui.window, MainWindow::Mix);
}

View File

@ -42,13 +42,15 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr
| `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. |
| `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). |
| `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. |
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. |
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. |
| `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). |
| `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |
| `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). |
| `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). |
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |
| `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. |
| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. |
| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). |
| `ui.move` | `{x, y}` | Move the pointer. |
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). |
| `ui.text` | `{text}` | Type text into the focused field. |