From 1f5bc965fe192be47ef30a384115b446a4b85073 Mon Sep 17 00:00:00 2001 From: coygeek <65363919+coygeek@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:28:07 -0700 Subject: [PATCH 01/46] fix(ui): follow live system appearance Resolve System appearance from egui on every frame and fall back to Dark when the integration cannot report a theme. Keep receiving OS changes during manual modes and apply the selected palette to both widget style branches and the current root UI. Remove the cached detector and its unused dependency. Cover startup, live transitions, manual overrides, saved System preferences, and user colors with an application-frame regression. Add reproducible offscreen system-theme inputs and document the fallback. --- Cargo.lock | 37 -------- Cargo.toml | 1 - crates/ui-egui/Cargo.toml | 1 - crates/ui-egui/examples/ui_shot.rs | 33 +++++++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/menus.rs | 3 - crates/ui-egui/src/theme.rs | 130 +++++++++++++++++++---------- docs/control-protocol.md | 9 +- 8 files changed, 125 insertions(+), 91 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 34de49a..8b26758 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -837,23 +837,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "dark-light" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f66b71ac6b73812b0bf51cda8dd646a268fbb38623f45b2b7907593c323f745" -dependencies = [ - "futures-channel", - "futures-core", - "objc2 0.6.5", - "objc2-foundation 0.3.2", - "wasm-bindgen", - "web-sys", - "windows-sys 0.61.2", - "winreg", - "zbus", -] - [[package]] name = "dasp_sample" version = "0.11.0" @@ -1319,15 +1302,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -3596,7 +3570,6 @@ dependencies = [ name = "soundcraft-ui-egui" version = "0.4.0" dependencies = [ - "dark-light", "egui", "egui_kittest", "image", @@ -5106,16 +5079,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" -dependencies = [ - "cfg-if", - "windows-sys 0.61.2", -] - [[package]] name = "winresource" version = "0.1.31" diff --git a/Cargo.toml b/Cargo.toml index fe55a4f..95b9a8e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,6 @@ cpal = "0.16" egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" -dark-light = "3" rfd = "0.17" image = { version = "0.25", default-features = false, features = ["png"] } diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 0439b8b..c91b324 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,7 +21,6 @@ soundcraft-engine.workspace = true soundcraft-playback.workspace = true soundcraft-video.workspace = true egui.workspace = true -dark-light.workspace = true serde.workspace = true serde_json.workspace = true log.workspace = true diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index acb1d5a..073b823 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -1,9 +1,10 @@ //! Render the real UI offscreen (no window, no focus stealing) and save PNGs. //! -//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH]` +//! `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl] [--blank] [--size WxH] [--system-theme light|dark|none]` //! //! Script lines are control requests (`{"method": "...", "params": {...}}`), `{"shot": "path.png"}` -//! or `{"steps": n}`. The demo session is loaded unless `--blank`. +//! or `{"steps": n}`. `{"system_theme": "light"}` (also `"dark"` or `"none"`) changes the +//! simulated OS appearance for subsequent frames. The demo session is loaded unless `--blank`. use soundcraft_ui_egui::{ControlRequest, Services, SoundApp}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -11,11 +12,29 @@ use std::sync::mpsc; static READY: AtomicBool = AtomicBool::new(false); +fn system_theme(value: &str) -> Option> { + match value { + "light" => Some(Some(egui::Theme::Light)), + "dark" => Some(Some(egui::Theme::Dark)), + "none" => Some(None), + _ => None, + } +} + fn main() { let args: Vec = std::env::args().skip(1).collect(); let out = args.first().cloned().unwrap_or_else(|| "soundcraft.png".into()); let script = args.get(1).filter(|a| !a.starts_with("--")).cloned(); let blank = args.iter().any(|a| a == "--blank"); + let appearance = if let Some(i) = args.iter().position(|a| a == "--system-theme") { + let Some(appearance) = args.get(i + 1).and_then(|value| system_theme(value)) else { + eprintln!("--system-theme requires light, dark or none"); + return; + }; + appearance + } else { + None + }; let (w, h) = args .iter() .position(|a| a == "--size") @@ -39,6 +58,7 @@ fn main() { app, ); harness.input_mut().max_texture_side = Some(8192); + harness.input_mut().system_theme = appearance; READY.store(true, Ordering::Relaxed); let step = |h: &mut egui_kittest::Harness| { let mut raw = std::mem::take(h.input_mut()); @@ -63,6 +83,15 @@ fn main() { eprintln!("bad line: {line}"); continue; }; + if let Some(value) = v.get("system_theme") { + let Some(appearance) = value.as_str().and_then(system_theme) else { + eprintln!("system_theme requires light, dark or none: {line}"); + continue; + }; + harness.input_mut().system_theme = appearance; + step(&mut harness); + continue; + } if let Some(p) = v.get("shot").and_then(|x| x.as_str()) { for _ in 0..3 { step(&mut harness); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..1a28d99 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -818,6 +818,8 @@ impl SoundApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + // The integration creates this Ui before logic resolves the current frame's palette. + ui.set_style(ctx.global_style()); if !self.fonts_ready { ctx.request_repaint(); return; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8c40e7..3fb49ce 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -538,9 +538,6 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option bool { LIGHT_THEME.load(Ordering::Relaxed) } -/// Whether `mode` resolves to the Light palette right now. Never blocks. +/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark. pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { match mode { ThemeMode::Light => true, ThemeMode::Dark => false, - ThemeMode::System => system_is_light(ctx), + ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light), } } -/// Forget the cached system appearance so the next System lookup asks again. -pub fn refresh_system() { - let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); - let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); -} - -/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it -/// answers this reports dark, and `ctx` is repainted when the answer arrives. -#[cfg(not(target_arch = "wasm32"))] -fn system_is_light(ctx: &egui::Context) -> bool { - if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - let ctx = ctx.clone(); - let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || { - let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); - SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); - ctx.request_repaint(); - }); - if spawned.is_err() { - SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed); - } - } - SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT -} - -/// On the web the query is a synchronous `matchMedia` lookup (no threads there). -#[cfg(target_arch = "wasm32")] -fn system_is_light(_ctx: &egui::Context) -> bool { - if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { - let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); - SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); - } - SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT -} - /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; @@ -430,8 +389,13 @@ pub fn apply(ctx: &egui::Context, light: bool) { v.widgets.inactive.fg_stroke.color = t.text; v.window_corner_radius = egui::CornerRadius::same(6); v.menu_corner_radius = egui::CornerRadius::same(4); - ctx.set_visuals(v); - ctx.global_style_mut(|s| { + // Keep receiving native OS appearance changes even when our palette is manual. On macOS, + // pinning egui's theme also pins the window appearance and hides later OS changes. + ctx.set_theme(egui::ThemePreference::System); + // Both egui style branches must use our resolved palette: egui can select either branch + // as the OS changes, including while SoundCraft's manual Light/Dark choice stays fixed. + ctx.all_styles_mut(|s| { + s.visuals = v.clone(); s.spacing.item_spacing = egui::vec2(6.0, 4.0); s.spacing.button_padding = egui::vec2(6.0, 2.0); s.interaction.tooltip_delay = 0.4; @@ -476,4 +440,78 @@ mod tests { assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"}))); assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme")); } + + #[test] + fn application_frames_follow_system_appearance_and_preserve_manual_palettes() { + // Keep all frame assertions in one test: custom-painted tokens share LIGHT_THEME. + fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option, light: bool) { + let mode = app.ui.theme; + let colors = |app: &SoundApp| { + app.engine + .session() + .tracks + .iter() + .map(|track| { + (track.color, track.playlists.iter().flat_map(|playlist| &playlist.clips).map(|clip| clip.color).collect::>()) + }) + .collect::>() + }; + let before = colors(app); + let mut output = ctx.run_ui(egui::RawInput { system_theme, ..Default::default() }, |ui| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + let tokens = if light { Tokens::LIGHT } else { Tokens::DARK }; + assert_eq!(is_light(), light, "custom palette for {mode:?} with {system_theme:?}"); + assert_eq!(Tokens::current().panel_bg, tokens.panel_bg); + assert_eq!(ctx.options(|options| options.theme_preference), egui::ThemePreference::System); + for theme in [egui::Theme::Light, egui::Theme::Dark] { + let style = ctx.style_of(theme); + assert_eq!(style.visuals.dark_mode, !light); + assert_eq!(style.visuals.panel_fill, tokens.panel_bg); + assert_eq!(style.visuals.widgets.inactive.bg_fill, tokens.button); + assert_eq!(style.visuals.widgets.inactive.fg_stroke.color, tokens.text); + assert_eq!(style.spacing.button_padding, egui::vec2(6.0, 2.0)); + } + app.ui(ui); + assert_eq!(ui.visuals().panel_fill, tokens.panel_bg); + assert_eq!(ui.visuals().widgets.inactive.bg_fill, tokens.button); + }); + output.textures_delta.clear(); + assert_eq!(app.ui.theme, mode); + assert_eq!(serde_json::to_value(&app.ui).unwrap()["theme"], mode.id()); + assert_eq!(colors(app), before, "appearance must preserve track and clip colors"); + } + + let appearances = [Some(egui::Theme::Light), Some(egui::Theme::Dark), Some(egui::Theme::Light), None]; + for initial in [Some(egui::Theme::Light), Some(egui::Theme::Dark), None] { + let ctx = egui::Context::default(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.ui = serde_json::from_value(json!({"theme": "system"})).unwrap(); + frame(&mut app, &ctx, initial, initial == Some(egui::Theme::Light)); + } + + let ctx = egui::Context::default(); + let mut engine = soundcraft_engine::demo::demo_engine(); + let track = engine.session_mut().tracks.first_mut().unwrap(); + track.color = [19, 83, 147]; + track.playlists.iter_mut().flat_map(|playlist| &mut playlist.clips).next().unwrap().color = Some([211, 57, 99]); + let mut app = SoundApp::new(engine, None, Services::default()); + for mode in [ThemeMode::System, ThemeMode::Light, ThemeMode::Dark] { + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + for appearance in appearances { + let light = mode == ThemeMode::Light || (mode == ThemeMode::System && appearance == Some(egui::Theme::Light)); + frame(&mut app, &ctx, appearance, light); + } + if mode != ThemeMode::System { + let latest = if mode == ThemeMode::Light { egui::Theme::Dark } else { egui::Theme::Light }; + frame(&mut app, &ctx, Some(latest), mode == ThemeMode::Light); + app.run("ui.theme", json!({"mode": "system"})).unwrap(); + // Resolve the current context immediately, without another OS appearance event. + app.logic(&ctx); + assert_eq!(is_light(), latest == egui::Theme::Light); + assert_eq!(ctx.global_style().visuals.dark_mode, latest != egui::Theme::Light); + frame(&mut app, &ctx, Some(latest), latest == egui::Theme::Light); + } + } + } } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index ba7b079..a64c4c8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -64,4 +64,11 @@ soundcraft-cli app --port $P ui.menu.invoke '{"path":"Track > New..."}' Offscreen (no window): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png script.jsonl`, where each script line is a request (`{"method": …, "params": …}`), `{"shot": "path.png"}` or -`{"steps": n}`. +`{"steps": n}`. Use `--system-theme light|dark|none` to supply the initial OS appearance, and +script events such as `{"system_theme": "dark"}` to change it on subsequent frames. + +Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by +the native or web integration at launch and on later frames, while the saved choice stays System. +When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay +fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no +OS appearance and therefore resolves System to Dark. From d3348bcbbcf90b78500523ea42625e60bd0c02aa Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:34:26 +0100 Subject: [PATCH 02/46] Nudge clip gain with Ctrl+Shift+Up/Down on Windows and Linux too Ctrl+Shift+Up/Down (#76) only worked on macOS. The registry's Ctrl there is the Mac Control key, which has no key of its own on Windows and Linux (#20), so the keys did nothing there, while the Shortcuts window and the command palette still showed Ctrl+Shift+Up. Pro Tools for Windows puts these on the Start key, but egui does not report that key off macOS, and Windows and GNOME use Win/Super+Shift+ Up/Down to resize and move windows. So off macOS the nudge is Ctrl+Shift+Up/Down: the same keys as on the Mac and what the labels already say. No SoundCraft shortcut uses them there; Ctrl+Up/Down without Shift still extends the selection. --- crates/ui-egui/src/shortcuts.rs | 6 ++++++ crates/ui-egui/tests/shortcuts.rs | 24 ++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index bffa713..90a4e08 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -170,6 +170,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run(id, json!({})); true } + // Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key). + Key::ArrowUp | Key::ArrowDown if !mac && m.ctrl && m.shift && !m.alt => { + let id = if key == Key::ArrowUp { "clip.gain_nudge_up" } else { "clip.gain_nudge_down" }; + let _ = app.run(id, json!({})); + true + } // Commands Keyboard Focus: single-key editing (Edit window only). k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true, Key::R | Key::T if plain => { diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 0844e66..fc7fdc4 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -1,3 +1,4 @@ +use egui::os::OperatingSystem; use egui::{Key, Modifiers}; use serde_json::json; use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; @@ -9,14 +10,17 @@ fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers #[test] fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { - let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); - let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; - app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); - let ctx = egui::Context::default(); - ctx.set_os(egui::os::OperatingSystem::Mac); - let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; - press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); - press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); - press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); - assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0); + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_os(os); + let ctrl_shift = Modifiers { shift: true, ..ctrl }; + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}"); + } } From 0734281104f53df2d4cd5f2c094674cd01b27739 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:15:28 +0800 Subject: [PATCH 03/46] Stop playback when replacing the session --- crates/engine/src/lib.rs | 5 ++ crates/engine/tests/session_transport.rs | 30 +++++++++ crates/ui-egui/src/lib.rs | 81 +++++++++++++++++++++++- 3 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/session_transport.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 88584e0..f12901d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -53,6 +53,8 @@ pub type Result = std::result::Result; /// Requests for the transport/audio engine, drained by the host app each frame. #[derive(Debug, Clone, PartialEq, serde::Serialize)] pub enum TransportRequest { + /// Stop the old session's transport without applying stop-time edits to the new document. + ResetSession, Play, Stop, TogglePlay, @@ -158,6 +160,9 @@ impl Engine { /// 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.undo.clear(); self.redo.clear(); diff --git a/crates/engine/tests/session_transport.rs b/crates/engine/tests/session_transport.rs new file mode 100644 index 0000000..73048e1 --- /dev/null +++ b/crates/engine/tests/session_transport.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::{Engine, TransportRequest, TransportStatus}; +use soundcraft_model::Session; +use soundcraft_time::Range; + +#[test] +fn replacing_session_resets_status_and_discards_old_transport_requests() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, recording: true, position: 48_000 }; + engine.execute("transport.play", &json!({"from": 96_000})).unwrap(); + engine.execute("transport.record", &json!({})).unwrap(); + let mut session = Session::default(); + session.edit.selection = Range::point(12_000); + engine.replace_session(session); + assert_eq!(engine.transport, TransportStatus { position: 12_000, ..TransportStatus::default() }); + assert_eq!(engine.transport_requests, vec![TransportRequest::ResetSession]); + assert!(!engine.is_dirty()); +} + +#[test] +fn failed_new_session_keeps_the_current_transport() { + let mut engine = Engine::default(); + engine.transport = TransportStatus { playing: true, position: 48_000, ..TransportStatus::default() }; + engine.execute("transport.play", &json!({})).unwrap(); + let status = engine.transport; + let requests = engine.transport_requests.clone(); + assert!(engine.execute("session.new", &json!({"sample_rate": 0})).is_err()); + assert_eq!(engine.transport, status); + assert_eq!(engine.transport_requests, requests); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..c4345d3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -642,6 +642,25 @@ impl SoundApp { let reqs: Vec = std::mem::take(&mut self.engine.transport_requests); for r in reqs { match r { + TransportRequest::ResetSession => { + if let Some(p) = &self.player { + p.stop(); + p.set_recording(false); + p.set_speed(1.0); + p.locate(self.engine.transport.position); + } + self.sim = None; + // The old capture belongs to the replaced document, never to the new one. + self.recorder = None; + self.recorder_failed = false; + self.punch = None; + self.gesture = None; + self.touched.clear(); + self.last_write_at = i64::MIN; + self.edit_layout.follow_hold = false; + self.meters.clear(); + self.main_meter = MeterDisplay::default(); + } TransportRequest::Play => { if !self.is_playing() { self.play_from_selection(); @@ -761,13 +780,14 @@ impl SoundApp { self.engine.end_merge(); } } + // Stop the old playback before sending a replacement session to the audio thread. + self.handle_transport(dt); if self.engine.revision != self.last_rev { self.last_rev = self.engine.revision; if let Some(p) = &self.player { p.update_session(self.engine.session_arc()); } } - self.handle_transport(dt); self.write_automation(ctx); self.autosave(now); if self.recorder.is_none() @@ -889,7 +909,64 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { #[cfg(test)] mod tests { - use super::automation_step_due; + use super::{Services, SoundApp, automation_step_due}; + use serde_json::json; + + #[test] + fn new_session_stops_playback_and_the_old_clock() { + for (command, params) in [ + ("session.new", json!({"name": "New project", "sample_rate": 96_000})), + ("session.new", json!({"template": "demo"})), + ("session.close", json!({})), + ] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert!(app.position() > 48_000); + app.run(command, params).unwrap(); + let insertion = app.engine.session().edit.selection.start; + app.handle_transport(0.25); + assert!(!app.is_playing(), "{command}"); + assert_eq!(app.position(), insertion, "{command}"); + assert_eq!(app.engine.transport.position, insertion, "{command}"); + app.handle_transport(0.25); + assert_eq!(app.engine.transport.position, insertion, "the old clock must stay stopped"); + app.run("transport.play", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.is_playing()); + assert_eq!(app.position(), insertion + i64::from(app.engine.session().sample_rate.hz()) / 4); + } + } + + #[test] + fn new_session_cancels_play_queued_before_the_next_frame() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("transport.play", json!({})).unwrap(); + app.run("session.new", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.transport.position, 0); + } + + #[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()); + app.run("transport.play", json!({"from": 48_000})).unwrap(); + app.handle_transport(0.25); + let mut session = soundcraft_model::Session::default(); + session.edit.selection = soundcraft_time::Range::new(12_000, 24_000); + session.edit.insertion_follows_playback = true; + session.edit.scrolling = "after_playback".into(); + let selection = session.edit.selection; + app.engine.replace_session(session); + app.handle_transport(0.25); + assert!(!app.is_playing()); + assert_eq!(app.engine.session().edit.selection, selection); + assert_eq!(app.position(), selection.start); + assert_eq!(app.engine.session().edit.zoom.scroll, 0); + assert!(!app.engine.is_dirty()); + } #[test] fn automation_first_pass_after_start_is_due_without_overflow() { From 94e13543ec6421c6015ca772e390ab4be9ce38a1 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:15:18 +0800 Subject: [PATCH 04/46] Keep the MIDI editor dock at its resized height --- crates/ui-egui/src/midi_editor.rs | 3 ++ crates/ui-egui/tests/midi_panel.rs | 79 ++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 crates/ui-egui/tests/midi_panel.rs diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 3716c98..a4882d4 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -56,6 +56,9 @@ fn is_black(p: i32) -> bool { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { + // Painter-only contents do not advance the layout. Claim the resized + // space, including the empty state, so the dock keeps its chosen height. + ui.take_available_space(); let t = Tokens::current(); let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.panel_bg); diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs new file mode 100644 index 0000000..45ec0ec --- /dev/null +++ b/crates/ui-egui/tests/midi_panel.rs @@ -0,0 +1,79 @@ +use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, midi_editor}; + +fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +fn panel(ctx: &egui::Context) -> Rect { + egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn check_resize(with_clip: bool) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + if with_clip { + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + } + assert_eq!(midi_editor::target_clip(&app).is_some(), with_clip); + let ctx = egui::Context::default(); + for _ in 0..6 { + frame(&ctx, &mut app, vec![]); + } + let initial = panel(&ctx); + assert!((initial.height() - 320.0).abs() < 1.0, "initial panel: {initial:?}"); + let notes = app.engine.session().clone(); + + // Drag the shared boundary up and down, then allow several idle frames + // to catch a panel that looks resized but snaps back after release. + for height in [440.0, 280.0, 200.0] { + let before = panel(&ctx); + let start = pos2(before.center().x, before.top() + 1.0); + let end = pos2(start.x, before.bottom() - height); + frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); + frame(&ctx, &mut app, vec![button(start, true)]); + for step in 1..=6 { + frame(&ctx, &mut app, vec![Event::PointerMoved(start.lerp(end, step as f32 / 6.0))]); + } + frame(&ctx, &mut app, vec![button(end, false)]); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + let after = panel(&ctx); + assert!((after.height() - height.max(260.0)).abs() < 1.0, "requested {height}, panel: {after:?}"); + assert!((after.bottom() - initial.bottom()).abs() < 1.0); + assert!(app.edit_layout.timeline[3] <= after.top()); + } + + // Closing and reopening the dock keeps the chosen height. + let resized = panel(&ctx); + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + app.run("window.midi_editor", json!({})).unwrap(); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(panel(&ctx), resized); + assert_eq!(*app.engine.session(), notes); +} + +#[test] +fn midi_panel_resizes_with_a_selected_clip() { + check_resize(true); +} + +#[test] +fn midi_panel_resizes_without_a_selected_clip() { + check_resize(false); +} From b92370d32fbe4d15c5911630e021431db0bb0919 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:20:57 +1100 Subject: [PATCH 05/46] Point I/O Setup rename and delete at setup.io. The bus row called mix.bus_rename and mix.bus_delete, which are not commands, so both buttons failed. setup.io already renames and deletes a bus by name. --- crates/ui-egui/src/windows.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 96677a6..b7b34e5 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -446,11 +446,11 @@ pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) { let r = ui.text_edit_singleline(&mut edit); ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); if r.lost_focus() && edit != name { - let _ = app.run("mix.bus_rename", json!({"bus": name, "name": edit})); + let _ = app.run("setup.io", json!({"action": "rename_bus", "name": name, "new_name": edit})); } ui.label(fmt); if ui.small_button("Delete").clicked() { - let _ = app.run("mix.bus_delete", json!({"bus": name})); + let _ = app.run("setup.io", json!({"action": "delete_bus", "name": name})); } ui.end_row(); } From be859f6cc5cccab2ffb32d81bd138ac72237cb18 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:20:57 +1100 Subject: [PATCH 06/46] Send Tempo Operations stretch as a factor. event.tempo_stretch accepts factor or length, and the Apply button was sending end_bpm, which the command ignored. A lower end tempo lengthens the selection, so the factor is the start BPM divided by the end BPM. --- crates/ui-egui/src/ops_windows.rs | 53 +++++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index d298ab3..87c7dde 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -123,6 +123,21 @@ fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_beat_detective = open; } +/// Command the Tempo Operations Apply button sends. Stretch uses `factor = start / end` +/// so a lower end BPM lengthens the selection (`event.tempo_stretch` has no `end_bpm`). +pub(crate) fn tempo_ops_apply(curve: &str, start_bpm: f64, end_bpm: f64) -> (&'static str, Value) { + let start = if start_bpm.is_finite() { start_bpm.max(1.0) } else { 1.0 }; + let end = if end_bpm.is_finite() { end_bpm.max(1.0) } else { 1.0 }; + match curve { + "constant" => ("event.tempo_constant", json!({"bpm": start_bpm})), + "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + "scale" => ("event.tempo_scale", json!({"factor": end / start})), + "stretch" => ("event.tempo_stretch", json!({"factor": start / end})), + _ => ("event.tempo_linear", json!({"start_bpm": start_bpm, "end_bpm": end_bpm})), + } +} + fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_tempo_ops; if !open { @@ -136,16 +151,14 @@ fn tempo_ops(app: &mut SoundApp, ctx: &egui::Context) { }); ui.add(egui::DragValue::new(&mut app.ops.tempo_start).range(5.0..=999.0).prefix("start bpm ")); ui.add(egui::DragValue::new(&mut app.ops.tempo_end).range(5.0..=999.0).prefix("end bpm ")); - ui.label(egui::RichText::new("Applies across the edit selection (constant: at the insertion point; scale: factor = end / start).").small()); + ui.label( + egui::RichText::new( + "Applies across the edit selection (constant: at the insertion point; scale: factor = end / start; stretch: factor = start / end).", + ) + .small(), + ); if ui.button(egui::RichText::new("Apply").strong()).clicked() { - let (id, p) = match app.ops.tempo_curve.as_str() { - "constant" => ("event.tempo_constant", json!({"bpm": app.ops.tempo_start})), - "parabolic" => ("event.tempo_parabolic", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - "s-curve" => ("event.tempo_s_curve", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - "scale" => ("event.tempo_scale", json!({"factor": app.ops.tempo_end / app.ops.tempo_start.max(1.0)})), - "stretch" => ("event.tempo_stretch", json!({"end_bpm": app.ops.tempo_end})), - _ => ("event.tempo_linear", json!({"start_bpm": app.ops.tempo_start, "end_bpm": app.ops.tempo_end})), - }; + let (id, p) = tempo_ops_apply(&app.ops.tempo_curve, app.ops.tempo_start, app.ops.tempo_end); let r = app.run(id, p); result_line(ui, &r); } @@ -274,3 +287,25 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) { }); app.ui.show_rtp = open; } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stretch_uses_start_over_end_and_scale_uses_end_over_start() { + let (id, p) = tempo_ops_apply("stretch", 120.0, 60.0); + assert_eq!(id, "event.tempo_stretch"); + assert!((p["factor"].as_f64().unwrap() - 2.0).abs() < 1e-9, "{p}"); + let (_, p) = tempo_ops_apply("stretch", 120.0, 120.0); + assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}"); + let (id, p) = tempo_ops_apply("scale", 120.0, 60.0); + assert_eq!(id, "event.tempo_scale"); + assert!((p["factor"].as_f64().unwrap() - 0.5).abs() < 1e-9, "{p}"); + let (_, p) = tempo_ops_apply("stretch", f64::NAN, f64::INFINITY); + assert!((p["factor"].as_f64().unwrap() - 1.0).abs() < 1e-9, "{p}"); + let (id, p) = tempo_ops_apply("linear", 100.0, 140.0); + assert_eq!(id, "event.tempo_linear"); + assert_eq!(p["end_bpm"], 140.0); + } +} From 3b9d5b94236a3417d98814c7415a3ea94b69caf4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:21:16 +1100 Subject: [PATCH 07/46] Allocate Mix Window View menu paths once. The menu bar rebuilt and leaked a fresh alias string for every mix-view section on each redraw. Those fifteen paths are now created the first time the menu catalog is asked for them. --- crates/ui-egui/src/menus.rs | 63 ++++++++++++++++++++++++++----------- 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8c40e7..a5a8c87 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -74,29 +74,41 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ /// Extra catalog mappings handled by the UI layer. pub fn ui_aliases() -> Vec<(&'static str, &'static str)> { let mut v: Vec<(&str, &str)> = UI_COMMANDS.iter().filter(|c| !c.2.is_empty()).map(|c| (c.2, c.0)).collect(); - for sec in [ - "Inserts A-E", - "Inserts F-J", - "Sends A-E", - "Sends F-J", - "I/O", - "Track Color", - "Comments", - "EQ Curve", - "Meters and Faders", - "All", - "Minimal", - "Mic Preamps", - "Instruments", - "Object", - ] { - v.push((Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()), "view.mix_section")); - } + v.extend(mix_view_aliases().iter().copied()); v.extend(AUDIOSUITE.iter().map(|(p, _)| (*p, "audiosuite.process"))); v.extend(MENU_WINDOWS.iter().copied()); v } +/// Mix-view menu paths, allocated once. Rebuilding them on every menu draw leaked a copy per frame. +fn mix_view_aliases() -> &'static [(&'static str, &'static str)] { + static ALIASES: std::sync::OnceLock> = std::sync::OnceLock::new(); + ALIASES.get_or_init(|| { + [ + "Inserts A-E", + "Inserts F-J", + "Sends A-E", + "Sends F-J", + "I/O", + "Track Color", + "Comments", + "EQ Curve", + "Meters and Faders", + "All", + "Minimal", + "Mic Preamps", + "Instruments", + "Object", + ] + .into_iter() + .map(|sec| { + let path: &'static str = Box::leak(format!("View > Mix Window Views > {sec}").into_boxed_str()); + (path, "view.mix_section") + }) + .collect() + }) +} + /// AudioSuite catalog entries → our processes (functional equivalents, our own plugins). pub const AUDIOSUITE: &[(&str, &str)] = &[ ("AudioSuite > EQ > Channel Strip", "channel_strip"), @@ -571,3 +583,18 @@ pub fn parity() -> Value { let extra = ui_aliases(); catalog::parity_with(&extra) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mix_view_aliases_are_allocated_once() { + let first = ui_aliases(); + let second = ui_aliases(); + let a = first.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap(); + let b = second.iter().find(|(path, _)| path.contains("Mix Window Views > All")).map(|(path, _)| *path).unwrap(); + assert!(std::ptr::eq(a, b)); + assert_eq!(first.iter().filter(|(path, _)| path.contains("Mix Window Views")).count(), 14); + } +} From 82340cee5c5e7bf26c5c0b3fde55c13973821844 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:21:47 +1100 Subject: [PATCH 08/46] Snap selected clips to their neighbours instead of past them. Snap to Next and Snap to Previous ignored other selected clips when looking for a destination, so a multi-clip selection piled onto the same later clip or left the gap between the selected clips in place. --- crates/engine/src/cmd/edit_more.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index d27bb97..ae16f88 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -663,8 +663,10 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result { let mut moved = 0; for (id, _) in order { let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; - let others = - s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id && !ids.contains(&x.id)).cloned().collect::>()).unwrap_or_default(); + // Selected neighbours are real destinations. Ignoring them made Snap to Next + // pile every selected clip onto the same later clip, and Snap to Previous + // leave the gap between the selected clips in place. + let others = s.track(t).map(|tr| tr.clips().iter().filter(|x| x.id != id).cloned().collect::>()).unwrap_or_default(); let delta = if next { others.iter().filter(|x| x.start >= c.end()).map(|x| x.start).min().map(|st| st - c.end()) } else { @@ -982,6 +984,19 @@ mod tests { assert_eq!(starts(&e, t), vec![0, 900, 1000]); } + #[test] + fn snap_selected_neighbours_keeps_both_clips() { + let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]); + e.execute("edit.snap_next", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips().len(), 3); + assert_eq!(starts(&e, t), vec![800, 900, 1000]); + + let (mut e, t, ids) = session_with_clips(&[(0, 100), (300, 100), (1000, 100)]); + e.execute("edit.snap_previous", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips().len(), 3); + assert_eq!(starts(&e, t), vec![0, 100, 1000]); + } + #[test] fn copy_and_paste_automation_only() { let (mut e, t, _) = session_with_clips(&[(0, 1000)]); From d07624da1fe877c058a711c0f1fc63ce7312a021 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:22:24 +1100 Subject: [PATCH 09/46] Give same-named clip exports their own files. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export Clips as Files wrote every clip to {name}.wav, so the second clip replaced the first. A name that is already in the destination now gets a -2, -3, … suffix. --- crates/engine/src/cmd/file.rs | 38 +++++++++++++++++++++++++++++++++++ crates/engine/src/io.rs | 11 +++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 796ad8f..d58af41 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -390,4 +390,42 @@ mod tests { assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); let _ = std::fs::remove_dir_all(&dir); } + + fn tone_engine() -> (Engine, u64) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); + let track = e.session().tracks[0].id; + let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800)); + e.session_mut().edit.selected_tracks = vec![track]; + (e, track.0) + } + + #[test] + fn same_named_clips_export_as_separate_files() { + let (mut e, track) = tone_engine(); + let first = e.session().tracks[0].clips()[0].id; + let src = e.session().tracks[0].clips()[0].source().unwrap(); + let second = e.session_mut().new_clip_id(); + crate::edit::place_clip( + e.session_mut(), + soundcraft_model::TrackId(track), + soundcraft_model::Clip::audio(second, "tone", src, 0, 10_000, 4_800), + ); + for id in [first, second] { + e.session_mut().find_clip_mut(id).unwrap().name = "Take".into(); + } + let dir = std::env::temp_dir().join(format!("sc-export-clips-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let n = e.execute("file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [first.0, second.0]})).unwrap(); + assert_eq!(n["written"], 2); + let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + wavs.sort(); + assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..cb3fc18 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -432,7 +432,16 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> let audio = soundcraft_mix::render_clips(s, t, c.range()); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?; - let path = Path::new(dir).join(format!("{}.wav", sanitize_name(&c.name))); + let stem = sanitize_name(&c.name); + let mut path = Path::new(dir).join(format!("{stem}.wav")); + let mut k = 2u32; + while path.exists() { + path = Path::new(dir).join(format!("{stem}-{k}.wav")); + k = k.saturating_add(1); + if k > 10_000 { + return Err(EngineError::Io(format!("{dir}: too many files named {stem}"))); + } + } std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; n += 1; } From c4a88ee64a1f459db35181e082273eb6ed310c38 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:43 +1100 Subject: [PATCH 10/46] Keep going when the CLI's stdout is closed. outln! treated a failed write as success and returned immediately, so run and script stopped before --bounce or --save whenever a pipe reader had gone away. A write error is now ignored and the exit code comes from the work itself. --- apps/soundcraft-cli/src/main.rs | 5 +- apps/soundcraft-cli/tests/closed_stdout.rs | 124 +++++++++++++++++++++ 2 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 apps/soundcraft-cli/tests/closed_stdout.rs diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..9c69978 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -20,11 +20,12 @@ use soundcraft_engine::Engine; use std::io::Write; use std::process::ExitCode; -/// Print without panicking on a closed pipe. +/// Print without panicking on a closed pipe. A write error must not skip later work +/// such as `--bounce` or `--save`, and must not by itself report success. macro_rules! outln { ($($t:tt)*) => {{ let mut o = std::io::stdout().lock(); - if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; } + let _ = writeln!(o, $($t)*); }}; } diff --git a/apps/soundcraft-cli/tests/closed_stdout.rs b/apps/soundcraft-cli/tests/closed_stdout.rs new file mode 100644 index 0000000..9807423 --- /dev/null +++ b/apps/soundcraft-cli/tests/closed_stdout.rs @@ -0,0 +1,124 @@ +//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`. + +use std::process::{Command, Stdio}; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +/// Drop the read end so the child's stdout writes fail with `EPIPE`. +fn closed_stdout() -> std::process::Stdio { + let (reader, writer) = std::io::pipe().expect("pipe"); + drop(reader); + Stdio::from(writer) +} + +fn temp_dir(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn run_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("run"); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("run") + .arg("--cmd") + .arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file(), "bounce was skipped"); + assert!(session.is_file(), "save was skipped"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn script_with_closed_stdout_still_bounces_and_saves() { + let dir = temp_dir("script"); + let script = dir.join("edit.scraft.txt"); + std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap(); + let wav = dir.join("mix.wav"); + let session = dir.join("Synthetic.scraft"); + let output = bin() + .arg("script") + .arg(&script) + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn bounce_then_save_with_closed_stdout_writes_the_wav() { + let dir = temp_dir("bounce"); + let wav = dir.join("mix.wav"); + let session = dir.join("Empty.scraft"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&wav) + .arg("--save") + .arg(&session) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(closed_stdout()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + assert!(wav.is_file()); + assert!(session.is_file()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn invalid_bounce_destination_still_fails() { + let dir = temp_dir("bad-bounce"); + let missing = dir.join("no-such-dir").join("mix.wav"); + let output = bin() + .arg("run") + .arg("--bounce") + .arg(&missing) + .arg("--start") + .arg("0") + .arg("--end") + .arg("0.01") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .unwrap(); + assert!(!output.status.success(), "a bounce that cannot be written must fail"); + assert!(!missing.exists()); + let _ = std::fs::remove_dir_all(&dir); +} From 734c8e18bc73207e646b53dfe3576674fe8fdd22 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:44 +1100 Subject: [PATCH 11/46] Treat dotted command ids as engine.execute in the CLI app shorthand. app used to leave every ui., engine., session., and app. method untouched, so engine.history, ui.theme, and session.new were sent as unknown control methods. Only the real control-channel methods stay methods. Every other dotted id is engine.execute. --- apps/soundcraft-cli/src/main.rs | 63 +++++++++++++++++++++++++++------ 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..7e61868 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -294,6 +294,39 @@ fn describe(args: &[String]) -> ExitCode { } } +/// Control-channel methods. Every other dotted id is an engine command. +fn is_control_method(method: &str) -> bool { + matches!( + method, + "engine.execute" + | "command" + | "engine.commands" + | "engine.parity" + | "session.inspect" + | "document.inspect" + | "ui.inspect" + | "ui.menu.list" + | "ui.menu.invoke" + | "ui.set" + | "ui.move" + | "ui.click" + | "ui.drag" + | "ui.key" + | "ui.text" + | "ui.screenshot" + | "app.quit" + ) +} + +/// A command id is shorthand for `engine.execute`. Documented control methods stay methods. +fn app_request(method: &str, params: Value) -> (String, Value) { + if is_control_method(method) || !method.contains('.') { + (method.to_string(), params) + } else { + ("engine.execute".to_string(), json!({"command": method, "params": params})) + } +} + fn app(args: &[String]) -> ExitCode { let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into()); let positional: Vec<&String> = args @@ -311,17 +344,8 @@ fn app(args: &[String]) -> ExitCode { None => json!({}), }; let mut r = Remote::new(&port); - // A bare command id is shorthand for engine.execute. - let (m, p) = if method.contains('.') - && !method.starts_with("ui.") - && !method.starts_with("engine.") - && !method.starts_with("session.") - && !method.starts_with("app.") - { - ("engine.execute".to_string(), json!({"command": method, "params": params})) - } else { - (method.to_string(), params) - }; + // A bare command id is shorthand for engine.execute. Control-channel methods stay methods. + let (m, p) = app_request(method, params); match r.call(&m, p) { Ok(v) => { outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); @@ -380,6 +404,23 @@ fn parity(args: &[String]) -> ExitCode { mod tests { use super::*; + #[test] + fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() { + let (m, p) = app_request("engine.history", json!({})); + assert_eq!(m, "engine.execute"); + assert_eq!(p["command"], "engine.history"); + let (m, p) = app_request("ui.theme", json!({"mode": "light"})); + assert_eq!(m, "engine.execute"); + assert_eq!(p["params"]["mode"], "light"); + let (m, _) = app_request("session.new", json!({"name": "Session A"})); + assert_eq!(m, "engine.execute"); + let (m, _) = app_request("track.new", json!({})); + assert_eq!(m, "engine.execute"); + for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] { + assert_eq!(app_request(method, json!({})).0, method); + } + } + #[test] fn missing_media_gets_one_warning_each() { assert!(missing_warnings(&[]).is_empty()); From 1aad99ff4cf8cb42dc99c022b573f353cfb8ba1d Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:53:47 +1100 Subject: [PATCH 12/46] Convert only the selected volume automation into clip gain. The command wrote the lane onto the whole clip and cleared points only inside the selection, so the old curve still played and the clip got quieter on both sides. The selection now holds the fader, with anchors so the surrounding curve stays put. Volume and clip-gain windows take their end value from the last sample inside the window, so a breakpoint on the next sample does not ramp the audio before it. --- crates/engine/src/cmd/mix.rs | 167 +++++++++++++++++++++++++++++++++-- crates/mix/src/lib.rs | 13 ++- 2 files changed, 169 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 03574b2..37e1a0f 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -534,25 +534,88 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result { Ok(json!({})) } +/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds. +fn env_db(env: &[(i64, f32)], at: i64) -> f32 { + let mut prev: Option<(i64, f32)> = None; + for &(t, v) in env { + if t >= at { + return match prev { + Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + _ => v, + }; + } + prev = Some((t, v)); + } + prev.map_or(0.0, |(_, v)| v) +} + fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?; let r = range_param(e, "automation.volume_to_clip_gain", p)?; let s = e.session_mut(); for t in &tracks { let Some(tr) = s.track_mut(*t) else { continue }; - let lane = tr.lane(&AutoParam::Volume).cloned(); + let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue }; let base = tr.mixer.volume_db; - let Some(lane) = lane else { continue }; + let delta = |at: i64| lane.value_at(at, base) - base; if let Some(pl) = tr.playlist_mut() { for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { - let mut env: Vec<(i64, f32)> = - lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect(); - env.insert(0, (0, lane.value_at(c.start, base) - base)); - env.push((c.length, lane.value_at(c.end(), base) - base)); + let sel0 = r.start.max(c.start); + let sel1 = r.end.min(c.end()); + if sel1 <= sel0 { + continue; + } + let rel = |at: i64| at - c.start; + let rel0 = rel(sel0); + let rel1 = rel(sel1); + // Later writes win, so the selection anchors replace any point they land on. + let mut pts: Vec<(i64, f32)> = + c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect(); + if rel0 > 0 { + pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1))); + } + pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0))); + for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) { + let at = rel(pt.at); + pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); + } + if sel1 - 1 > sel0 { + let at = rel(sel1 - 1); + pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); + } + if sel1 < c.end() { + pts.push((rel1, env_db(&c.gain_env, rel1))); + } + pts.sort_by_key(|p| p.0); + let mut env = Vec::new(); + for p in pts { + if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) { + env.pop(); + } + env.push(p); + } c.gain_env = env; } } - tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end); + let covers_all = lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); + let lane_m = tr.lane_mut(&AutoParam::Volume); + if covers_all { + lane_m.clear_range(r.start, r.end); + } else { + // Hold the fader across the selection and anchor the samples on either side, so the + // curve outside does not interpolate through the gap or keep the old level inside. + let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base)); + let after = lane.value_at(r.end, base); + lane_m.clear_range(r.start, r.end); + if let Some(v) = before { + lane_m.set_point(r.start - 1, v); + } + lane_m.set_point(r.start, base); + if r.end - 1 > r.start { + lane_m.set_point(r.end - 1, base); + } + lane_m.set_point(r.end, after); + } } Ok(json!({})) } @@ -606,3 +669,93 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result { } Ok(json!({})) } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; + use soundcraft_time::Range; + use std::sync::Arc; + + fn rms_db(before: &[f32], after: &[f32]) -> f32 { + let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::() / xs.len().max(1) as f32; + 10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10() + } + + fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) { + let mut s = Session::default(); + let n = 8192; + let tone: Vec = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect(); + s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + let id = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n)); + s.track_mut(t).unwrap().mixer.volume_db = -3.0; + for &(at, value) in points { + s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value); + } + s.edit.selected_tracks = vec![t]; + s.edit.selection = Range::new(2048, 6144); + (Engine::new(s), t) + } + + fn rendered(s: &Session) -> Vec { + soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default() + } + + fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) { + e.session_mut().edit.selection = Range::new(start, end); + e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap(); + } + + #[test] + fn partial_volume_to_clip_gain_keeps_each_region() { + let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)]; + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB"); + } + let tr = e.session().track(t).unwrap(); + assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5); + let lane = tr.lane(&AutoParam::Volume).unwrap(); + assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3); + assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3); + assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3); + + let (mut e, t) = tone_session(&[]); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(diff < 1e-5, "no lane changed a sample by {diff}"); + + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 0, 8193); + let after = rendered(e.session()); + let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max); + assert!(diff < 1e-4, "full selection changed a sample by {diff}"); + assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty())); + + for points in [ + [(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)], + [(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)], + ] { + let (mut e, t) = tone_session(&points); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2048), (6144, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}"); + } + let delta = rms_db(&before[2048..6144], &after[2048..6144]); + assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}"); + } + } +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..a0297d3 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -876,8 +876,11 @@ fn process_strip( d.extend_from_slice(src.get(..frames).unwrap_or(&[])); } } + // Endpoints are the first and last sample of this block. The next block starts on its own + // first sample, so a breakpoint there does not ramp the block before it. + let last = pos.saturating_add(frames.saturating_sub(1) as i64); let mut v0 = volume_db_at(t, pos); - let mut v1 = volume_db_at(t, pos + frames as i64); + let mut v1 = volume_db_at(t, last); // Trim automation (an offset on top of the volume curve). if let Some(l) = t .automation @@ -886,14 +889,14 @@ fn process_strip( .filter(|l| !l.points.is_empty()) { v0 += l.value_at(pos, 0.0); - v1 += l.value_at(pos + frames as i64, 0.0); + v1 += l.value_at(last, 0.0); } // VCA master: its fader offsets the member's, and its mute mutes the member. if let Some(vid) = t.mixer.vca && let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca) { v0 += volume_db_at(vca, pos); - v1 += volume_db_at(vca, pos + frames as i64); + v1 += volume_db_at(vca, last); if vca.mixer.mute { strip.muted = true; } @@ -1202,7 +1205,9 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut while i < i1 { let seg_end = (i + STEP).min(i1); let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start); - let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start); + // The last sample of this step, not the first sample of the next one, so a breakpoint + // on the boundary does not ramp the audio before it. + let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start); let g0 = clip.gain_at(rel0); let g1 = clip.gain_at(rel1.min(clip.length)); let n = (seg_end - i) as f32; From 35a6fbccc0a3a6417ff98a4f5275775baa83411b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:27:21 +1100 Subject: [PATCH 13/46] Restore full playback speed when transport stops. Half-Speed Playback stored 0.5 on the player, and a manual Stop left that rate in place, so the next ordinary Play stayed at half speed. Stopping now resets the player to 1.0. Half-speed itself still plays at 0.5, and a pass that ends on its own still returns to full speed. --- crates/playback/src/lib.rs | 5 +++++ crates/ui-egui/src/lib.rs | 41 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..5d22e7a 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -622,6 +622,11 @@ impl Player { self.shared.speed.store(v, Ordering::Relaxed); } + /// Playback rate last requested, 1.0 at full speed. + pub fn speed(&self) -> f32 { + self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0 + } + pub fn position(&self) -> Samples { self.shared.position.load(Ordering::Relaxed) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc0685d..d4d8560 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -358,6 +358,8 @@ impl SoundApp { fn stop_play(&mut self) { if let Some(p) = &self.player { + // Half-speed is a separate command. Stop must not leave that rate for the next Play. + p.set_speed(1.0); p.stop(); } if self.engine.transport.recording { @@ -909,4 +911,43 @@ mod tests { fn automation_write_fires_after_loop_wrap() { assert!(automation_step_due(0, 1_000_000, 960)); } + + #[test] + fn ordinary_play_after_stop_is_full_speed() { + use serde_json::json; + let engine = soundcraft_engine::Engine::default(); + let player = soundcraft_playback::Player::new(engine.session_arc()); + let mut app = super::SoundApp::new(engine, Some(player), super::Services::default()); + app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap(); + app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap(); + app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap(); + let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0); + assert!((speed(&app) - 1.0).abs() < 1e-3); + + app.engine.execute("transport.half_speed", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(app.is_playing()); + assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback"); + + app.engine.execute("transport.stop", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(!app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed"); + + app.engine.execute("transport.play", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!(app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed"); + + app.engine.execute("transport.stop", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap(); + app.engine.execute("transport.half_speed", &json!({})).unwrap(); + app.handle_transport(1.0 / 60.0); + assert!((speed(&app) - 0.5).abs() < 1e-3); + app.player.as_ref().unwrap().stop(); + app.handle_transport(1.0 / 60.0); + assert!(!app.is_playing()); + assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed"); + } } From ec33d4011b0e2fd08df4c74bfb0214a4ae4b606b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:28:24 +1100 Subject: [PATCH 14/46] Keep clip gain when AudioSuite inverts or duplicates. Both processes rendered a unity-gain file and then cleared the clip's gain and envelope, so the result was quieter than the clip the user heard. Invert and Duplicate now leave that gain on the new clip. Processes that bake a new level still write a unity-gain clip. --- crates/engine/src/cmd/audiosuite.rs | 48 +++++++++++++++++++++++++++-- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index ac26a30..e2abfca 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result { nc.name = new_name; nc.content = ClipContent::Audio { source: src, offset: 0 }; nc.length = i64::try_from(frames).unwrap_or(nc.length); - nc.gain_db = 0.0; - nc.gain_env.clear(); + // Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain, + // the same way its fades stay editable. Other processes bake a new file at unity gain. + if !matches!(name.as_str(), "invert" | "duplicate") { + nc.gain_db = 0.0; + nc.gain_env.clear(); + } if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { pl.clips.retain(|c| c.id != id); } @@ -122,3 +126,43 @@ fn short(n: &str) -> String { let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join(""); s.chars().take(8).collect() } + +#[cfg(test)] +mod tests { + use super::*; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Session, TrackKind}; + + fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + let mut c = Clip::audio(id, "tone", src, 0, 0, 64); + c.gain_db = -6.0; + c.gain_env = vec![(0, 0.0), (32, -12.0)]; + crate::edit::place_clip(&mut s, t, c); + s.edit.selected_clips = vec![id]; + (crate::Engine::new(s), id) + } + + #[test] + fn invert_and_duplicate_keep_clip_gain() { + let (mut e, id) = gained_clip(); + e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db); + assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]); + let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; + assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}"); + + let (mut e, id) = gained_clip(); + e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap(); + let c = e.session().tracks[0].clips()[0].clone(); + assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db); + assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]); + let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; + assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); + } +} From abcec3938b91c5aa64b5898c5b9b4726045766a6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:41:03 +1100 Subject: [PATCH 15/46] Render a named bus when bouncing, and reject an unknown one. file.bounce_mix advertised a source but always wrote the main mix, so an empty bus and a missing bus both succeeded with the whole mix. --- crates/engine/src/cmd/file.rs | 61 ++++++++++++++++++++++++++- crates/engine/src/io.rs | 31 ++++++++++++-- crates/mix/src/lib.rs | 79 +++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 796ad8f..4a1eb50 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -234,6 +234,17 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result> { selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track")) } +/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error. +fn bounce_bus(e: &Engine, p: &Value) -> Result> { + let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else { + return Ok(None); + }; + if name.eq_ignore_ascii_case("main") { + return Ok(None); + } + e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`"))) +} + /// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix. fn fold_down_param(p: &Value, id: &str) -> Result { match str_param(p, "fold_down").map(str::trim) { @@ -260,7 +271,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }; let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; - let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?; + 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}")))?; 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}), @@ -365,6 +377,53 @@ mod tests { assert!(std::fs::remove_dir_all(&dir).is_ok()); } + #[test] + fn bounce_source_renders_the_named_bus() { + let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let wav = tone_wav(&dir, "tone.wav"); + let mut e = Engine::default(); + e.execute("file.import_audio", &json!({"path": wav})).unwrap(); + let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo); + e.session_mut().add_bus("Empty", ChannelFormat::Stereo); + let track = e.session().tracks[0].id; + { + let tr = e.session_mut().track_mut(track).unwrap(); + let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem)); + send.level_db = -12.0; + tr.mixer.sends[0] = Some(send); + } + let bounce = |e: &mut Engine, name: &str, source: Option<&str>| { + let path = dir.join(name); + let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false}); + if let Some(source) = source { + params["source"] = json!(source); + } + let value = e.execute("file.bounce_mix", ¶ms).unwrap(); + let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0); + (std::fs::read(&path).unwrap(), peak) + }; + let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None); + let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main")); + let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM")); + let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty")); + assert_eq!(main_bytes, named_main); + assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}"); + assert!(main_peak > -20.0, "{main_peak}"); + assert_ne!(stem_bytes, main_bytes); + assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}"); + assert_ne!(empty_bytes, main_bytes); + assert!(empty_peak < -100.0, "{empty_peak}"); + let missing = dir.join("missing.wav"); + let err = e + .execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false})) + .unwrap_err(); + assert!(err.to_string().contains("unknown bus"), "{err}"); + assert!(!missing.exists()); + assert!(std::fs::remove_dir_all(&dir).is_ok()); + } + fn remove_media(dir: &std::path::Path) { for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { let p = entry.path(); diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..b81894f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -3,7 +3,7 @@ use crate::{Engine, EngineError, Result}; use serde_json::{Value, json}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; -use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_time::{Range, Samples}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -217,11 +217,23 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) /// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV /// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { + bounce_source(e, r, opts, normalize, fold_stereo, None) +} + +/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named. +pub fn bounce_source( + e: &Engine, + r: Range, + opts: &EncodeOptions, + normalize: bool, + fold_stereo: bool, + bus: Option, +) -> Result<(Vec, (f32, f32, f32))> { let s = e.session(); if r.len() > s.sample_rate.samples(4.0 * 3600.0) { return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); } - let (mut ch, fmt) = render_main(s, r, fold_stereo); + let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus); if normalize { soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); } @@ -238,8 +250,19 @@ pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: /// The main mix over `r` and its format (folded to stereo on request). fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { - let ch = soundcraft_mix::render_range(s, r, 1024); - let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + render_bounce(s, r, fold_stereo, None) +} + +/// The main mix, or one bus, over `r`. +fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) { + let (ch, fmt) = if let Some(id) = bus { + let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo); + (soundcraft_mix::render_bus(s, id, r, 1024), fmt) + } else { + let ch = soundcraft_mix::render_range(s, r, 1024); + let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; + (ch, fmt) + }; if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..eb17222 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -293,6 +293,11 @@ pub struct MixEngine { order: Vec, /// Current total compensation delay (samples) of the mix. pub latency: usize, + /// Delay already applied to audio written onto busses: the slowest track insert. + /// A bus render drops this so the bus lines up with the timeline. The extra delay + /// that lines direct outputs up with aux returns stays in `latency` and is removed + /// only from the main mix. + track_latency: usize, /// Live input for the next block (planar), set by the audio host for input monitoring. pub input: Vec>, /// Transport stopped: only monitored inputs sound (no clips). @@ -363,6 +368,7 @@ impl MixEngine { synced: (0, 0, 0), order: Vec::new(), latency: 0, + track_latency: 0, input: Vec::new(), monitor_only: false, recording: false, @@ -593,6 +599,7 @@ impl MixEngine { let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; + self.track_latency = lmax_t; self.latency = lmax_t + lmax_a; for (t, st) in work.iter_mut() { let ch = st.buf.len(); @@ -679,6 +686,24 @@ impl MixEngine { self.main_meter.measure(&self.main, frames, 0.0); } + /// Copy one rendered block of `bus` into `out`. A missing bus stays silent. + fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec]) { + for o in out.iter_mut() { + for x in o.iter_mut().take(frames) { + *x = 0.0; + } + } + let Some(src) = self.busses.get(&bus) else { return }; + for (o, c) in out.iter_mut().zip(src.buf.iter()) { + for i in 0..frames { + let v = c.get(i).copied().unwrap_or(0.0); + if let Some(d) = o.get_mut(i) { + *d = if v.is_finite() { v } else { 0.0 }; + } + } + } + } + /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr); @@ -1640,6 +1665,46 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { out } +/// Offline render of one bus over `range` (planar, the bus format's channel count). +/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. +/// An unknown bus is silent. +pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { + let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); + if nch == 0 { + return Vec::new(); + } + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch); + let latency = { + let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = probe.max_block(); + probe.render(s, range.start, b.min(len.max(1)), &mut []); + probe.track_latency + }; + let total = len.saturating_add(latency); + let mut out = vec![vec![0.0f32; total]; nch]; + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let b = eng.max_block(); + let mut tmp = vec![vec![0.0f32; b]; nch]; + let mut done = 0usize; + while done < total { + let n = (total - done).min(b); + eng.render(s, range.start + done as i64, n, &mut []); + eng.copy_bus(bus, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(sr); + } + } + done += n; + } + if latency > 0 { + for c in &mut out { + c.drain(..latency.min(c.len())); + } + } + out +} + /// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; /// Ambisonics play their W channel). Mono/stereo input is returned unchanged. pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { @@ -1879,6 +1944,20 @@ mod tests { assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]); } + #[test] + fn render_bus_is_the_bus_not_the_main_mix() { + let (mut s, t) = session_with_dc(0.5, 4800); + let stem = s.add_bus("Stem", ChannelFormat::Stereo); + let empty = s.add_bus("Empty", ChannelFormat::Stereo); + s.track_mut(t).unwrap().mixer.output = Route::Bus(stem); + let main = render_range(&s, Range::new(0, 4800), 512); + let bus = render_bus(&s, stem, Range::new(0, 4800), 512); + let silent = render_bus(&s, empty, Range::new(0, 4800), 512); + assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]); + assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]); + assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6))); + } + #[test] fn volume_automation_ramps() { let (mut s, t) = session_with_dc(0.5, 48_000); From fe4f59a85644acfa5881a7a95d660deb46f8eb52 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:46:11 +1100 Subject: [PATCH 16/46] Bake varispeed into rendered clip gain, then stop stretching it again. Clip gain and clip effects renders wrote the clip as it already sounded and left the old ratio in place, so the new file was stretched a second time and its tail went silent. --- crates/engine/src/cmd/clip_more.rs | 65 ++++++++++++++++++++++++++++++ crates/engine/src/io.rs | 3 ++ 2 files changed, 68 insertions(+) diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index cca8102..74dfd8d 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -362,4 +362,69 @@ mod tests { assert_eq!(c.length, 18_000); assert_ne!(c.source(), Some(src)); } + + fn peak_from(ch: &[f32], start: usize) -> f32 { + ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs())) + } + + fn max_abs_diff(a: &[Vec], b: &[Vec]) -> f32 { + a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max) + } + + /// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result. + #[test] + fn render_preserves_stretched_audio() { + for command in ["clip.gain_render", "clip.effects_render"] { + for ratio in [0.5_f64, 1.0, 2.0] { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600)); + s.edit.selected_clips = vec![id]; + let mut e = Engine::new(s); + e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap(); + e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap(); + if command == "clip.effects_render" { + e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap(); + } + let range = e.session().find_clip(id).unwrap().1.range(); + let before = soundcraft_mix::render_clips(e.session(), t, range); + e.execute(command, &json!({"clip": id.0})).unwrap(); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch); + assert_eq!(c.gain_db, 0.0); + let after = soundcraft_mix::render_clips(e.session(), t, range); + let diff = max_abs_diff(&before, &after); + assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}"); + if ratio < 1.0 { + let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0); + assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}"); + } + } + } + + // Varispeed on a non-elastic track does not change the clip length. Render still matches it. + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let tone: Vec = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600)); + s.edit.selected_clips = vec![id]; + let mut e = Engine::new(s); + e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap(); + e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap(); + let range = e.session().find_clip(id).unwrap().1.range(); + let before = soundcraft_mix::render_clips(e.session(), t, range); + e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap(); + let c = e.session().find_clip(id).unwrap().1.clone(); + assert_eq!(c.length, 9_600); + assert!((c.stretch - 1.0).abs() < 1e-9); + let after = soundcraft_mix::render_clips(e.session(), t, range); + assert!(max_abs_diff(&before, &after) < 1e-5); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 53d38e2..73028a2 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -379,6 +379,9 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result< cl.content = ClipContent::Audio { source: src, offset: 0 }; cl.gain_db = 0.0; cl.gain_env.clear(); + // The new source is the clip as it already sounds, stretch included. + // Keeping the old ratio would play that audio through the stretch again. + cl.stretch = 1.0; } Ok(true) } From 59721f7c87e3e2c109febcbe46b058ceda8ea0d7 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:46:11 +1100 Subject: [PATCH 17/46] Compensate for an aux insert's latency on the same block that measures it. The bounce probe read aux latency before the aux had processed, so a lookahead compressor shifted the export and dropped audio at the end of the range. --- crates/mix/src/lib.rs | 73 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..36d8b47 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -587,9 +587,17 @@ impl MixEngine { let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo)); self.busses = busses; - // Delay compensation: align every strip to the slowest; direct-to-main outputs also wait - // for the slowest aux return (aux latencies are from the previous block; they are stable). + // Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait + // for the slowest aux return. Aux latency has to be read before those outputs are routed, + // or the first block (and a one-block bounce probe) still sees zero. let pdc = s.edit.delay_compensation; + if pdc { + for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) { + if let Some(st) = self.strips.get_mut(&t.id) { + note_insert_latency(t, st, pos); + } + } + } let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; @@ -1278,6 +1286,20 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } } +/// Record insert latency without processing audio, so this block can compensate for it. +fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { + let mut latency = 0usize; + for (i, ins) in t.mixer.inserts.iter().enumerate() { + let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue }; + if !ins.active || ins.bypass { + continue; + } + apply_params(slot, ins, t, i, pos); + latency = latency.saturating_add(slot.plugin.latency()); + } + strip.latency = latency; +} + fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { let slot_u8 = u8::try_from(i).unwrap_or(0); let automated = t.mixer.automation_mode.reads() @@ -2057,6 +2079,53 @@ mod tests { assert_ne!(argmax(&out[0]), argmax(&out[1])); } + fn impulse_at(ch: &[f32]) -> Vec { + ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect() + } + + /// An aux insert's lookahead is part of the mix latency on the first block, not the block after. + #[test] + fn delay_compensation_aligns_an_aux_insert() { + let mut s = Session::default(); + let n = 8192; + let mut left = vec![0.0f32; n]; + for at in [0usize, 2048, 8000] { + left[at] = 0.25; + } + s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] }))); + let bus = s.add_bus("Test Bus", ChannelFormat::Stereo); + let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source")); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return")); + let cid = s.new_clip_id(); + s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64)); + s.track_mut(source).unwrap().mixer.output = Route::Bus(bus); + s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus); + let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024); + let control = render(&s, 0, n as i64); + assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]); + assert_eq!(impulse_at(&control[1]), impulse_at(&control[0])); + + let mut comp = Insert::new("compressor"); + comp.params.insert("lookahead".into(), 5.0); + comp.params.insert("ratio".into(), 1.0); + s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone()); + let on_source = render(&s, 0, n as i64); + assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0])); + + s.track_mut(source).unwrap().mixer.inserts[0] = None; + s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp); + let on_aux = render(&s, 0, n as i64); + assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce"); + assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0])); + assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]); + let partial = render(&s, 2048, 3072); + assert_eq!(impulse_at(&partial[0]), vec![0]); + + s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0); + let zero = render(&s, 0, n as i64); + assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0])); + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); From 7b25efd8cd3ed936085987ff4240b9b6419bb7ef Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:34:04 +1100 Subject: [PATCH 18/46] Credit Vikram Bhaskaran by real name and display name. The About window can show either name when the viewer picks that label. The GitHub profile name stays unused. --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 5079ae9..bc8bd31 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -35,3 +35,8 @@ sync_github_name = false [people.salignatmoandal] real_name = "Mawen Salignat-Moandal" sync_github_name = true + +[people.vbasky] +real_name = "Vikram Bhaskaran" +display_name = "Vikram Bhaskaran" +sync_github_name = false From 30347ee42f2fbf50854ba2b60d8b924a0a341edb Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:21:16 +1100 Subject: [PATCH 19/46] Open New Tracks and Bounce only from the menu. engine.execute and app.run of ui.new_tracks_dialog and ui.bounce_dialog were opening modal dialogs, so a script could not create tracks or bounce without a window. Those ids now return an error unless the menu invokes them. --- crates/ui-egui/src/menus.rs | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index d8c40e7..316cd4e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -390,6 +390,15 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) { if wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id) { return; } + // These ids only exist to open a dialog from the menu. Programmatic `app.run` refuses them. + if id == "ui.new_tracks_dialog" { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + return; + } + if id == "ui.bounce_dialog" { + let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix"); + return; + } let params = params_for(path, id); let _ = app.run(id, params); } @@ -524,12 +533,10 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { - let _ = app.dialogs.open_for_command(&app.engine, "track.new"); - json!({}) + return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into())); } "ui.bounce_dialog" => { - let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix"); - json!({}) + return Some(Err("ui.bounce_dialog opens a dialog from the menu only; call file.bounce_mix with a path".into())); } "ui.theme" => { let Some(mode) = p.get("mode").and_then(Value::as_str) else { @@ -571,3 +578,24 @@ pub fn parity() -> Value { let extra = ui_aliases(); catalog::parity_with(&extra) } +#[cfg(test)] +mod tests { + use super::*; + use crate::{Services, SoundApp}; + + #[test] + fn programmatic_dialog_commands_stay_closed_and_the_menu_opens_them() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let err = app.run("ui.new_tracks_dialog", json!({})).unwrap_err(); + assert!(err.contains("track.new"), "{err}"); + let err = app.run("ui.bounce_dialog", json!({})).unwrap_err(); + assert!(err.contains("file.bounce_mix"), "{err}"); + assert!(app.dialogs.open_name().is_none()); + + invoke_menu(&mut app, "ui.new_tracks_dialog", "Track > New..."); + assert_eq!(app.dialogs.open_name(), Some("new_tracks")); + app.dialogs.open = None; + invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix..."); + assert_eq!(app.dialogs.open_name(), Some("bounce")); + } +} From 9ef16e1e6ceed0846b349c636ce0d8a1661234e6 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:21:47 +1100 Subject: [PATCH 20/46] Put Cut All Automation on the clipboard. The command cleared every automation lane in the selection and left the clipboard empty, so the cut could not be pasted. It now uses the same copy-and-clear path as Cut Pan Automation. --- crates/engine/src/cmd/edit.rs | 4 +++- crates/engine/src/cmd/edit_more.rs | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7e4f2d5..39add57 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -25,7 +25,9 @@ pub fn specs() -> Vec { p, "edit.clear_clip_gain" )), - cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)), + cmd!("edit.cut_all_automation", "All Automation", ["Edit", "Cut Special"], None, "{tracks?, start?, end?}", has_range, |e, p| { + super::edit_more::cut_all_automation(e, p) + }), cmd!("edit.clear_all_automation", "All Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation(e, p, None)), cmd!("edit.clear_pan_automation", "Pan Automation", ["Edit", "Clear Special"], None, "{}", has_range, |e, p| clear_automation( e, diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index d27bb97..286d1d9 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -277,6 +277,10 @@ fn nonempty_range(e: &Engine, p: &Value, id: &str) -> Result { if r.is_empty() { Err(bad(id, "the selection is empty")) } else { Ok(r) } } +pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result { + auto_copy(e, p, Lanes::All, true, "edit.cut_all_automation") +} + fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result { let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; @@ -982,6 +986,21 @@ mod tests { assert_eq!(starts(&e, t), vec![0, 900, 1000]); } + #[test] + fn cut_all_automation_can_be_pasted() { + let (mut e, t, _) = session_with_clips(&[(0, 1000)]); + e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 100, "value": -6.0})).unwrap(); + e.execute("automation.set_point", &json!({"track": t.0, "param": "volume", "at": 400, "value": -12.0})).unwrap(); + let cut = e.execute("edit.cut_all_automation", &json!({"start": 50, "end": 250})).unwrap(); + assert!(cut["lanes"].as_u64().unwrap() >= 1, "{cut}"); + assert!(e.clipboard.automation.iter().any(|lanes| !lanes.is_empty())); + let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!(lane.points.iter().all(|p| p.at != 100), "the cut point is gone: {:?}", lane.points); + e.execute("edit.paste_to_current_automation", &json!({"at": 1000})).unwrap(); + let vol = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap().clone(); + assert!(vol.points.iter().any(|p| p.at >= 1000 && (p.value + 6.0).abs() < 1e-2), "paste wrote nothing at -6 dB: {:?}", vol.points); + } + #[test] fn copy_and_paste_automation_only() { let (mut e, t, _) = session_with_clips(&[(0, 1000)]); From 15fea1ed5dc0af1b8ceb39533b083f6af37b2560 Mon Sep 17 00:00:00 2001 From: Mawen Salignat-Moandal Date: Sat, 10 Oct 2026 14:23:08 +0200 Subject: [PATCH 21/46] MCP batch: set isError when any call fails, even with keep_going. Agents that only check isError were treating partial failures as success. Also reject a non-array calls value instead of silently running nothing. Fixes #145. --- crates/automation/src/mcp.rs | 17 +++++++++--- crates/automation/tests/agent_tasks.rs | 36 ++++++++++++++++++++++++++ docs/mcp.md | 2 +- 3 files changed, 51 insertions(+), 4 deletions(-) diff --git a/crates/automation/src/mcp.rs b/crates/automation/src/mcp.rs index 1b38dcc..19fe91a 100644 --- a/crates/automation/src/mcp.rs +++ b/crates/automation/src/mcp.rs @@ -127,11 +127,20 @@ impl Server { } "batch" => { let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false); - let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default(); + let Some(calls) = args.get("calls").and_then(Value::as_array) else { + return error_result("`calls` must be an array"); + }; let mut out = Vec::new(); let mut failed = false; for c in calls.iter().take(1000) { - let cmd = c.get("command").and_then(Value::as_str).unwrap_or(""); + let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else { + out.push(json!({"command": "", "ok": false, "error": "`command` required"})); + failed = true; + if !keep { + break; + } + continue; + }; let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({}))); match r { Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})), @@ -144,7 +153,9 @@ impl Server { } } } - if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } + // Any failure sets isError so agents that only check that flag notice; + // keep_going still runs the rest and the payload lists every call. + if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) } } "inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})), "new_session" => self.exec("session.new", args.clone()), diff --git a/crates/automation/tests/agent_tasks.rs b/crates/automation/tests/agent_tasks.rs index 04fe256..3636cfe 100644 --- a/crates/automation/tests/agent_tasks.rs +++ b/crates/automation/tests/agent_tasks.rs @@ -135,3 +135,39 @@ fn errors_are_reported_not_panics() { let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}})); assert_eq!(r["result"]["isError"], true); } + +#[test] +fn batch_keep_going_still_sets_is_error_on_partial_failure() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc( + &mut s, + 1, + "tools/call", + json!({"name": "batch", "arguments": {"keep_going": true, "calls": [ + {"command": "track.new", "params": {"count": 1, "name": "Kept"}}, + {"command": "mix.volume", "params": {"track": "Nope", "db": -6}}, + {"command": "track.new", "params": {"count": 1, "name": "After"}}, + ]}}), + ); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + let results: Value = serde_json::from_str(text).unwrap(); + let arr = results.as_array().unwrap(); + assert_eq!(arr.len(), 3, "{results}"); + assert_eq!(arr[0]["ok"], true); + assert_eq!(arr[1]["ok"], false); + assert_eq!(arr[2]["ok"], true); + let sess = session(&mut s); + assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept")); + assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After")); +} + +#[test] +fn batch_rejects_non_array_calls() { + let mut s = Server::new(Headless::new(Engine::default())); + let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}})); + assert_eq!(r["result"]["isError"], true, "{r}"); + let text = r["result"]["content"][0]["text"].as_str().unwrap(); + assert!(text.contains("`calls` must be an array"), "{text}"); + assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0); +} diff --git a/docs/mcp.md b/docs/mcp.md index 2949871..d3ed9a9 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -22,7 +22,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo |---|---| | `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). | | `execute` | Run one command with JSON params. | -| `batch` | Run several commands in order (`keep_going` optional). | +| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. | | `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). | | `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). | | `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. | From 840939faf7fff2ad2056b7579a264a2367452692 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:37:19 +0100 Subject: [PATCH 22/46] Cmd+= only switches Mix/Edit; the scale slider drags smoothly Fixes #144. Unless Options::zoom_with_keyboard is off, egui zooms the whole interface on Cmd+= (or Cmd+Plus), Cmd+- and Cmd+0. It checks for those keys at the end of every frame, after SoundCraft's shortcuts have read them without consuming them. So every Cmd+= that switched between the Mix and Edit windows also zoomed in by another 10 %, and Cmd+0 (Quantize to Grid) reset the interface scale. Ctrl did the same on Windows and Linux. Turn egui's keyboard zoom off. The Interface scale slider in Setup > Appearance is now the only thing that sizes the interface, so it always shows the real scale. Before, keyboard zoom changed the scale without moving the slider, and could go past its 75-150 % range. Dragging that slider rescaled the whole interface on every frame of the drag. Each rescale moved the slider relative to the pointer, which stays put on screen, so the next frame read another value under the pointer. The scale jumped back and forth (105, 110, 105 ... 145, 120, 150, 90 % in one drag) and the slider would not stay where it was dragged. Apply the scale once the mouse button is released. It is now applied after the window has drawn, too, so a click, Reset or an arrow key takes effect in the same frame instead of waiting for the next input. --- crates/ui-egui/src/extra_windows.rs | 5 +-- crates/ui-egui/src/shortcuts.rs | 2 ++ crates/ui-egui/tests/appearance.rs | 47 +++++++++++++++++++++++++++++ crates/ui-egui/tests/shortcuts.rs | 22 +++++++++++++- 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 crates/ui-egui/tests/appearance.rs diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index 71e63fa..e269573 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option { } pub fn show(app: &mut SoundApp, ctx: &egui::Context) { - apply_customization(app, ctx); auto_update(app, ctx); renderer(app, ctx); ui_customization(app, ctx); + apply_customization(app, ctx); } fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) { let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion); - if app.extra.applied == Some(want) { + // Rescaling mid-drag would move the Interface scale slider out from under the pointer. + if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) { return; } // Only touch the zoom once the user has changed it, so platform scaling stays untouched. diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 90a4e08..fb978c3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool { } pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { + // Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom. + ctx.options_mut(|o| o.zoom_with_keyboard = false); if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() { return; } diff --git a/crates/ui-egui/tests/appearance.rs b/crates/ui-egui/tests/appearance.rs new file mode 100644 index 0000000..87145a8 --- /dev/null +++ b/crates/ui-egui/tests/appearance.rs @@ -0,0 +1,47 @@ +use egui::accesskit::Role; +use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp}; + +const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0); + +fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option) { + let zoom = h.ctx.zoom_factor(); + h.set_size(WINDOW_PX / zoom); + let pos = (px.to_vec2() / zoom).to_pos2(); + h.input_mut().events.push(Event::PointerMoved(pos)); + if let Some(pressed) = pressed { + h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + } + h.step(); +} + +#[test] +fn interface_scale_slider_follows_the_pointer() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.extra.show_ui_customization = true; + let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + h.run_steps(6); + let rail = h.get_by_role(Role::Slider).rect(); + let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y); + let end = pos2(rail.right() + 40.0, rail.center().y); + frame(&mut h, start, Some(true)); + let mut scales = vec![]; + for i in 1..=20 { + frame(&mut h, start.lerp(end, i as f32 / 20.0), None); + scales.push(h.state().extra.ui_scale); + } + assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}"); + frame(&mut h, end, Some(false)); + frame(&mut h, end, None); + assert_eq!(h.state().extra.ui_scale, 1.5); + assert_eq!(h.ctx.zoom_factor(), 1.5); +} diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index fc7fdc4..5d04d47 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -1,7 +1,7 @@ use egui::os::OperatingSystem; use egui::{Key, Modifiers}; use serde_json::json; -use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts}; 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(); @@ -24,3 +24,23 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}"); } } + +#[test] +fn cmd_equals_toggles_mix_and_edit_without_zooming() { + let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + 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::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + ctx.set_os(os); + ctx.set_zoom_factor(1.25); + press(&ctx, &mut app, Key::Equals, cmd); + assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}"); + press(&ctx, &mut app, Key::Equals, cmd); + press(&ctx, &mut app, Key::Num0, cmd); + press(&ctx, &mut app, Key::Minus, cmd); + ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear(); + assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}"); + assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}"); + } +} From 4aa837e933656f3ab860fc252b6fc83da75b48b7 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 13:38:23 +0100 Subject: [PATCH 23/46] Make Cmd+H heal and Cmd+Alt+H copy to send on macOS On macOS, Heal Separation (Cmd+H) and Copy to Send (Cmd+Alt+H) never ran (#140). The system menu bar used muda's predefined Hide and Hide Others items, which carry those keys, and macOS offers a key press to the menu bar's key equivalents before the window, so egui never saw them: Cmd+H hid SoundCraft and Cmd+Alt+H hid the other apps. Pro Tools uses the same keys for Heal Separation and Copy Automation to Send, so the keys stay and Hide loses its shortcut instead. Hide SoundCraft and Hide Others are now plain menu items that call NSApplication's hide: and hideOtherApplications:. These are safe objc2-app-kit calls, and both crates were already in the build through muda. Show All keeps muda's item, which has no shortcut. Windows and Linux have no such menu, so nothing changes there. --- Cargo.lock | 2 ++ apps/soundcraft/Cargo.toml | 2 ++ apps/soundcraft/src/native_menu.rs | 16 ++++++++++++---- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b26758..2ea1d8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3409,6 +3409,8 @@ dependencies = [ "egui", "log", "muda", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "rfd", "serde_json", "soundcraft-engine", diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index cb9dab1..783c7b5 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -28,3 +28,5 @@ winresource = "0.1" [target.'cfg(target_os = "macos")'.dependencies] muda = { version = "0.19.2", default-features = false } +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] } diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs index 9cc72de..4bd085a 100644 --- a/apps/soundcraft/src/native_menu.rs +++ b/apps/soundcraft/src/native_menu.rs @@ -1,5 +1,7 @@ //! macOS system menu bar. All actions use the same menu dispatch as the egui host. -use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu}; +use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu}; +use objc2::MainThreadMarker; +use objc2_app_kit::NSApplication; use soundcraft_ui_egui::{SoundApp, menus}; use std::sync::mpsc::{Receiver, channel}; @@ -36,8 +38,9 @@ impl NativeMenu { app_menu.append(&PredefinedMenuItem::separator())?; app_menu.append(&PredefinedMenuItem::services(None))?; app_menu.append(&PredefinedMenuItem::separator())?; - app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?; - app_menu.append(&PredefinedMenuItem::hide_others(None))?; + // muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send. + app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?; + app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?; app_menu.append(&PredefinedMenuItem::show_all(None))?; app_menu.append(&PredefinedMenuItem::separator())?; native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?; @@ -82,7 +85,12 @@ impl NativeMenu { while let Ok(event) = self.events.try_recv() { // A click changes state (and Cocoa flips the item's check mark): sync on the next pass. self.last_sync = None; - if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) + if (event.id == "hide" || event.id == "hide_others") + && let Some(mtm) = MainThreadMarker::new() + { + let ns = NSApplication::sharedApplication(mtm); + if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) } + } else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) && let Some(command) = &entry.command && menus::item_state(app, &entry.path, Some(command)).0 { From 330a6983973dc2043bb1fa0f0ba67fa07ae50685 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 14:08:49 -0400 Subject: [PATCH 24/46] Progress docs: target-app parity, gaps, alpha gate, readiness by audience (#155) * Progress docs to the craftrules standard; full re-measure against Pro Tools 2026.4.1 Stage: alpha. Menu breadth 482/512 (94.1%, measured), feature breadth ~70%, ready for real work ~40%; ~350-570 agent-hours to beta, ~950-1,450 to full parity, calibrated on this repo's PR history. - ROADMAP.md rewritten: stage banner, two numbers, dimension/feature/language tables - docs/target-app-parity.md: authoritative assessment (area table moved from ROADMAP) - docs/gaps.md: 22 ranked gaps with evidence and estimates - docs/roadmap.md: milestones, current focus, beta plan - docs/ui-, file-format-, hardware-, plugin-, localization-parity.md: new checklists - docs/parity.md -> docs/parity-checklist.md (generated by cargo xtask parity) - architecture.md: video, vst3-host, au-host crates; device I/O limits Co-Authored-By: Claude Opus 5.5 * Stage: pre-alpha under the core-workflow gate Add the alpha gate to docs/roadmap.md: interface recording at a chosen buffer size, MIDI recording, and session interchange with Pro Tools fail, so the stage moves from alpha to pre-alpha (~5 points and ~110-180 agent-hours to alpha). Mark the alpha blockers in docs/gaps.md; update ROADMAP.md, target-app-parity.md and README. Co-Authored-By: Claude Opus 5.5 * Add mainstream-practitioner and essentials-user readiness numbers Mainstream practitioner ~36% (weekly-area depth 50.6% x 0.90 interaction x 0.92 stability x 0.85 file exchange) and essentials user ~57% (core-feature depth 72.4% x 0.90 launch/stability x 0.90 discoverability x 0.97 files), with written weights, discounts and GitHub user evidence. Full ready-for-real-work checked against its written dimension weights (40.6%); unchanged at ~40%, stage unchanged. Co-Authored-By: Claude Opus 5.5 * Readiness by audience: hours to ~95% for full, mainstream and essentials Full ~40% / 950-1,450 h, mainstream ~36% / 380-600 h, essentials ~57% / 110-200 h, calibrated on this repo's PR history. The full number is already an additive weighted sum over the written dimension table; method unchanged. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 6 +- README.md | 13 +- ROADMAP.md | 182 +++++++++++------ apps/soundcraft-cli/src/main.rs | 2 +- docs/architecture.md | 24 ++- docs/file-format-parity.md | 83 ++++++++ docs/gaps.md | 200 +++++++++++++++++++ docs/hardware-parity.md | 75 +++++++ docs/localization-parity.md | 42 ++++ docs/{parity.md => parity-checklist.md} | 4 +- docs/plugin-parity.md | 61 ++++++ docs/roadmap.md | 90 +++++++++ docs/target-app-parity.md | 252 ++++++++++++++++++++++++ docs/ui-parity.md | 67 +++++++ xtask/src/main.rs | 2 +- 15 files changed, 1036 insertions(+), 67 deletions(-) create mode 100644 docs/file-format-parity.md create mode 100644 docs/gaps.md create mode 100644 docs/hardware-parity.md create mode 100644 docs/localization-parity.md rename docs/{parity.md => parity-checklist.md} (94%) create mode 100644 docs/plugin-parity.md create mode 100644 docs/roadmap.md create mode 100644 docs/target-app-parity.md create mode 100644 docs/ui-parity.md diff --git a/AGENTS.md b/AGENTS.md index 629b9fa..e0f7c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule ## Start every session here 1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed). -2. Read `ROADMAP.md` (committed) for status, milestones and the parity estimate. +2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. 3. Work autonomously; don't stop to ask unless a decision is genuinely the user's. ## ⚠️ Assets: the absolute rule @@ -51,7 +51,7 @@ Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read e ## Everything is a command User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu path, shortcut, params doc, `enabled`, `run`) + tests. UI-only commands (windows, dialogs) live in `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`). Menus, shortcuts, the CLI, the control channel and MCP all dispatch the same ids. Programmatic calls never open dialogs; only menu-style invocation (`ui.menu.invoke`) does. New commands go in a new module with its own `specs()`, registered by one line in `cmd/mod.rs`. -Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity.md`; a test enforces a floor that only rises. +Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises. ## Running and looking at the app - `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed). @@ -69,7 +69,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. ## Roadmap -`ROADMAP.md` (committed) tracks status, milestones and estimates. Update it whenever a milestone lands. +`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry. ## Contributor credits (About window) diff --git a/README.md b/README.md index 89ec40e..add0398 100644 --- a/README.md +++ b/README.md @@ -236,10 +236,15 @@ and layering checks and the WebAssembly build. ## Status and roadmap -SoundCraft is **pre-alpha, and close to its first alpha**: it covers 94 % of the incumbent's menu -items and roughly two thirds of its features in depth. The core works and is fun to use, and plenty is still missing. The -honest status, what's next and our effort estimates are in [`ROADMAP.md`](ROADMAP.md); the -menu-by-menu comparison is in [`docs/parity.md`](docs/parity.md). Bug reports and wish lists are +SoundCraft is **pre-alpha**: it covers 94 % of the incumbent's menu items (measured), about 70 % of +its features by presence, and is roughly 40 % of the way to replacing it for real work: record, +edit, mix and bounce work end to end, but choosing an audio interface and buffer size, +recording MIDI from a controller and session interchange (AAF/OMF) are still missing, and those +are the alpha checklist. Translations are missing too. The status, the stage and our effort +estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in +[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in +[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in +[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are very welcome, in the issues or on Discord. ## Downloads diff --git a/ROADMAP.md b/ROADMAP.md index a89da3f..7ace53f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,54 +1,114 @@ # SoundCraft roadmap +**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows) + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with -Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This file is -the honest status: what works today, what is missing, and how far we are. +Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is +the one-page summary; the detail is in [`docs/target-app-parity.md`](docs/target-app-parity.md) +(assessment), [`docs/gaps.md`](docs/gaps.md) (ranked work list), [`docs/roadmap.md`](docs/roadmap.md) +(milestones and beta plan) and [`docs/parity-checklist.md`](docs/parity-checklist.md) (generated +menu checklist). -## Where we are (2026-10-07) +## Headline numbers -| Measure | Value | How | +| Number | Value | Kind | |---|---|---| -| Menu-catalog parity (engine + UI) | **482 / 512 menu items (94 %)**; engine alone 397 / 512, see [`docs/parity.md`](docs/parity.md) | `cargo xtask parity` compares the incumbent's 512 menu leaves (names observed black-box) with our command registry. Most of what is left is Avid's online services (sign-in, cloud projects, collaboration, Splice, Learn), Dolby Atmos-specific items, HEAT and ARA | -| Estimated overall feature parity | **~65 %** | judgement across the areas below, weighted by how much working engineers rely on them. Menu items are only the surface; depth (editing feel, plugin catalogue, MIDI and score editing, video) is where the gap is | -| Distance to an **alpha** | **~85 % there, ~12–18 wall-clock hours** of Claude Opus 5.5 work | see [Alpha](#alpha) below | -| Distance to **100 % parity** | **~110–150 wall-clock hours** of Claude Opus 5.5 work with a single agent; roughly 40–55 hours with 3–4 agents in parallel (the last three features in this session were built in parallel this way) | sum of the per-area estimates below | +| Menu breadth | **482 / 512 Pro Tools menu leaves (94.1 %)**; engine alone 397 / 512 | measured (`cargo xtask parity` + UI-layer aliases) | +| Feature breadth | **~70 %** | estimated (menus plus plugins, hardware, formats, immersive, cloud) | +| **Ready for real work** | **~40 %** | estimated, weighted by dimension | +| Mainstream practitioner (tracking/mixing engineer) | **~36 %** | estimated: 50.6 % weekly-area depth × 0.90 interaction × 0.92 stability × 0.85 file exchange ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) | +| Essentials user (song or podcast, defaults) | **~57 %** | estimated: 72.4 % core-feature depth × 0.90 launch/stability × 0.90 discoverability × 0.97 files ([how](docs/target-app-parity.md#mainstream-practitioner-and-essentials-user)) | +| Remaining to alpha | **~110–180 Opus 5.5 agent-hours** (the failing gate rows) | estimated, calibrated on PR history | +| Remaining to beta | **~350–570 Opus 5.5 agent-hours** (includes the alpha work; ~60–70 % parallelizable) | estimated | +| Remaining to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated | -### Area by area +| Audience | Ready % | Agent-hours to ~95 % | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ~40 % | ~950–1,450 h | Hardware, the stock plugin catalogue, AAF/OMF and immersive, localization, fidelity | +| Mainstream practitioner | ~36 % | ~380–600 h | Interface and MIDI recording, AAF/OMF, stock-plugin depth, core-path bugs | +| Essentials user | ~57 % | ~110–200 h | Launch and platform fixes, onboarding, effect panels and presets, MP3/AAC export | -| Area | Status | Parity | Remaining (h) | -|---|---|---:|---:| -| Session model, save/load, undo | Tracks, playlists, clips, fades, clip gain, automation, markers, groups, busses, video; JSON `.scraft` + Audio Files folder; plugin state; autosave; copy-on-write undo with gesture coalescing | 85 % | 8 | -| Audio file I/O | WAV/BWF/RF64, AIFF/AIFC, FLAC (read/write); MP3, OGG, AAC, ALAC, CAF (read); peaks. Whole files are decoded into memory: disk streaming for very long sessions is still to do | 70 % | 12 | -| Editing | Slip/Shuffle/Spot/Grid, cut/copy/paste/clear/duplicate/repeat/shift, Paste Special (merge MIDI, markers, clip gain/effects), separate/heal/trim/consolidate/strip silence, fades & crossfades, nudge, playlists with comping, every tool gesture, edit groups, clip groups (and `.scgrp` import/export) | 75 % | 20 | -| Mixer & routing | Faders, pan, mute/solo (SIP, implicit solo), sends pre/post, busses, aux inputs, master faders, VCAs, routing folders, 10 inserts, plugin delay compensation, clip effects, preamp/instrument/object views | 75 % | 15 | -| Automation | Breakpoint lanes for volume/pan/mute/sends/plugin params, trim automation; live Write/Touch/Latch passes; write/thin/glide/convert/coalesce. Surround-pan automation is still to do | 60 % | 15 | -| Plugins (built-in) | 26 original processors + 2 instruments, AudioSuite offline processing for the whole AudioSuite menu | 45 % | 30 | -| Third-party plugins | CLAP, VST3 and Audio Units (macOS): audio, parameters, latency, notes, state saved in sessions, instances created/destroyed off the audio thread, editors (floating CLAP GUIs, VST3 views on macOS, verified with Melodyne). Missing: AU editors, embedded CLAP GUIs, VST3 editors on Windows/Linux, ARA | 80 % | 10 | -| MIDI & notation | MIDI/instrument tracks, SMF import/export, MIDI editor (piano roll, velocity, rubber-band select, keyboard transpose/move), event list, step input, quantize/transpose/velocity/duration, real-time properties, Score Editor, MusicXML export (Sibelius) and printable SVG score | 55 % | 20 | -| Recording | Input capture, punch in/out (selection, on the fly, pre/post-roll), loop record into playlists, input monitoring through the channel strip, autosave and recovery | 60 % | 10 | -| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 | -| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 | -| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 | -| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 | -| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 | -| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 | +Hours are Opus 5.5 agent wall-clock hours, calibrated on this repo's PRs; 60–70 % parallelizes. +Detail: [target-app-parity.md](docs/target-app-parity.md#readiness-by-audience). -## Alpha +**Why pre-alpha:** the core-workflow gate in [docs/roadmap.md](docs/roadmap.md#alpha-gate) fails. +Editing, mixing, bouncing, and saving and reopening work end to end. But a Pro Tools user can't +record from a chosen audio interface at a chosen buffer size (only the default devices, a fixed +512-sample block, and takes held in RAM). They can't record MIDI from a keyboard (there is no +MIDI device layer). And they can't bring a session in or hand one back (no AAF/OMF/`.ptx`). +Readiness (~40 %) sits right at the alpha bar, but a gate failure keeps the app pre-alpha. The +2026-10-07 estimate ("~65 % overall, 110–150 h to 100 %") counted feature presence. This one +measures readiness and adds hardware, localization and field bug reports. -An alpha is a build people can download, open the demo or their own audio in, record, edit, mix -and bounce without crashes or lost work. Feature-wise SoundCraft is past that bar; what is left is -shipping and hardening: +## By dimension -1. **Publish the repository and cut the first release** (needs a decision from the maintainers: - the GitHub repo, the release secrets, and a push to the `release` branch). The pipelines exist - but have never run end to end. ~2 h once unblocked, mostly fixing what the first run finds. -2. **Platform smoke tests** of the installers on Windows, Linux and FreeBSD (only macOS has been - exercised by hand). ~3–4 h. -3. **Disk streaming** of long sources, so an hour-long multitrack session does not need all of its - audio in RAM. ~5–6 h. -4. **A hands-on QA pass** in the live app: recording on real interfaces, long sessions, plugin-heavy - mixes, save/reopen loops. ~3–4 h. -5. **Docs**: a short user guide next to the existing agent/CLI docs. ~1 h. +| Dimension | Ready | Remaining (h) | Doc | +|---|---:|---:|---| +| Features | ~45 % | 520–810 | [target-app-parity.md](docs/target-app-parity.md#feature-areas) | +| UI/UX fidelity | ~55 % | 60–100 | [ui-parity.md](docs/ui-parity.md) | +| File formats | ~40 % | 70–120 | [file-format-parity.md](docs/file-format-parity.md) | +| Hardware (interfaces, MIDI, surfaces, sync) | ~12 % | 110–180 | [hardware-parity.md](docs/hardware-parity.md) | +| Localization | ~12 % (1 of Pro Tools' 8 languages) | 70–110 | [localization-parity.md](docs/localization-parity.md) | +| Performance | ~35 % (unmeasured) | 40–70 | [hardware-parity.md](docs/hardware-parity.md#performance) | +| Stability | ~45 % | 50–80 | [gaps.md](docs/gaps.md) | +| Platforms | ~65 % (5 platforms built, macOS exercised) | 20–40 | [gaps.md](docs/gaps.md) | +| Ecosystem / plugins | ~30 % | in Features + Hardware | [plugin-parity.md](docs/plugin-parity.md) | +| AI features | ~5 % | 30–50 | [gaps.md](docs/gaps.md) | +| Agent control | ahead of Pro Tools | 3 | [control-protocol.md](docs/control-protocol.md), [mcp.md](docs/mcp.md) | + +## Features + +| Area | Breadth | Ready | Remaining (h) | +|---|---:|---:|---:| +| Recording and monitoring | 75 % | 40 % | 40–60 | +| Editing | 92 % | 65 % | 40–60 | +| Mixing and routing | 85 % | 60 % | 35–50 | +| Automation | 80 % | 55 % | 20–30 | +| Built-in plugins and AudioSuite | 40 % | 35 % | 80–120 | +| Third-party plugin hosting | 70 % | 45 % | 40–60 | +| MIDI and instruments | 70 % | 30 % | 50–80 | +| Score and notation | 60 % | 35 % | 20–30 | +| Elastic Audio, TCE, Beat Detective | 70 % | 35 % | 40–60 | +| Surround and immersive | 55 % | 35 % | 50–80 | +| Video and post | 60 % | 40 % | 40–60 | +| Session, file management, interchange | 55 % | 35 % | 60–100 | +| Collaboration and cloud (Avid services) | 0 % | 0 % | owner decision | +| Agent control | 100 % | 100 % | 3 | + +Weights and evidence per row: [target-app-parity.md](docs/target-app-parity.md#feature-areas). + +## Languages + +| Language | Code | Status | Translated | +|---|---|---|---:| +| English | en | full | 100 % | +| Simplified Chinese | zh-Hans | none | 0 % | +| Spanish | es | none (PR #143 open) | 0 % | +| Hindi | hi | none | 0 % | +| Arabic | ar | none | 0 % | +| French | fr | none (PR #143 open) | 0 % | +| Portuguese | pt | none | 0 % | +| Indonesian | id | none | 0 % | +| Japanese | ja | none | 0 % | +| German | de | none | 0 % | +| Korean | ko | none | 0 % | +| Vietnamese | vi | none | 0 % | + +Other languages shipped: 0. Detail: [localization-parity.md](docs/localization-parity.md). + +## Upcoming + +Ranked; the alpha gate, the alpha checklist and the beta plan are in [docs/roadmap.md](docs/roadmap.md). + +1. **Alpha blocker:** audio interfaces: device and buffer-size choice, duplex stream, ASIO; record to disk (G-01, G-06). 35–55 h. +2. **Alpha blocker:** MIDI devices: record from keyboards, play instruments live, output (G-02). 20–35 h. +3. **Alpha blocker:** AAF/OMF import and export (G-03). 50–90 h. +4. macOS plugin entitlement (#153), then AU editors (G-05). 1 h for the entitlement (needed for the mix row), then 15–25 h. +5. Land the open bug-fix PRs and clear the correctness backlog (G-04). 15–25 h. +6. PR CI running `cargo xtask ci` on three OSes (G-14). 5–10 h. +7. Fidelity harness against Pro Tools (G-07). 20–30 h. ## Robustness @@ -57,22 +117,34 @@ command parameters through the engine and mixer; they have caught fade, overflow unbounded-allocation bugs. Every command also runs with empty parameters on empty and demo sessions. Hosted plugins live in isolated unsafe crates (`clap-host`, `vst3-host`, `au-host`) and are created, loaded and destroyed off the audio thread. The video decoders survive hundreds of -mutated and truncated movies. +mutated and truncated movies. Field reports from the first public week are tracked in +[gaps.md](docs/gaps.md) (G-04). -## Current focus +## Progress log -1. The alpha list above. -2. Surround-pan automation; AU editor views. -3. Built-in plugin depth (more processors, better UIs). -4. MIDI and score editing depth. +- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard + progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ + localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours, + tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session. +- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media + warnings, session audio health, malformed-JSON and encoder-extension checks in the CLI, macOS + system menu bar, Windows DX12 default, Linux fontconfig, metronome click in playback. +- **2026-10-08** — v0.2.x–v0.3.0 released (Flatpak, AppImage zsync); VST3 quit crash fixed; + Ctrl-as-Cmd shortcuts on Windows/Linux; plugin menus by vendor, AU inserts, instrument picker; + rotating log file; branded DMG. +- **2026-10-07** — Repository published; About window credits; release CI on macOS runners. +- **2026-10-06** — Video track, Audio Unit hosting, plugin state and editors, Renderer; Mix + window views; score export and print; surround mixing; VST3 hosting; property tests. +- **2026-10-05** — Foundation crates, Edit and Mix windows, playback, CLI/MCP, parity report, + recording and punch, CLAP hosting, Elastic Audio, Beat Detective, release pipeline. -## Milestones +## Revision history -- **M0–M4 (done):** workspace, gates, model, IO, DSP, mix engine, command engine, Edit/Mix UI, realtime playback, metering, plugins, bounce. -- **M5 (done):** editing depth (tools, modes, playlists, fades, comping, clip groups). -- **M6 (done for the first cut):** recording, punch, loop record, monitoring. -- **M7 (in progress):** MIDI and notation editing. -- **M8 (done):** CLI, control channel, MCP, agent acceptance test. -- **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective. -- **M10 (next):** alpha release on all platforms. -- **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround. +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h | +| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers | +| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added | +| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md | +| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list | +| 2026-10-06 | minor | Alpha and parity estimates | diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..69acb9c 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -351,7 +351,7 @@ fn mcp(args: &[String]) -> ExitCode { fn parity(args: &[String]) -> ExitCode { let p = soundcraft_engine::catalog::parity_json(); let mut md = String::new(); - md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`).\n"); + md.push_str("# Parity report\n\nGenerated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).\n"); md.push_str("It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.\n"); md.push_str("UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).\n\n"); md.push_str(&format!( diff --git a/docs/architecture.md b/docs/architecture.md index 3432699..02bc5a8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,14 @@ # Architecture +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate diagram brought up to date: video, vst3-host, au-host; device I/O limits) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. ``` -L0 time audio-io midi (standalone: no workspace deps) -L1 dsp clap-host (plugins; clap-host is the only unsafe crate) +L0 time audio-io midi video (standalone: no workspace deps) +L1 dsp clap-host vst3-host au-host (plugins; the three hosts are the only crates + allowed `unsafe`, confined to their FFI modules) L2 model (the session document) L3 mix (the mix engine) L4 engine playback (commands, undo, I/O · audio devices) @@ -41,6 +44,16 @@ process in parallel; plugin delay compensation aligns every path. The same engin bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns it on the audio thread and receives new session snapshots through a channel). +Device I/O today is the system's *default* output and *default* input device, opened as two +separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when +they differ. There is no device or buffer-size selection, no ASIO, and no MIDI device layer yet +(see [`hardware-parity.md`](hardware-parity.md)). Sources are decoded whole into the `SourcePool` +and recordings are captured in memory until stop. + +`soundcraft-video` holds our own H.264, ProRes and Motion JPEG decoders and the ISO-BMFF demuxer +for the video track. Hosted plugins (`clap-host`, `vst3-host`, `au-host`) are scanned, created and +destroyed off the audio thread; only `process` runs on it. + The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does, so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted @@ -52,3 +65,10 @@ by the UI thread (see README › Logs). The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection, synthetic input and screenshots; `soundcraft-cli mcp` wraps either a headless engine or a running app as an MCP server. See `control-protocol.md` and `mcp.md`. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Added video, vst3-host and au-host to the layer diagram; documented device I/O limits and in-memory media | +| 2026-10-05 | major | First architecture overview | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md new file mode 100644 index 0000000..5149502 --- /dev/null +++ b/docs/file-format-parity.md @@ -0,0 +1,83 @@ +# File-format parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format inventory against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types +its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its +documented import/bounce options. Ours comes from `crates/audio-io` (`detect_format`, `decode`, +`ENCODE_EXTENSIONS`), `crates/engine/src/cmd/file.rs`, `crates/engine/src/score.rs`, +`crates/engine/src/clip_group_file.rs` and `crates/video`. + +**File formats: ~40 % ready** (estimated: weighted by how often a Pro Tools user hits each row; +session interchange carries a third of the weight and is at 0 %). Remaining ≈ 70–120 h. + +Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not applicable. + +## Sessions and interchange + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| Pro Tools session `.ptx` (and `.ptf` from 7–9) | native | ❌ | ❌ | Proprietary, undocumented. Owner decision needed on black-box format study (G-03) | +| Session templates `.ptxt` | read/write | ❌ | ❌ | We have no templates at all (G-16) | +| AAF (Advanced Authoring Format) | import/export | ❌ | ❌ | #40. The post-production handoff from picture editors (G-03) | +| OMF | import/export | ❌ | ❌ | Legacy, still delivered by some editors | +| Media Composer–compatible session | export | ❌ | ❌ | AAF variant | +| ADM BWF (Dolby Atmos master) | import/export | ❌ | ❌ | G-12 | +| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded | +| Import Session Data (from another session) | Pro Tools sessions | 🟡 | — | From `.scraft` only | +| Session info as text | export | — | ✅ | `file.export_session_text` | +| Clip groups `.rgrp` / ours `.scgrp` | read/write | 🟡 | 🟡 | Our own `.scgrp` format; Pro Tools' clip-group files not read | +| Track presets, fade presets, I/O settings, workspace, sync settings | read/write | ❌ | ❌ | Their equivalents don't exist yet (G-16) | + +## Audio + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read | +| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` | +| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) | +| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native | +| FLAC | import | ✅ | ✅ | Own encoder | +| MP3 | import, bounce | ✅ (symphonia) | ❌ | G-21 | +| AAC / M4A / MP4 audio | import, bounce | ✅ (symphonia) | ❌ | G-21 | +| ALAC | import | ✅ | ❌ | | +| Ogg Vorbis | — | ✅ | ❌ | Beyond Pro Tools | +| CAF | — | ✅ | ❌ | | +| Sound Designer / SD II | import (legacy) | ❌ | — | Rare today | +| ReCycle / REX | import | ❌ | — | Loops | +| MXF audio (OP-Atom, OP1a) | import | ❌ | ❌ | Avid media; FilmCraft has an MXF reader to learn from | +| iXML / polyphonic field-recorder metadata | read | ❌ | ❌ | G-17 | +| Peak overviews | `.wfm` cache | ✅ (own, in memory) | — | `audio-io::peaks`; not cached on disk | + +## MIDI and notation + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| Standard MIDI File (type 0/1) | import/export | ✅ | 🟡 | Exported MIDI omits imported controllers and ignores the tracks filter (#87, #98; PR #125) | +| MusicXML (to Sibelius) | export | — | ✅ | `file.export_sibelius`, `score.rs` | +| Printable score | print | — | ✅ (SVG) | | + +## Video + +| Format | Pro Tools | Read | Write | Tests / notes | +|---|---|---|---|---| +| QuickTime / MP4, H.264 | import, bounce | ✅ | ❌ | Own pure-Rust decoder (Baseline/Main/High); mutated-movie fuzzing | +| ProRes (all flavours) | import | ✅ | ❌ | Own decoder | +| Motion JPEG | import | ✅ | ❌ | | +| HEVC, AV1 | import | ❌ | ❌ | G-17 | +| DNxHD / DNxHR, MXF video | import | ❌ | ❌ | G-17 | +| Bounce with video | export | — | ❌ | G-17 | + +## Plugin and preset files + +| Format | Pro Tools | Ours | +|---|---|---| +| AAX plug-ins | the only format it hosts | ❌ (cannot: Avid SDK licence, PACE signing) | +| VST3 / Audio Units / CLAP | — (AAX only) | ✅ hosted (ahead of Pro Tools) | +| Plug-in settings `.tfx` | read/write | ❌ (our own JSON presets per plugin) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options | diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 0000000..cf3ea5b --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,200 @@ +# Where SoundCraft falls short of Pro Tools + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha blockers marked after the core-workflow gate) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing +real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker** +fail the core-workflow gate in [`roadmap.md`](roadmap.md#alpha-gate) and keep SoundCraft pre-alpha. Numbers and weights live in +[`target-app-parity.md`](target-app-parity.md); the plan that orders these into milestones is +[`roadmap.md`](roadmap.md). Hours are Opus 5.5 agent-hours (calibration in target-app-parity.md). +Estimates marked "≈" are judgements; issue numbers are `storytold/soundcraft` GitHub issues. + +When you close a gap, delete its entry (or shrink it) in the same PR, and bump the timestamp. + +## Ranked gaps + +| # | Gap | Dimension | Doc | Est. (h) | +|---|---|---|---|---:| +| G-01 | **Alpha blocker.** No audio device, channel or buffer-size selection; no duplex low-latency path | Hardware | [hardware-parity.md](hardware-parity.md) | 25–40 | +| G-02 | **Alpha blocker.** No MIDI device input or output | Hardware / MIDI | [hardware-parity.md](hardware-parity.md) | 20–35 | +| G-03 | **Alpha blocker.** Cannot exchange sessions with Pro Tools (no AAF/OMF, no `.ptx`) | File formats | [file-format-parity.md](file-format-parity.md) | 50–90 | +| G-04 | Open correctness bugs in features counted as done (~25 issues) | Stability | this file | 25–40 | +| G-05 | (Entitlement part: alpha blocker for the mix row) Third-party VST3s blocked in the signed macOS build; AU editors missing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 15–25 | +| G-06 | (Record-to-disk part: alpha blocker for the tracking row) Sources decoded whole into RAM; recording into RAM | Performance | [hardware-parity.md](hardware-parity.md) | 15–25 | +| G-07 | Nothing is compared against Pro Tools' output (no fidelity harness) | Features | this file | 20–30 | +| G-08 | Built-in plugin catalogue thin and unverified | Features | [plugin-parity.md](plugin-parity.md) | 80–120 | +| G-09 | English only, no string catalog, no CJK/RTL rendering | Localization | [localization-parity.md](localization-parity.md) | 70–110 | +| G-10 | No control surfaces (HUI/MCU, EUCON) | Hardware | [hardware-parity.md](hardware-parity.md) | 40–70 | +| G-11 | No external sync (MTC, LTC, MIDI clock, video reference) | Hardware | [hardware-parity.md](hardware-parity.md) | 15–25 | +| G-12 | Immersive delivery: no Dolby Atmos/ADM BWF, MPEG-H, binaural | Features | [file-format-parity.md](file-format-parity.md) | 50–80 | +| G-13 | Sidechain inputs, ARA, out-of-process plugin sandboxing | Ecosystem | [plugin-parity.md](plugin-parity.md) | 30–50 | +| G-14 | Windows/Linux/Web exercised only lightly at runtime; no PR CI running the tests | Platforms / Stability | this file | 20–40 | +| G-15 | Elastic Audio depth: one algorithm, no Elastic Pitch | Features | [target-app-parity.md](target-app-parity.md) | 30–45 | +| G-16 | Session templates, track presets, workspace browser, batch rename | Features | [target-app-parity.md](target-app-parity.md) | 25–40 | +| G-17 | Video: HEVC/DNx decode, video export, external timecode | Features / Formats | [file-format-parity.md](file-format-parity.md) | 30–50 | +| G-18 | Speech-to-text transcription | AI | this file | 30–50 | +| G-19 | UI feel: generic plugin windows, shortcut collisions, pro-grade meters and scaling | UI/UX | [ui-parity.md](ui-parity.md) | 60–100 | +| G-20 | MIDI/instrument depth: MIDI learn, sampler, notation editing | Features | [target-app-parity.md](target-app-parity.md) | 40–60 | +| G-21 | Compressed export (MP3, AAC/M4A, Ogg/Opus) | File formats | [file-format-parity.md](file-format-parity.md) | 10–20 | +| G-22 | Avid cloud services (collaboration, Sketch, Splice, sign-in) | Ecosystem | — | not estimated (owner decision) | + +## Details + +### G-01. Audio interface support + +- **Missing:** choosing the output or input device, the device's channel count and routing to + physical channels, the buffer size, and the sample rate from the hardware side. Input and + output are separate cpal streams on the *default* devices (`crates/playback/src/lib.rs` + `open_device`, `record.rs`); the mix runs in fixed 512-sample blocks and resamples when rates + differ. `setup.playback_engine` stores `device`/`buffer_size` params, but playback ignores them. + No ASIO on Windows (WASAPI shared only), so latency there is high. +- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42 + (no input device selection, forced monitoring feedback with virtual routing). +- **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard. +- **Estimate:** ≈ 25–40 h (device enumeration and selection, buffer size, duplex stream with one + clock, ASIO feature build, per-channel I/O mapping into I/O Setup, latency reporting and + record-offset compensation). Needs hands-on hardware testing. + +### G-02. MIDI devices + +- **Missing:** all MIDI hardware I/O. No `midir`/CoreMIDI/WinMM/ALSA sequencer code exists; + `setup.midi_input_devices` and `setup.midi_studio` only store names. You cannot record from a + keyboard, play a hosted instrument live, drive external synths, or send MIDI clock/MTC. + `midi_editor.rs:288` notes audition "needs a live MIDI input path". +- **Impact:** MIDI production is limited to drawing notes and importing SMF. +- **Estimate:** ≈ 20–35 h (device layer, realtime input to armed tracks and instruments with + timestamps, MIDI record/merge/loop, thru, output tracks, clock out). + +### G-03. Session interchange with Pro Tools + +- **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media + Composer–compatible sessions. Our session format is `.scraft` JSON. +- **Impact:** the standard's main-format bar for beta. A Pro Tools user cannot bring a project in + or hand one back; post-production (picture editors deliver AAF) is blocked. +- **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn + from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether + black-box format study is in the clean-room rules; not included in the estimate. + +### G-04. Correctness bugs in "done" features + +Open on 2026-10-10 (several have PRs open): +editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132); +automation #104 (PR #134); mix/bounce #59 (PR #131), #103 (PR #133), #152; playback #101 (PR #129); +MIDI #87, #98 (PR #125); import #89 (PR #128); CLI #95, #96 (PR #126, #127); MCP #145 (PR #146); +web #63, #64, #136; Clip List #4 (PR #75); plugin view #6; shortcuts #7, #41; +launch #142; Linux #45, #107. +- **Impact:** each one is small, but together they say "presence is not parity": an outside + contributor found seven in one sitting (#116–#122). +- **Estimate:** ≈ 25–40 h at 0.5–1.5 h each, plus review of the open PRs. + +### G-05. Plugin hosting on macOS + +- **Missing:** the `com.apple.security.cs.disable-library-validation` entitlement in the signed + build, so hardened runtime refuses third-party VST3 bundles (#153, #149). AU editor views + (`crates/au-host/src/lib.rs` header: "Not hosted yet: Audio Unit editor views"). Embedded CLAP + GUIs; VST3 editors on Windows/Linux. +- **Estimate:** ≈ 15–25 h (entitlement ≈ 1 h; AU Cocoa views and VST3 HWND/X11 views most of it). + +### G-06. Memory-bound media + +- **Missing:** streaming sources from disk; recording to disk. Every source is decoded whole into + the `SourcePool`; takes accumulate in RAM until stop (PR #84 open). +- **Impact:** hour-long multitrack sessions and long takes exhaust memory; a crash during a long + take loses it. +- **Estimate:** ≈ 15–25 h. + +### G-07. Fidelity harness against Pro Tools + +- **Missing:** any measured comparison: null tests of bounces of the same material, fade curve + and pan-law checks, plugin frequency/dynamics responses, elastic-audio quality, automation + timing. Pro Tools is installed; it can be driven black-box with synthetic sessions (outputs kept + under `plan/`, never committed). +- **Estimate:** ≈ 20–30 h for the harness and the first 20 scored cases; turns many "≈" in + target-app-parity.md into measurements. + +### G-08. Built-in plugins + +See [`plugin-parity.md`](plugin-parity.md): 26 processors + 2 instruments against Pro Tools' +roughly 80 bundled processors and instruments; no fidelity checks; generic parameter UIs; EQ +parametric request #139. ≈ 80–120 h. + +### G-09. Localization + +See [`localization-parity.md`](localization-parity.md): no catalog, every string literal in code; +egui has no complex-script shaping or RTL; no CJK font fallback (PR #141 open). ≈ 70–110 h plus +native-speaker review. + +### G-10. Control surfaces + +None: no HUI or Mackie Control, no EUCON (Avid-proprietary, needs Avid's SDK: owner decision), +no generic MIDI controller mapping. Pro Tools users on S1/S3/S4/S6/Dock or HUI devices can't +switch. ≈ 40–70 h for HUI/MCU plus a mapping layer; hardware needed to verify. + +### G-11. Sync + +No MTC/LTC chase or generation, no MIDI Beat Clock (`setup.midi_beat_clock` stores a flag only), +no video reference or word clock awareness, no Satellite-style linking. ≈ 15–25 h after G-01/G-02. + +### G-12. Immersive delivery + +No Dolby Atmos renderer integration, ADM BWF import/export, re-renders, binaural monitoring, +MPEG-H (new in Pro Tools 2026.4), surround/object automation. ≈ 50–80 h; Dolby's renderer and +SDKs are licensed, so we'd write our own ADM and object renderer. + +### G-13. Plugin ecosystem depth + +No sidechain (key input) buses for any host; no ARA (Melodyne-style editing is a menu in Pro +Tools); plugins run in-process, so a crashing plugin can take the session down. ≈ 30–50 h. AAX +cannot be hosted (#109): Avid's AAX SDK licence and PACE signing; owner decision, likely never. + +### G-14. Platforms and CI + +Only `freebsd.yml` and `windows-arm64.yml` run on pull requests, and only for manifest paths; +nothing runs `cargo xtask ci` on PRs. Windows and Linux have startup and rendering reports +(#41, #45, #107, #142). ≈ 20–40 h (a PR CI matrix plus runtime smoke tests on three OSes). + +### G-15. Elastic Audio depth + +One warp algorithm; Pro Tools offers several per track (polyphonic, rhythmic, monophonic, +varispeed, high-quality offline) plus Elastic Pitch and warp-marker editing depth. ≈ 30–45 h. + +### G-16. Session management + +No session templates, track presets, workspace/sound browser with previews, batch rename (new in +2026.4), disk-usage and relink depth. ≈ 25–40 h. + +### G-17. Video and post + +No HEVC, DNxHD/HR or AV1 decode; no bounce with video; no external timecode/9-pin; no iXML +field-recorder conform. ≈ 30–50 h. + +### G-18. Speech-to-text + +Pro Tools transcribes dialogue to a searchable transcript (upgraded in 2026.4). We have a +settings command only. Needs an openly licensed local speech model. ≈ 30–50 h. + +### G-19. UI feel + +See [`ui-parity.md`](ui-parity.md). ≈ 60–100 h. + +### G-20. MIDI and instrument depth + +MIDI learn, a sampler (PR #108), drum machine depth, notation editing (tuplets, lyrics, +articulations, parts). ≈ 40–60 h. + +### G-21. Compressed export + +Bounce writes WAV/BWF/RF64, AIFF and FLAC; Pro Tools also writes MP3 and AAC/M4A in Bounce Mix. +≈ 10–20 h (pure-Rust encoders). + +### G-22. Avid cloud services + +Collaboration, cloud projects, Sketch, Splice, Learn, sign-in: 11 of the 30 menu leaves still +missing. Out of scope unless the owner decides otherwise. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate | +| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md new file mode 100644 index 0000000..2b3eadc --- /dev/null +++ b/docs/hardware-parity.md @@ -0,0 +1,75 @@ +# Hardware parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first hardware audit against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +A DAW is judged by how it drives audio interfaces, MIDI gear, control surfaces and sync. This is +SoundCraft's weakest dimension. Ours is measured from `crates/playback` (cpal) and the absence of +any MIDI-device or control-surface code; Pro Tools' from its documentation and Setup menus. + +**Hardware: ~12 % ready** (estimated). **Performance: ~35 %** (estimated; nothing benchmarked +against Pro Tools). Remaining ≈ 110–180 h for hardware, 40–70 h for performance. + +Legend: ✅ works · 🟡 partial · ❌ missing. + +## Audio interfaces + +| Feature | Pro Tools (macOS / Windows) | SoundCraft macOS | Windows | Linux | FreeBSD | Web | +|---|---|---|---|---|---|---| +| Driver API | Core Audio / ASIO | 🟡 Core Audio via cpal | 🟡 WASAPI shared via cpal; no ASIO | 🟡 ALSA/Pulse via cpal; no JACK | 🟡 cpal (ALSA layer) | 🟡 Web Audio | +| Choose playback device | ✅ Playback Engine | ❌ default output only | ❌ | ❌ | ❌ | ❌ | +| Choose input device | ✅ | ❌ default input only (#42) | ❌ | ❌ | ❌ | ❌ | +| Buffer size (32–2048) | ✅ | ❌ fixed 512-sample mix block | ❌ | ❌ | ❌ | ❌ | +| Duplex stream on one clock | ✅ | ❌ separate input/output streams | ❌ | ❌ | ❌ | ❌ | +| Hardware I/O mapping (I/O Setup to physical channels) | ✅ | 🟡 multichannel output in order; no per-channel map | 🟡 | 🟡 | 🟡 | ❌ | +| Low-latency monitoring | ✅ | ❌ (`options.low_latency_monitoring` is a stored flag) | ❌ | ❌ | ❌ | ❌ | +| Latency reporting and record offset compensation | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Sample rate follows the session; resample when it can't | ✅ (switches device) | 🟡 resamples (input pitch bug fixed, #28) | 🟡 | 🟡 | 🟡 | 🟡 | +| Avid HDX / HD Native DSP, Hybrid Engine DSP mode | ✅ | ❌ (out of reach: Avid hardware) | ❌ | ❌ | ❌ | ❌ | +| Disk allocation / record to disk | ✅ | ❌ recording into RAM (PR #84) | ❌ | ❌ | ❌ | ❌ | + +## MIDI + +| Feature | Pro Tools | SoundCraft (all platforms) | +|---|---|---| +| MIDI input devices (keyboards, pads) | ✅ | ❌ no MIDI device layer at all (`setup.midi_input_devices` stores names) | +| MIDI output to external gear | ✅ | ❌ | +| MIDI Thru, input filter | ✅ | ❌ (stored flags only) | +| MIDI Beat Clock out, MTC generate/chase | ✅ | ❌ | +| MIDI learn / controller mapping | ✅ (via surfaces and plug-in maps) | ❌ | +| Virtual MIDI keyboard | ✅ (MIDI Keyboard window) | 🟡 window exists, no live audition path | + +## Control surfaces + +| Feature | Pro Tools | SoundCraft | +|---|---|---| +| EUCON (S1/S3/S4/S6, Dock, Control app) | ✅ | ❌ Avid-proprietary SDK; owner decision | +| HUI | ✅ | ❌ | +| Mackie Control Universal | ✅ (via HUI emulation and personalities) | ❌ | +| Generic MIDI controller personalities | ✅ | ❌ | + +## Sync and video hardware + +| Feature | Pro Tools | SoundCraft | +|---|---|---| +| LTC / MTC chase, 9-pin machine control | ✅ (Ultimate) | ❌ | +| Word clock / video reference awareness | ✅ via interface | ❌ | +| Video output to a second display or video card | ✅ | ❌ (video window only) | +| Satellite linking of systems | ✅ | ❌ | + +## Performance + +| Feature | Pro Tools | SoundCraft | Evidence | +|---|---|---|---| +| Parallel mixing across cores | ✅ | ✅ | `soundcraft-mix` renders independent strips in parallel (rayon) | +| Plugin delay compensation | ✅ | ✅ | | +| Disk streaming of long sources | ✅ | ❌ | Whole files decoded into the `SourcePool` | +| Track count (Ultimate: 2,048 voices) | ✅ | unmeasured | No benchmark exists; add `cargo xtask bench` | +| Real-time audio thread discipline | ✅ | ✅ | No blocking/allocation in steady state, audio-thread-aware logger | +| Freeze / commit to save CPU | ✅ | 🟡 | `track.commit` renders; `track.freeze` only sets a flag (no CPU saved) | +| GPU-accelerated UI | — | ✅ (wgpu/glow via eframe) | llvmpipe X11 issue #107 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: audit of audio interface, MIDI, control-surface, sync and performance support against Pro Tools 2026.4.1 | diff --git a/docs/localization-parity.md b/docs/localization-parity.md new file mode 100644 index 0000000..fe832d4 --- /dev/null +++ b/docs/localization-parity.md @@ -0,0 +1,42 @@ +# Localization parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first per-language status) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +SoundCraft is English only. There is no string catalog: every menu label, dialog and tooltip is a +literal in Rust (`crates/engine/src/cmd/*`, `crates/ui-egui/src/*`). Pro Tools ships its UI in +**8 languages** (bundle `.lproj` folders: English, German, Spanish, French, Japanese, Korean, +Simplified Chinese, Traditional Chinese). + +**Localization: ~12 %** (measured: 1 of the target's 8 UI languages, and English is the only one +complete). Remaining ≈ 70–110 h: a catalog and extraction pass (≈ 15–25 h), a font fallback for +CJK/Devanagari/Arabic (PR #141 open), RTL layout and complex-script shaping (egui has neither: +≈ 25–40 h, or ship Arabic/Hindi menus-only), then ≈ 3–5 h of agent translation per language, plus +native-speaker review (human). + +Open work: PR #143 (French and Spanish, a Language setting), PR #141 (bundled multilingual font +fallbacks), issue #67 (Ukrainian). + +| Language | Code | UI strings translated | Dialogs / tooltips / help | Script support | Native review | Status | Est. to full (h) | In Pro Tools | +|---|---|---|---|---|---|---|---:|---| +| English | en | all (source) | ✅ | Latin ✅ | — | **full** | 0 | ✅ | +| Simplified Chinese | zh-Hans | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ | +| Spanish | es | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ | +| Hindi | hi | 0 (0 %) | ❌ | ❌ no Devanagari font or shaping | no | none | 8–15 | ❌ | +| Arabic | ar | 0 (0 %) | ❌ | ❌ no RTL layout, no shaping | no | none | 15–25 | ❌ | +| French | fr | 0 (0 %) — PR #143 pending | ❌ | Latin ✅ | no | none | 3–5 | ✅ | +| Portuguese | pt | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ | +| Indonesian | id | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ❌ | +| Japanese | ja | 0 (0 %) | ❌ | ❌ no CJK font fallback, no IME testing | no | none | 6–10 | ✅ | +| German | de | 0 (0 %) | ❌ | Latin ✅ | no | none | 3–5 | ✅ | +| Korean | ko | 0 (0 %) | ❌ | ❌ no Hangul font fallback | no | none | 6–10 | ✅ | +| Vietnamese | vi | 0 (0 %) | ❌ | 🟡 Latin with stacked diacritics, untested | no | none | 3–5 | ❌ | + +Other languages shipped: **0**. (Pro Tools also ships Traditional Chinese, zh-Hant.) + +Per-language hours exclude the shared catalog and script work above. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: English only, no catalog; the twelve key languages at 0 %; Pro Tools' 8 languages listed from its bundle | diff --git a/docs/parity.md b/docs/parity-checklist.md similarity index 94% rename from docs/parity.md rename to docs/parity-checklist.md index 5572f35..229b783 100644 --- a/docs/parity.md +++ b/docs/parity-checklist.md @@ -1,6 +1,8 @@ # Parity report -Generated by `soundcraft-cli parity --write docs/parity.md` (also `cargo xtask parity`). +> **Generated:** 2026-10-06 (last run of `cargo xtask parity`; the catalog and aliases are unchanged since) · **Target:** Avid Pro Tools Ultimate 2026.4.1 menu tree + +Generated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`). It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry. UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel). diff --git a/docs/plugin-parity.md b/docs/plugin-parity.md new file mode 100644 index 0000000..f59b598 --- /dev/null +++ b/docs/plugin-parity.md @@ -0,0 +1,61 @@ +# Plugin parity: built-in processors and plugin hosting + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first plugin checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins +cover the rest. This doc tracks both. Our plugins have our own names and are written from +scratch (clean-room rule): Avid plugin names are not used here, only categories. + +**Built-in plugins and AudioSuite: breadth ~40 %, ready ~35 %** (80–120 h). +**Third-party hosting: breadth ~70 %, ready ~45 %** (40–60 h). Both estimated. + +## Built-in processors (measured from `crates/dsp/src/plugins`) + +| Category | Pro Tools bundles (approx., by category) | SoundCraft (ids) | Gap | +|---|---|---|---| +| EQ | 1-band and 7-band EQ, channel strip, vintage EQ models | `eq_1band`, `eq_7band`, `channel_strip` | Vintage models, linear phase, dynamic EQ; parametric UI (#139) | +| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) | +| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor | +| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth | +| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation | +| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape | +| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction | +| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options | +| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth` | Sampler (PR #108 open), piano, organ, wavetable, content library | +| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | | + +AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in +`crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview, +handles, "clip by clip / entire selection" and multi-input modes beyond the basics; hosted +plugins can't be used offline as AudioSuite yet. + +Fidelity: **unmeasured.** Nothing compares a SoundCraft processor's frequency response, gain +reduction curve or reverb tail with the Pro Tools equivalent (G-07 in [`gaps.md`](gaps.md)). +User report #152: a saved instrument level does not reach the bounce. + +## Third-party plugin hosting + +| Feature | Pro Tools | SoundCraft | Notes | +|---|---|---|---| +| Plugin format | AAX only (Native and DSP) | CLAP, VST3, Audio Units (macOS) | Users' libraries mostly ship VST3/AU too. AAX (#109) needs Avid's SDK licence and PACE signing: owner decision, likely never | +| VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable | +| Scan, vendor menus, instrument picker | ✅ | ✅ | #23 | +| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | | +| Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | +| State saved in sessions, presets | ✅ | ✅ | | +| Create/destroy off the audio thread | ✅ | ✅ | | +| Editors: CLAP | — | 🟡 floating windows; no embedded GUI | | +| Editors: VST3 | — | 🟡 macOS only (verified with a commercial pitch editor) | Windows/Linux views missing | +| Editors: AU | — | ❌ | Cocoa view hosting missing | +| Multiple editor pages | ✅ | 🟡 | #6 | +| macOS hardened-runtime entitlement for third-party code | ✅ | ❌ | #153, #149 — signed builds refuse third-party VST3 | +| Sidechain / key input | ✅ | ❌ | All three hosts | +| ARA 2 (pitch/time editors in the timeline) | ✅ | ❌ | Menu leaves Track/Clip/Window ▸ ARA still missing | +| Crash isolation | in-process (DSP on HDX) | in-process | Out-of-process hosting would beat Pro Tools | +| Plugin delay reporting changes on the fly | ✅ | 🟡 | Aux insert latency bug #103 (PR #133) | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: built-in processor inventory by category and third-party hosting checklist | diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..d8ea3f8 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,90 @@ +# SoundCraft roadmap: milestones and what's next + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha gate added; stage re-normalized to pre-alpha) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and +[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md). +Hours are Opus 5.5 agent-hours. + +## Current focus + +1. **Merge or close the open bug-fix PRs** (#123–#134, #146, #148, #75) and fix the rest of the + correctness backlog (G-04). ≈ 15–25 h. +2. **Audio interface basics** (G-01): device selection, buffer size, duplex stream, ASIO. ≈ 25–40 h. +3. **MIDI devices** (G-02): input to armed tracks and instruments, output, thru, clock. ≈ 20–35 h. +4. **macOS plugin hosting** (G-05): library-validation entitlement now (#153), then AU editors. ≈ 15–25 h. +5. **A PR CI that runs `cargo xtask ci`** on Linux, macOS and Windows (G-14). ≈ 5–10 h. + +## Alpha gate + +The core workflows a Pro Tools professional does every day, checked end to end on macOS (the +main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist +(craftrules `standards/progress-docs.md`, Stages). + +| Workflow | Works end to end? | Evidence | Hours to pass | +|---|---|---|---:| +| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 | +| Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 | +| Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — | +| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 26 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) | +| Bounce mix and stems; save and reopen the session | yes (rough) | WAV/BWF/AIFF/FLAC bounce, stems, `.scraft` save/reopen with plugin state; bugs #59, #103 in bus bounce and aux latency (PRs open) | — | +| Exchange a session with Pro Tools or a picture editor | **no** | No `.ptx`, AAF or OMF import or export (#40, G-03) | 50–90 | + +**Result: pre-alpha.** Three workflows fail. Passing them is ~110–180 h, about 70 % of it +parallelizable (interfaces, MIDI and AAF are independent crates). + +## Milestones + +Status: ✅ done · 🟡 in progress · ⬜ not started. + +| # | Milestone | Status | Remaining (h) | +|---|---|---|---:| +| M0–M4 | Workspace, gates, model, I/O, DSP, mix engine, command engine, Edit/Mix UI, playback, metering, plugins, bounce | ✅ | — | +| M5 | Editing depth (tools, modes, playlists, fades, comping, clip groups) | ✅ (bugs open, G-04) | in G-04 | +| M6 | Recording, punch, loop record, monitoring | 🟡 first cut; device choice and record-to-disk missing | 40–60 | +| M7 | MIDI and notation editing | 🟡 | 50–80 | +| M8 | CLI, control channel, MCP, agent acceptance test | ✅ | 3 | +| M9 | AudioSuite breadth ✅, Elastic Audio 🟡, Beat Detective 🟡 | 🟡 | 40–60 | +| M10 | Public releases on all platforms | ✅ v0.3.0 (2026-10-08) and v0.4.0 (2026-10-10) released for every platform | — | +| M11 | Third-party plugin hosting (CLAP, VST3, AU), video, surround | 🟡 mostly done | 40–60 | +| M12 | **Hardware**: audio interfaces, MIDI devices, sync, control surfaces | ⬜ | 110–180 | +| M13 | **Interchange**: AAF/OMF import/export, disk streaming, record to disk | ⬜ | 65–115 | +| M14 | **Fidelity harness** against Pro Tools (null tests, plugin responses) | ⬜ | 20–30 | +| M15 | **Localization**: catalog, fonts/RTL, the 8 Pro Tools languages first | ⬜ (PRs #141, #143 open) | 70–110 | +| M16 | Built-in plugin depth, sidechain, ARA | ⬜ | 110–170 | +| M17 | Immersive delivery (ADM BWF, object renderer, binaural) and video depth | ⬜ | 80–130 | + +## To beta (~350–570 h, about 60–70 % parallelizable) + +Beta needs ~75 % ready for real work and no blocking gap in session interchange. + +| Step | Gaps | Est. (h) | +|---|---|---:| +| Correctness backlog and a PR CI | G-04, G-14 | 40–65 | +| Audio interfaces, MIDI devices, sync | G-01, G-02, G-11 | 60–100 | +| AAF/OMF interchange, disk streaming, record to disk | G-03, G-06 | 65–115 | +| Plugin hosting completeness on every OS, sidechain | G-05, G-13 | 35–60 | +| Fidelity harness and the fixes it finds | G-07 | 40–60 | +| Built-in plugin essentials (EQ/dynamics/reverb/delay depth, sampler) | G-08 (half) | 40–60 | +| UI feel pass: shortcuts, plugin windows, meters | G-19 (half) | 30–50 | +| Localization of the 8 Pro Tools languages (catalog, CJK fonts) | G-09 (part) | 40–60 | + +Needs a human: hardware for G-01/G-02/G-10/G-11, native-speaker review, and owner decisions on +AAX, `.ptx` and Avid cloud features. + +## After beta (to full parity, a further ~600–900 h) + +Control surfaces (G-10), immersive delivery (G-12), the rest of the built-in plugin catalogue +(G-08), Elastic Audio depth (G-15), session templates and browser (G-16), video depth (G-17), +speech-to-text (G-18), notation and instrument depth (G-20), compressed export (G-21), and the +remaining languages. + +There is no `cargo xtask scorecard` in this repo; the measured number is `cargo xtask parity` +([`parity-checklist.md`](parity-checklist.md)). + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) | +| 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md new file mode 100644 index 0000000..77d7be9 --- /dev/null +++ b/docs/target-app-parity.md @@ -0,0 +1,252 @@ +# Target-app parity: SoundCraft vs Avid Pro Tools + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95 % for each audience; full number confirmed as an additive weighted sum) · **Target:** Avid Pro Tools Ultimate 2026.4.1 (bundle 26.4.1.179, macOS) + +The authoritative assessment of how close SoundCraft is to Pro Tools. [`ROADMAP.md`](../ROADMAP.md) +summarizes it, [`gaps.md`](gaps.md) itemizes every shortfall, and the per-dimension docs go deeper: +[`ui-parity.md`](ui-parity.md), [`file-format-parity.md`](file-format-parity.md), +[`hardware-parity.md`](hardware-parity.md), [`plugin-parity.md`](plugin-parity.md), +[`localization-parity.md`](localization-parity.md). The generated menu checklist is +[`parity-checklist.md`](parity-checklist.md). + +## Headline + +| Number | Value | Kind | +|---|---|---| +| Menu breadth (engine + UI layer) | **482 / 512 menu leaves (94.1 %)** | **measured**: the 512-leaf catalog `crates/engine/catalog/menus.txt` against the command registry; engine alone 397 / 512 (77.5 %) from `cargo xtask parity` ([`parity-checklist.md`](parity-checklist.md)); the 85 UI-layer matches re-counted on 2026-10-10 from `crates/ui-egui/src/menus.rs` (`UI_COMMANDS`, `AUDIOSUITE`, `MENU_WINDOWS`, Mix Window Views) by script | +| Feature breadth (menus plus everything not in a menu: plugins, hardware, formats, immersive, cloud) | **~70 %** | estimated (area table below, presence only) | +| **Ready for real work** (whole of Pro Tools) | **~40 %** | estimated: written dimension weights × values = 40.6 % ([By dimension](#by-dimension)) | +| **Mainstream practitioner** (a working tracking/mixing engineer) | **~36 %** | estimated: written area weights and discounts ([below](#mainstream-practitioner-and-essentials-user)) | +| **Essentials user** (making a song or podcast with the core features) | **~57 %** | estimated: written feature weights and discounts ([below](#mainstream-practitioner-and-essentials-user)) | +| Stage | **pre-alpha** (core-workflow gate fails) | see [Stage](#stage) | +| Remaining effort to alpha | **~110–180 Opus 5.5 agent-hours** | estimated: the failing gate rows | +| Remaining effort to beta | **~350–570 Opus 5.5 agent-hours** | estimated, calibrated (below) | +| Remaining effort to full parity | **~950–1,450 Opus 5.5 agent-hours** | estimated, calibrated (below) | + +### Readiness by audience + +| Audience | Ready % | Opus 5.5 agent wall-clock hours to ~95 % | Work that dominates | +|---|---:|---:|---| +| Full target (ready for real work) | ~40 % | ~950–1,450 h (~60–70 % parallelizable) | Hardware (interfaces, MIDI, surfaces, sync), the built-in plugin catalogue, AAF/OMF and immersive delivery, localization, fidelity against Pro Tools | +| Mainstream practitioner | ~36 % | ~380–600 h (~60 % parallelizable) | Interface recording and MIDI devices, AAF/OMF exchange, stock-plugin depth, the core-path bug backlog, interaction feel | +| Essentials user | ~57 % | ~110–200 h (~70 % parallelizable) | Launch and platform fixes, an onboarding and user guide, stock-effect panels and presets, MP3/AAC export, editing and transport bugs | + +Each audience's hours are for its own areas only, so essentials ⊂ mainstream ⊂ full. Calibration +is the same as for the full estimate ([Effort and calibration](#effort-and-calibration)): bug fixes +at 0.5–1.5 h each, features at about 200–300 tested lines per agent-hour, from the public-week PRs. +- **Mainstream sum:** recording 40–60, MIDI 50–80, editing 40–60, mixing 35–50, automation 20–30, + stock plugins 80–120, AAF/OMF 50–90, compressed export 10–20, bug backlog 25–40, interaction + feel 30–50. +- **Essentials sum:** default-device recording 10–15, transport 5–10, editing polish 15–25, effect + panels and presets 20–30, MP3/AAC export 10–20, built-in instrument 10–20, launch and platform + 15–25, onboarding and user guide 15–25, save fixes 5–10. + +The previous figure in ROADMAP.md (2026-10-07: "~65 % overall feature parity", "110–150 h to 100 %") +measured feature *presence*. This pass measures readiness, and found new evidence that pulls the +numbers down: 37 open GitHub issues from the first public week (about 25 of them correctness bugs +in features counted as done), no MIDI hardware input or output at all, no audio device or buffer +size selection, no localization, and a macOS release that cannot load signed third-party VST3s +(#153). The estimates did not drift up; the scope of what is measured widened. + +## How this was measured + +- **The target.** `/Applications/Pro Tools.app` on the owner's Mac: Info.plist reports version + 26.4.1.179 (Pro Tools Ultimate 2026.4.1). Only bundle metadata was read (version, + `CFBundleDocumentTypes` for the file types it opens, the `.lproj` folder names for its UI + languages: en, de, es, fr, ja, ko, zh-Hans, zh-Hant). Nothing inside the bundle's resources, + plug-ins or code was read or copied (clean-room rule, AGENTS.md). +- **The menu tree.** `plan/protools/menus-clean.txt` (observed black-box, local only) became the + committed 512-leaf catalog; `cargo xtask parity` and the UI layer's `engine.parity` score it. +- **Avid's own documentation.** *What's New in Pro Tools 2026.4* (Track Pin, MPEG-H renderer, + Dolby headphone personalization, speech-to-text upgrades, batch file rename) and earlier + release notes for features outside menus (Dolby Atmos renderer integration, ADM BWF, Sketch, + cloud collaboration, Splice, ARA, HEAT, control surfaces, I/O setup). +- **The code on origin/main (3bc2d92, 2026-10-10).** Read and counted with grep: ~70,900 lines of + Rust; 524 `#[test]` functions plus proptest suites; ~350 macro-registered engine command ids; + 26 built-in processors and 2 instruments in `crates/dsp/src/plugins`; audio I/O in + `crates/audio-io`; device I/O in `crates/playback` (cpal default output and default input only); + no MIDI device crate (no `midir`/CoreMIDI anywhere); no string catalog. +- **Users.** The 37 open and 21 closed GitHub issues, and the 42 merged and 29 open pull + requests up to 2026-10-10, are the only field evidence. Nothing has been benchmarked against + Pro Tools. +- **Not done:** running Pro Tools side by side on the same material (null tests of mixes, plugin + responses, fades, elastic audio). That is gap G-01 in [`gaps.md`](gaps.md). + +## By dimension + +Weights are how much each dimension decides whether a Pro Tools user (music production, +post-production, tracking) can switch. "Ready" is depth, correctness and fidelity, not presence. + +| Dimension | Weight | Ready | Kind | Remaining (h) | Doc | Evidence | +|---|---:|---:|---|---:|---|---| +| Features | 45 % | **~45 %** | estimated | 520–810 | [area table](#feature-areas) | Menu breadth 94 % measured, but depth is thin: see the area table | +| UI/UX fidelity | 10 % | **~55 %** | estimated | 60–100 | [ui-parity.md](ui-parity.md) | Edit/Mix windows, every menu, floating windows; open UX bugs #3 #5 #6 #7 #140 #144; shortcuts only recently right on Windows/Linux (#15, #41); generic plugin UIs | +| File formats | 10 % | **~40 %** | estimated | 70–120 | [file-format-parity.md](file-format-parity.md) | WAV/BWF/RF64/AIFF/FLAC read+write, compressed formats read; no `.ptx`, AAF, OMF, ADM BWF, MP3/AAC export, video export | +| Hardware | 10 % | **~12 %** | estimated | 110–180 | [hardware-parity.md](hardware-parity.md) | Default output and default input device only, fixed 512-sample block, no ASIO, no MIDI devices, no control surfaces, no sync | +| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), X11 llvmpipe failure (#107) | +| Performance | 5 % | **~35 %** | estimated (unmeasured) | 40–70 | [hardware-parity.md](hardware-parity.md) | Parallel strip rendering and PDC exist; whole files decoded into RAM, recording into RAM (#84 open), no track-count or latency benchmarks | +| Ecosystem / plugins | 4 % | **~30 %** | estimated | in Features + Hardware | [plugin-parity.md](plugin-parity.md) | CLAP/VST3/AU hosting; no AAX (Pro Tools' only format), no ARA, no sidechain, no control-surface ecosystem, no content library | +| Platforms | 3 % | **~65 %** | estimated | 20–40 | [ROADMAP.md](../ROADMAP.md) | Builds for macOS, Windows x64/x86/arm64, Linux, FreeBSD, Web (Pro Tools: macOS, Windows), but only macOS is exercised at runtime; Web lacks file pickers (#63, #64) | +| Localization | 2 % | **~12 %** | measured (catalog count) | 70–110 | [localization-parity.md](localization-parity.md) | English only, no string catalog; Pro Tools ships 8 UI languages; FR/ES PR #143 open | +| AI features | 1 % | **~5 %** | estimated | 30–50 | [gaps.md](gaps.md) | Pro Tools has speech-to-text transcription (upgraded in 2026.4); we have only an audio-to-MIDI pitch tracker | +| **Weighted** | 100 % | **~40 %** | | **~950–1,450** (overlaps removed) | | | + +Agent control is not weighted (Pro Tools has only its scripting SDK); SoundCraft is ahead there: +every command is reachable from the CLI, the JSON control channel and MCP. + +## Mainstream practitioner and essentials user + +Both use the method in craftrules `standards/progress-docs.md`. Each multiplies a weighted depth +average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and +the evidence there; issue numbers are `storytold/soundcraft` GitHub issues. + +### Mainstream practitioner: ~36 % + +The typical Pro Tools professional is a music or post engineer who tracks through an audio +interface, overdubs MIDI, edits, mixes with the stock plugins and bounces every week. This number +leaves out third-party plugin hosting, Avid cloud services and AI, control surfaces and sync, +immersive delivery, video, notation, and languages other than English. + +| Area (weekly use) | Weight | Depth | +|---|---:|---:| +| Recording and monitoring through an interface | 20 % | 40 % | +| Editing (trim, fades, comping, Slip/Grid, nudge) | 22 % | 65 % | +| Mixing and routing (faders, sends, busses, inserts, PDC) | 18 % | 60 % | +| Automation | 10 % | 55 % | +| Built-in plugins and AudioSuite | 12 % | 35 % | +| MIDI recording, editing, instruments | 10 % | 30 % | +| Bounce, save and reopen | 8 % | 60 % | +| **Weighted depth** | 100 % | **50.6 %** | + +| Discount | Factor | Evidence | +|---|---:|---| +| Interaction fidelity | ×0.90 | Shortcut collisions and dead shortcuts (#140 Heal, #144 Mix/Edit vs zoom, #7 Export); Multitool handles don't adjust audio (#5); generic plugin parameter panels (#138, #139); clip deletion inconsistencies (#3, #4) | +| Stability on real machines | ×0.92 | 26 open core-path bugs, e.g. bounce and latency (#59, #103), playback speed (#101), session saves (#86, #91), plus a launch failure (#142). Never-crash rules and property tests hold up: no crash reports on macOS since #16 | +| File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower | +| **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | | + +This comes out *below* the full number (~40 %), which the standard says is unusual. The +mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the +alpha-gate failures. The full number gets partial credit from areas this user doesn't weight: +editing breadth, five platforms built, agent control. + +**User evidence** (GitHub, 2026-10-10; all 69 issues are from 28 outside reporters, none from +maintainers): +- **Praise:** 0 threads, and no "switched from Pro Tools" reports. +- **Open issues:** of 48 open, 26 are core-path bugs (editing, bounce, playback, sessions, + shortcuts, plugin loading, recording monitoring), 7 are niche-path bugs (web build, CLI/MCP, + Linux fonts and llvmpipe), and 15 are requests. +- **Requests:** several ask for other DAWs' feel: Ableton shortcuts and layout (#71, #72), an + FL/Ableton port (#38), Audition-style file editing (#81, #82). AAX (#109), VST2/LV2 (#30), + Ukrainian (#67). +- **Closed:** 21. +- **Contributions:** 12 outside contributors have merged PRs in the first week. That's + engagement, not yet use on real projects. + +### Essentials user: ~57 % + +A user who needs only the essentials: makes a song, demo or podcast, records through the computer's default +microphone or interface, uses default settings, and exports a file to share. + +| Core feature | Weight | Depth | Notes | +|---|---:|---:|---| +| New session, open a session, demo | 10 % | 80 % | | +| Import audio (drag and drop, WAV/MP3/AAC/FLAC) | 10 % | 80 % | Reads every common format | +| Record from the default device | 12 % | 65 % | Works on the default input; forced monitoring can feed back (#42) | +| Play, stop, loop, scroll, zoom | 10 % | 75 % | Half-speed after Stop (#101) | +| Basic editing: select, cut/copy/paste, split, trim, move, fades | 18 % | 70 % | #3, #5 | +| Undo/redo | 5 % | 90 % | Every command undoable | +| Volume, pan, mute, solo | 10 % | 85 % | | +| Add a stock effect (EQ, compressor, reverb) and pick a preset | 10 % | 60 % | Generic panels, few presets | +| Draw MIDI notes for a built-in synth | 5 % | 50 % | Two built-in instruments | +| Save and reopen | 5 % | 80 % | Web build can drop unsaved edits (#136) | +| Export / bounce a file to share | 5 % | 60 % | WAV/AIFF/FLAC only, no MP3/AAC | +| **Weighted depth** | 100 % | **72.4 %** | | + +| Discount | Factor | Evidence | +|---|---:|---| +| Launch and stability | ×0.90 | Doesn't launch for one user (#142), X11 llvmpipe failure (#107), Linux symbols as boxes (#45), Windows shortcuts (#41); macOS is solid | +| Discoverability and UI clarity | ×0.90 | Pro Tools' dense layout with no onboarding or user guide; shortcut collisions; plugin windows are bare sliders | +| Opening files people send them | ×0.97 | These users get WAV/MP3, which work; only Pro Tools sessions fail | +| **Result** | 72.4 × 0.90 × 0.90 × 0.97 = **56.9 % ≈ 57 %** | | + +## Feature areas + +Weights by what Pro Tools users actually spend time on. **Breadth** = the feature exists; +**Ready** = it works deeply and correctly enough for paid work. + +| Area | Weight | Breadth | Ready | Remaining (h) | What's there / what's missing | +|---|---:|---:|---:|---:|---| +| Recording and monitoring | 12 % | 75 % | **40 %** | 40–60 | Punch in/out, QuickPunch-style punch on the fly, pre/post-roll, loop record into playlists, input monitoring through the strip, autosave/recovery. Missing: input/output device choice, buffer size, duplex low-latency path, record straight to disk (#84 open), per-channel hardware input routing, TrackPunch/destructive punch beyond flags, monitoring opt-out (#42) | +| Editing | 15 % | 92 % | **65 %** | 40–60 | Slip/Shuffle/Spot/Grid, every Edit menu command, fades/crossfades, playlists and comping, clip groups, nudge, Tab to transient, separate/heal/consolidate/strip silence. Open bugs: #3, #5 (Multitool handles), #140 (Heal shortcut), #86, #91, #92; no batch fade presets, no field-recorder workflows | +| Mixing and routing | 12 % | 85 % | **60 %** | 35–50 | Faders, pan, sends, busses, aux, masters, VCAs, folders, 10 inserts, PDC, solo modes, I/O Setup. Missing: sidechain inputs, hardware-channel I/O mapping, bus/AFL/PFL paths verified, HEAT; bugs #59, #103 | +| Automation | 7 % | 80 % | **55 %** | 20–30 | Lanes for volume/pan/mute/sends/plugin params, trim, Write/Touch/Latch, glide/thin/coalesce. Missing: surround-pan and object automation, Touch/Latch prime and preview modes, automation on control surfaces; bug #104 | +| Built-in plugins and AudioSuite | 8 % | 40 % | **35 %** | 80–120 | 26 processors + 2 instruments covering every AudioSuite category; Pro Tools bundles roughly three times as many plugins plus virtual instruments. No fidelity comparison, generic parameter UIs (#139). See [plugin-parity.md](plugin-parity.md) | +| Third-party plugin hosting | 8 % | 70 % | **45 %** | 40–60 | CLAP, VST3, AU: audio, params, latency, notes, state, presets, off-thread lifecycle, CLAP floating GUIs, VST3 editors on macOS. Missing: AU editors, VST3 editors on Windows/Linux, sidechain buses, ARA, out-of-process sandboxing, macOS library-validation entitlement (#153, #149). AAX cannot be hosted (Avid SDK licence, PACE signing) | +| MIDI and instruments | 8 % | 70 % | **30 %** | 50–80 | Piano roll, event list, step input, quantize/transpose, real-time properties, SMF import/export. Missing: **any MIDI device input/output** (no keyboard recording, no external synths, no MIDI clock/MTC out), MIDI learn, instrument depth (Sampler PR #108 open), bug #98 | +| Score and notation | 2 % | 60 % | **35 %** | 20–30 | Grand-staff Score Editor, MusicXML export, printable SVG. Missing: notation editing depth (tuplets, articulations, lyrics, parts layout) | +| Elastic Audio, TCE, Beat Detective, pitch | 5 % | 70 % | **35 %** | 40–60 | Pitch-preserving warp on Elastic tracks, TCE, conform to tempo, Beat Detective and Identify Beat, audio-to-MIDI. Missing: per-algorithm choice (polyphonic/rhythmic/monophonic/varispeed/X-Form), warp markers UI depth, Elastic Pitch, quality vs Pro Tools unmeasured | +| Surround and immersive | 5 % | 55 % | **35 %** | 50–80 | Formats stereo → 9.1.6 and Ambisonics, surround panner, fold-down, multichannel bounce, object/bed routing. Missing: Dolby Atmos renderer integration and ADM BWF, MPEG-H (new in 2026.4), binaural/headphone monitoring, surround automation; bug #89 | +| Video and post | 5 % | 60 % | **40 %** | 40–60 | Video track, H.264/ProRes/MJPEG decoders, sync offset, relinking, timecode rulers. Missing: HEVC/DNx/AV1, video bounce/export, timecode sync to external machines, speech-to-text transcript, field-recorder/iXML conform | +| Session, file management, interchange | 10 % | 55 % | **35 %** | 60–100 | `.scraft` + Audio Files, Save Copy In, Import Session Data, clip groups, session text export, audio-health report. Missing: `.ptx` read/write, AAF/OMF (#40), session templates, track presets, workspace/Soundbase browser, disk streaming of long sources, batch rename; bugs #86, #91, #136 | +| Collaboration, cloud, Sketch, Splice | 2 % | 0 % | **0 %** | not estimated | Avid services; out of scope unless the owner decides otherwise | +| Agent control (CLI, control channel, MCP) | 1 % | 100 % | **100 %** | 3 | Ahead of Pro Tools' scripting SDK; one open bug (#145) | +| **Weighted** | 100 % | **~70 %** | **~45 %** | **~520–810** | | + +## Stage + +**Pre-alpha.** Readiness (~40 %) is at the alpha bar, but the core-workflow gate +([`roadmap.md`](roadmap.md#alpha-gate)) fails three of six Pro Tools workflows on macOS, the main +platform: + +- Tracking from a chosen interface at a chosen buffer size: **no**. Only the default devices are + used, the mix runs in fixed 512-sample blocks, input and output are separate streams, and + takes are held in RAM. +- MIDI recording: **no**. There is no MIDI device layer. +- Session interchange with Pro Tools or picture editors: **no**. No AAF/OMF/`.ptx`. + +Editing, mixing with plugins, bouncing, and saving and reopening pass (with known bugs). + +**Distance to alpha: ~5 points of readiness and ~110–180 agent-hours** (G-01, G-02, G-03, part +of G-06, and the #153 entitlement). **Distance to beta: ~35 points and ~350–570 agent-hours** +(beta also needs ~75 % ready). The beta plan is in [`roadmap.md`](roadmap.md). + +## Effort and calibration + +Hours are **Opus 5.5 agent wall-clock hours**: one agent session working sequentially, including +tests and verification. + +**Calibrated against this repo's history** (`git log`, `gh pr list`): + +- The 10-05 bulk commits (`first commit` through `Video track, Audio Unit hosting…`, ~70k lines) + carry batch-commit times, not authoring times, so they don't calibrate. +- The public week (2026-10-07 → 10-10, 42 merged PRs) does. Focused bug fixes with a regression + test, +20 to +120 lines (#116–#122, #25, #28, #37, #61), took **~0.5–1.5 h** each. Small + features of +200 to +1,000 lines (#32 light theme +516, #11 log file +981, #8 credits +666, #23 + plugin menus +259, #76 clip-gain fader +198) took **~2–5 h**: about **200–300 tested lines per + agent-hour**. +- Areas were sized by the code they need at that rate (for example, MIDI device I/O plus MIDI + recording ≈ 3–5k lines ≈ 12–25 h), plus a 1.3–1.5× tail factor for verification against Pro + Tools behaviour, as FilmCraft observed for its long tail. + +**Parallelism.** About 60–70 % of the work parallelizes across 4–5 agents by crate (dsp plugins, +hardware/playback, file interchange, UI, localization). The audio-thread and hardware work +serializes more, because it needs hands-on testing. + +**Needs a human:** +- Owner decisions: AAX (requires Avid's SDK licence and PACE signing; probably never), `.ptx` + reverse engineering (clean-room implications), Avid cloud features (out of scope by default). +- Hardware we don't have in CI: multichannel interfaces, ASIO drivers, control surfaces (HUI/MCU, + EUCON is Avid-proprietary), MIDI keyboards, sync generators. +- Native-speaker review for every language beyond English. +- Ears: a mix engineer's A/B of plugins, fades and elastic audio. + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | minor | Readiness-by-audience table: hours to ~95 % for full (950–1,450 h), mainstream (380–600 h) and essentials (110–200 h). The full number is already the additive weighted sum over the written dimension table (40.6 %); method unchanged | +| 2026-10-10 | minor | Added the mainstream-practitioner (~36 %) and essentials-user (~57 %) numbers with written weights, discounts and user evidence. Checked the full ~40 %: it is the written dimension weights × values (40.6 %), so it doesn't move | +| 2026-10-10 | minor | Stage alpha → pre-alpha under the core-workflow gate; alpha distance ~110–180 h | +| 2026-10-10 | major | Full re-measure against Pro Tools Ultimate 2026.4.1: two numbers (breadth ~70 %, ready ~40 %), dimension and area tables with hours, stage alpha; hours re-calibrated from PR history (110–150 h → 950–1,450 h to full parity: the old figure counted presence only) | +| 2026-10-07 | major | Area table and "~65 % overall, 110–150 h" estimate (then in ROADMAP.md) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md new file mode 100644 index 0000000..80ff25b --- /dev/null +++ b/docs/ui-parity.md @@ -0,0 +1,67 @@ +# UI parity + +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 + +How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, +windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command +registry (shortcuts), `crates/ui-egui/src` (windows, gestures) and user issues; compared with +reference screenshots kept locally under `plan/protools/screenshots/` (16, never committed). + +**UI/UX fidelity: ~55 % ready** (estimated). Remaining ≈ 60–100 h. + +Legend: ✅ matches · 🟡 partial · ❌ missing. + +## Tools and modes + +| Feature | Status | Notes | +|---|---|---| +| Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | +| Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | +| Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | +| Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | +| Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | | +| Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured | + +## Keyboard and mouse + +| Feature | Status | Notes | +|---|---|---| +| Default shortcuts | 🟡 146 bound (measured: distinct `Some("…")` shortcuts in the registry) | Pro Tools' default set is several hundred incl. numeric keypad and focus keys. Collisions: Mix/Edit toggle vs zoom (#144), Heal (#140), Export (#7) | +| Ctrl as Cmd on Windows/Linux | ✅ since #15, #41 still open in some builds | #62 shows Ctrl in labels | +| Numeric keypad transport and memory-location recall | 🟡 | Classic / Transport / Shuttle modes not all present | +| Modifier gestures (Alt for all tracks, Shift-Alt for selected, Cmd-click fine faders, Ctrl-click defaults) | 🟡 | Main ones on faders and pan; not across every control | +| Scroll/zoom: wheel, trackpad pinch | 🟡 | Anchored trackpad zoom in open PRs #112, #137 | +| Drag and drop: Clip List → tracks, files → tracks | ✅ macOS/Windows; 🟡 Wayland (PR #150) | | +| Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4) | + +## Windows and panels + +| Window | Status | Notes | +|---|---|---| +| Edit window: rulers, track headers, view columns, Clip List, Groups, Tracks list | ✅ | Track Pin (new in 2026.4) ✅ (`track.pin`) | +| Mix window: strips, views (inserts, sends, I/O, comments, EQ curve, preamps, instruments, object) | ✅ | Narrow Mix ✅; Delay Compensation view and HEAT missing | +| MIDI Editor, Score Editor, Event List | 🟡 | Dock height bug fixed (#113); notation editing shallow | +| Transport, Big Counter, Memory Locations, Automation, Video, Universe, Renderer, UI Customization | ✅ present | | +| Workspace / sound browser with previews | ❌ | G-16 | +| I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) | +| Preferences | 🟡 | Many settings stored, fewer honoured | +| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 | +| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) | +| Command palette (Search) | ✅ | Over every command | + +## Look and meters + +| Feature | Status | Notes | +|---|---|---| +| Dark theme close to Pro Tools' | ✅ | Light theme (#32) and live system appearance (#85) beyond Pro Tools | +| Interface scaling | 🟡 | Slider in PR #147 | +| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial | +| Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | | +| Fader resolution, fine adjust, numeric entry | 🟡 | | +| macOS system menu bar | ✅ | #10 | + +## Revision history + +| Date | Change | Summary | +|---|---|---| +| 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 | diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9ccb8fe..6aaaca0 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -113,7 +113,7 @@ fn assets() -> Result<(), String> { } fn parity() -> Result<(), String> { - cargo(&["run", "-q", "-p", "soundcraft-cli", "--", "parity", "--write", "docs/parity.md"]) + cargo(&["run", "-q", "-p", "soundcraft-cli", "--", "parity", "--write", "docs/parity-checklist.md"]) } /// Crates that must compile for the web. From 63b487589c7b931ac5e8a5e312d383d76a1af3f0 Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 15:37:36 -0600 Subject: [PATCH 25/46] UI.9: preserve SoundCraft workflows with shared docking --- Cargo.lock | 10 + Cargo.toml | 1 + README.md | 5 +- ROADMAP.md | 6 + apps/soundcraft-cli/src/main.rs | 2 +- apps/soundcraft/src/config.rs | 192 +++++++++ apps/soundcraft/src/main.rs | 40 +- crates/mix/src/lib.rs | 2 +- crates/ui-egui/Cargo.toml | 3 +- crates/ui-egui/src/docking.rs | 475 ++++++++++++++++++++++ crates/ui-egui/src/edit_window.rs | 33 +- crates/ui-egui/src/lib.rs | 9 + crates/ui-egui/src/menus.rs | 24 +- crates/ui-egui/src/mix_window.rs | 54 +-- crates/ui-egui/src/panels.rs | 140 +++---- crates/ui-egui/src/widgets.rs | 260 ++++++++++-- crates/ui-egui/src/windows.rs | 13 +- crates/ui-egui/tests/docking_workflows.rs | 159 ++++++++ crates/ui-egui/tests/midi_panel.rs | 79 +++- crates/ui-egui/tests/shared_controls.rs | 217 ++++++++++ crates/ui-egui/tests/theme_docking.rs | 83 ++++ docs/architecture.md | 7 + docs/control-protocol.md | 31 ++ xtask/src/main.rs | 2 +- 24 files changed, 1659 insertions(+), 188 deletions(-) create mode 100644 apps/soundcraft/src/config.rs create mode 100644 crates/ui-egui/src/docking.rs create mode 100644 crates/ui-egui/tests/docking_workflows.rs create mode 100644 crates/ui-egui/tests/shared_controls.rs create mode 100644 crates/ui-egui/tests/theme_docking.rs diff --git a/Cargo.lock b/Cargo.lock index 8b26758..cb38648 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -782,6 +782,15 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -3570,6 +3579,7 @@ dependencies = [ name = "soundcraft-ui-egui" version = "0.4.0" dependencies = [ + "craft-ui", "egui", "egui_kittest", "image", diff --git a/Cargo.toml b/Cargo.toml index 95b9a8e..083ceba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ rustfft = "6" cpal = "0.16" # ui +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/README.md b/README.md index 89ec40e..26101e1 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ where things are. ## Getting started Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build -from source with a recent stable Rust: +from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared +minimum remains Rust 1.90): ```sh git clone https://github.com/storytold/soundcraft @@ -366,3 +367,5 @@ Forks and modified versions must remove them. ArtCraft
Made by the ArtCraft team and community.

+ +Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window. diff --git a/ROADMAP.md b/ROADMAP.md index a89da3f..14ef908 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,10 @@ mutated and truncated movies. ## Current focus +Common toolbar icon buttons and track record-enable buttons use shared `craft-ui` interaction, +accessible labels and keyboard focus, retaining SoundCraft's blue tool-selection and red record-arm +states. The specialized transport buttons, faders, meters and saved session model remain local. + 1. The alpha list above. 2. Surround-pan automation; AU editor views. 3. Built-in plugin depth (more processors, better UIs). @@ -76,3 +80,5 @@ mutated and truncated movies. - **M9 (in progress):** AudioSuite breadth (done), Elastic Audio, Beat Detective. - **M10 (next):** alpha release on all platforms. - **M11 (mostly done):** third-party plugin hosting (CLAP, VST3, AU), video, surround. + +- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. No milestone percentage change. diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index 2ba9112..b127f19 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -299,7 +299,7 @@ fn app(args: &[String]) -> ExitCode { let positional: Vec<&String> = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port"))) + .filter(|(i, a)| !(a.starts_with("--") || (*i > 0 && args.get(i - 1).is_some_and(|p| p == "--port")))) .map(|(_, a)| a) .collect(); let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") }; diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs new file mode 100644 index 0000000..71abd34 --- /dev/null +++ b/apps/soundcraft/src/config.rs @@ -0,0 +1,192 @@ +//! Per-user settings and automation profile directories. + +use std::ffi::OsString; +use std::path::PathBuf; + +#[derive(Clone, Copy)] +enum Platform { + Mac, + Windows, + Other, +} + +/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default. +/// The override is the complete directory; no application-name suffix is appended. An explicitly +/// empty value disables the settings directory instead of falling back to a personal profile. +pub fn config_dir() -> Option { + let platform = if cfg!(target_os = "macos") { + Platform::Mac + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Other + }; + config_dir_with(platform, |name| std::env::var_os(name)) +} + +fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) -> Option { + if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") { + return (!dir.is_empty()).then(|| PathBuf::from(dir)); + } + match platform { + Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")), + Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")), + Platform::Other => { + env("XDG_CONFIG_HOME").map(PathBuf::from).or_else(|| env("HOME").map(|h| PathBuf::from(h).join(".config"))).map(|c| c.join("soundcraft")) + } + } +} + +/// Read a bounded native settings file and reject invalid docking trees before using them. +pub fn load_ui(path: &std::path::Path) -> Result, String> { + use std::io::Read as _; + let file = match std::fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("Settings could not be read: {error}")), + }; + let mut bytes = Vec::new(); + file.take(MAX_UI_BYTES + 1).read_to_end(&mut bytes).map_err(|error| format!("Settings could not be read: {error}"))?; + if bytes.len() as u64 > MAX_UI_BYTES { + return Err("Settings file exceeds the 4 MiB limit".into()); + } + let state: soundcraft_ui_egui::UiState = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?; + state.docking.validate()?; + state.mix_docking.validate_mix()?; + Ok(Some(state)) +} + +const MAX_UI_BYTES: u64 = 4 * 1024 * 1024; + +/// Write settings atomically. Preserve an unreadable or malformed existing file for recovery. +pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> { + use std::io::Write as _; + load_ui(path)?; + ui.docking.validate()?; + ui.mix_docking.validate_mix()?; + let mut saved = ui.clone(); + saved.plugin_windows.clear(); + saved.status.clear(); + let bytes = serde_json::to_vec_pretty(&saved).map_err(|error| error.to_string())?; + if bytes.len() as u64 > MAX_UI_BYTES { + return Err("Settings exceed the 4 MiB limit".into()); + } + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|error| error.to_string())?; + } + let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(|error| error.to_string())?.as_nanos(); + let temporary = path.with_extension(format!("json.{}.{nonce}.tmp", std::process::id())); + let mut file = + std::fs::OpenOptions::new().write(true).create_new(true).open(&temporary).map_err(|error| format!("Settings could not be saved: {error}"))?; + let written = (|| -> std::io::Result<()> { + file.write_all(&bytes)?; + file.sync_all()?; + std::fs::rename(&temporary, path) + })(); + if written.is_err() { + let _ = std::fs::remove_file(&temporary); + } + written.map_err(|error| format!("Settings could not be saved: {error}")) +} + +#[cfg(test)] +mod config_tests { + use super::*; + + #[test] + fn native_file_reload_preserves_edit_mix_and_hidden_floating_panels() { + use soundcraft_ui_egui::{MainWindow, Services, SoundApp, docking::Panel}; + let directory = std::env::temp_dir().join(format!("soundcraft-dock-prefs-{}", std::process::id())); + let path = directory.join("ui.json"); + let _ = std::fs::remove_dir_all(&directory); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"close","panel":"tracks"})).unwrap(); + app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light; + let edit = serde_json::to_value(&app.ui.docking).unwrap(); + app.ui.window = MainWindow::Mix; + app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap(); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[140,150,280,420]})).unwrap(); + save_ui(&path, &app.ui).unwrap(); + app.ui = load_ui(&path).unwrap().unwrap(); + assert_eq!(app.ui.theme, soundcraft_ui_egui::theme::ThemeMode::Light); + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), edit); + assert_eq!(app.ui.mix_docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [140.0, 150.0, 280.0, 420.0]); + app.ui.window = MainWindow::Edit; + app.run("ui.dock", serde_json::json!({"operation":"open","panel":"tracks"})).unwrap(); + assert_eq!(app.ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Tracks)).unwrap().rect, [80.0, 90.0, 260.0, 400.0]); + let mut invalid_mix = app.ui.clone(); + invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default(); + let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap(); + std::fs::write(&path, &invalid_bytes).unwrap(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &app.ui).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes); + std::fs::write(&path, b"damaged settings").unwrap(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &app.ui).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), b"damaged settings"); + std::fs::remove_dir_all(directory).unwrap(); + } + + #[test] + fn oversized_settings_are_rejected_and_preserved() { + let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ui.json"); + let bytes = vec![b' '; MAX_UI_BYTES as usize + 1]; + std::fs::write(&path, &bytes).unwrap(); + assert!(load_ui(&path).is_err()); + assert!(save_ui(&path, &soundcraft_ui_egui::UiState::default()).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + std::fs::remove_dir_all(directory).unwrap(); + } + + fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option { + config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value)))) + } + + #[test] + fn config_override_wins_without_reading_user_profile() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + let actual = config_dir_with(platform, |name| { + assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR"); + Some(OsString::from("isolated/settings")) + }); + assert_eq!(actual, Some(PathBuf::from("isolated/settings"))); + } + } + + #[test] + fn config_empty_override_disables_profile_without_reading_defaults() { + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!( + config_dir_with(platform, |name| { + assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR"); + Some(OsString::new()) + }), + None + ); + } + } + + #[test] + fn config_unset_override_keeps_platform_defaults() { + let vars = [("HOME", "/user"), ("APPDATA", "/roaming"), ("XDG_CONFIG_HOME", "/xdg")]; + assert_eq!(resolve(Platform::Mac, &vars), Some(PathBuf::from("/user/Library/Application Support/SoundCraft"))); + assert_eq!(resolve(Platform::Windows, &vars), Some(PathBuf::from("/roaming/SoundCraft"))); + assert_eq!(resolve(Platform::Other, &vars), Some(PathBuf::from("/xdg/soundcraft"))); + assert_eq!(resolve(Platform::Other, &[("HOME", "/user")]), Some(PathBuf::from("/user/.config/soundcraft"))); + for platform in [Platform::Mac, Platform::Windows, Platform::Other] { + assert_eq!(resolve(platform, &[]), None); + } + } + + #[cfg(unix)] + #[test] + fn config_override_preserves_non_unicode_paths() { + use std::os::unix::ffi::OsStringExt as _; + let value = OsString::from_vec(b"isolated/\xff".to_vec()); + assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value))); + } +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 87da4f0..a54e7e1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -11,6 +11,7 @@ #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod config; mod control_server; #[cfg(any(target_os = "windows", test))] mod graphics; @@ -97,16 +98,7 @@ fn open_path(app: &mut SoundApp, p: &str) { /// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here. fn config_dir() -> Option { - if cfg!(target_os = "macos") { - std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/SoundCraft")) - } else if cfg!(windows) { - std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("SoundCraft")) - } else { - std::env::var_os("XDG_CONFIG_HOME") - .map(std::path::PathBuf::from) - .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config"))) - .map(|c| c.join("soundcraft")) - } + config::config_dir() } fn prefs_path() -> Option { @@ -122,24 +114,24 @@ fn load_prefs() -> Option { if !prefs_enabled() { return None; } - let bytes = std::fs::read(prefs_path()?).ok()?; - serde_json::from_slice(&bytes).ok() + let path = prefs_path()?; + match config::load_ui(&path) { + Ok(state) => state, + Err(error) => { + log::warn!("{error}"); + None + } + } } fn save_prefs(ui: &UiState) { if !prefs_enabled() { return; } - if let Some(p) = prefs_path() { - if let Some(dir) = p.parent() { - let _ = std::fs::create_dir_all(dir); - } - let mut ui = ui.clone(); - ui.plugin_windows.clear(); - ui.status.clear(); - if let Ok(bytes) = serde_json::to_vec_pretty(&ui) { - let _ = std::fs::write(&p, bytes); - } + if let Some(path) = prefs_path() + && let Err(error) = config::save_ui(&path, ui) + { + log::warn!("{error}"); } } @@ -198,7 +190,7 @@ fn main() -> eframe::Result { let files: Vec = args .iter() .enumerate() - .filter(|(i, a)| !a.starts_with("--") && !(i > &0 && args.get(i - 1).is_some_and(|p| p == "--control"))) + .filter(|(i, a)| !(a.starts_with("--") || (i > &0 && args.get(i - 1).is_some_and(|p| p == "--control")))) .map(|(_, a)| a.clone()) .collect(); log::info!("SoundCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); @@ -215,7 +207,7 @@ fn main() -> eframe::Result { }, None => { logger.no_file(); - log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)"); + log::warn!("no log file: no settings directory (profile override is empty or platform configuration is unavailable)"); } } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 70cdd1c..ef6b8ef 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -663,7 +663,7 @@ impl MixEngine { // through any track, send or insert. Only the playback engine sets `metronome`. // Not while monitoring only (transport stopped): the position does not advance there, so the // start of the same beat would repeat every block as a buzz. - if self.metronome && !self.monitor_only && s.edit.click && !(s.edit.flag("click.only_during_record") && !self.recording) { + if self.metronome && !self.monitor_only && s.edit.click && (!s.edit.flag("click.only_during_record") || self.recording) { click::render(s, pos, frames, &mut self.main); } // Output: min(out.len(), main channels) channels; any further output channels are silent. diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c91b324..66158bb 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -4,10 +4,11 @@ description = "SoundCraft's egui user interface: Edit and Mix windows, transport version.workspace = true edition.workspace = true license.workspace = true -rust-version.workspace = true +rust-version = "1.95" repository.workspace = true [dependencies] +craft-ui.workspace = true soundcraft-time.workspace = true soundcraft-audio-io.workspace = true soundcraft-midi.workspace = true diff --git a/crates/ui-egui/src/docking.rs b/crates/ui-egui/src/docking.rs new file mode 100644 index 0000000..f2668fa --- /dev/null +++ b/crates/ui-egui/src/docking.rs @@ -0,0 +1,475 @@ +//! Application panel identities and persistence around the shared docking renderer. +//! Document contents remain owned by their existing view implementations. + +use craft_ui::docking::{Action, DockArea, DockStyle, Layout, Location, Node, PanelLimits, Permissions, Placement, Zone}; +use craft_ui::layout::{SplitAxis, SplitSize}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Panel { + Canvas, + Tracks, + Clips, + Midi, + Memory, + History, +} + +impl Panel { + pub const ALL: &[Self] = &[Self::Canvas, Self::Tracks, Self::Clips, Self::Midi, Self::Memory, Self::History]; + pub fn id(self) -> &'static str { + match self { + Self::Canvas => "canvas", + Self::Tracks => "tracks", + Self::Clips => "clips", + Self::Midi => "midi", + Self::Memory => "memory", + Self::History => "history", + } + } + fn label(self) -> &'static str { + match self { + Self::Canvas => "Session", + Self::Tracks => "Tracks and Groups", + Self::Clips => "Clips", + Self::Midi => "MIDI Editor", + Self::Memory => "Memory Locations", + Self::History => "Undo History", + } + } + fn default_side(self) -> Zone { + match self { + Self::Canvas => Zone::Right, + Self::Tracks => Zone::Left, + Self::Clips => Zone::Right, + Self::Midi => Zone::Bottom, + Self::Memory => Zone::Bottom, + Self::History => Zone::Right, + } + } + fn parse(id: &str) -> Result { + Self::ALL.iter().copied().find(|panel| panel.id() == id).ok_or_else(|| format!("Unknown panel: {id}")) + } +} + +const MIDI_TAB_HEIGHT: f32 = 26.0; + +fn permissions(panel: &Panel) -> Permissions { + if *panel == Panel::Canvas { Permissions::PROTECTED } else { Permissions::default() } +} + +fn tabs(panel: Panel) -> Node { + Node::Tabs { panels: vec![panel], active: 0 } +} + +fn split(axis: SplitAxis, size: SplitSize, first: Node, second: Node) -> Node { + Node::Split { axis, size, first: Box::new(first), second: Box::new(second) } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(default)] +pub struct Workspace { + pub layout: Layout, + /// Closed panels retain their most recent group, side, size or floating rectangle. + pub hidden: Vec<(Panel, Location)>, +} + +impl Default for Workspace { + fn default() -> Self { + Self { + layout: Layout { + root: Some(split( + SplitAxis::Vertical, + SplitSize::FixedSecond(320.0 + MIDI_TAB_HEIGHT), + split( + SplitAxis::Horizontal, + SplitSize::FixedFirst(168.0), + tabs(Panel::Tracks), + split(SplitAxis::Horizontal, SplitSize::FixedSecond(230.0), tabs(Panel::Canvas), tabs(Panel::Clips)), + ), + tabs(Panel::Midi), + )), + floating: vec![ + craft_ui::docking::Floating { panels: vec![Panel::Memory], active: 0, rect: [160.0, 120.0, 480.0, 280.0] }, + craft_ui::docking::Floating { panels: vec![Panel::History], active: 0, rect: [680.0, 120.0, 260.0, 300.0] }, + ], + }, + hidden: Vec::new(), + } + } +} + +impl Workspace { + pub fn validate(&self) -> Result<(), String> { + self.layout.validate().map_err(|error| error.to_string())?; + if !self.layout.contains(&Panel::Canvas) || self.hidden.len() > Panel::ALL.len() { + return Err("The panel layout must contain its document view and bounded hidden panels".into()); + } + // Saved settings cannot turn the protected document view into a floating or closable tab. + if self.layout.floating.iter().any(|group| group.panels.contains(&Panel::Canvas)) { + return Err("The document view cannot float".into()); + } + let mut nodes: Vec<_> = self.layout.root.iter().collect(); + while let Some(node) = nodes.pop() { + match node { + Node::Split { first, second, .. } => { + nodes.push(first); + nodes.push(second); + } + Node::Tabs { panels, .. } if panels.contains(&Panel::Canvas) && panels.len() != 1 => { + return Err("The document view cannot share a tab group".into()); + } + Node::Stack { entries } if entries.iter().any(|entry| entry.panel == Panel::Canvas) => { + return Err("The document view cannot be collapsed".into()); + } + _ => {} + } + } + let mut seen = std::collections::HashSet::new(); + for (panel, location) in &self.hidden { + if *panel == Panel::Canvas || self.layout.contains(panel) || !seen.insert(*panel) { + return Err("A hidden panel has an invalid or duplicate identity".into()); + } + if location.anchor == Some(Panel::Canvas) && matches!(location.placement, Placement::Tab { .. } | Placement::Split(Zone::Center)) { + return Err("A hidden panel cannot restore over the document view".into()); + } + let mut probe = self.layout.clone(); + if let Err(error) = probe.restore(*panel, location) + && error != craft_ui::docking::DockError::MissingTarget + { + return Err(error.to_string()); + } + } + Ok(()) + } + + pub fn validate_mix(&self) -> Result<(), String> { + self.validate()?; + if self.layout.contains(&Panel::Clips) || self.layout.contains(&Panel::Midi) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + Ok(()) + } + + pub fn apply(&mut self, action: Action) -> Result<(), String> { + self.validate()?; + let mut next = self.clone(); + if let Action::Close { panel } = &action { + let location = next.layout.location(panel).map_err(|error| error.to_string())?; + next.hidden.retain(|(id, _)| id != panel); + next.hidden.push((*panel, location)); + } else if let Action::Open { panel, .. } | Action::OpenAt { panel, .. } = &action { + next.hidden.retain(|(id, _)| id != panel); + } + next.layout.apply_with_permissions(action, permissions).map_err(|error| error.to_string())?; + next.validate()?; + *self = next; + Ok(()) + } + + pub fn set_visible(&mut self, panel: Panel, visible: bool) -> Result<(), String> { + if self.layout.contains(&panel) == visible { + return Ok(()); + } + if !visible { + return self.apply(Action::Close { panel }); + } + self.validate()?; + let mut next = self.clone(); + let mut location = next.hidden.iter().find(|(id, _)| *id == panel).map(|(_, location)| location.clone()); + next.hidden.retain(|(id, _)| *id != panel); + if let Some(saved) = &mut location + && saved.floating.is_some() + && saved.anchor.as_ref().is_some_and(|anchor| !next.layout.floating.iter().any(|group| group.panels.contains(anchor))) + { + // The other members of a floating group may have been closed too. + saved.anchor = None; + saved.placement = Placement::Tab { before: None }; + } + let restored = location.as_ref().is_some_and(|location| next.layout.restore(panel, location).is_ok()); + if !restored { + next.layout + .apply_with_permissions( + Action::OpenAt { panel, anchor: Panel::Canvas, placement: Placement::Split(panel.default_side()) }, + permissions, + ) + .map_err(|error| error.to_string())?; + } + next.validate()?; + *self = next; + Ok(()) + } + + fn command(&mut self, params: &Value) -> Result<(), String> { + self.validate()?; + if let Some(action) = params.get("action") { + let action: Action = serde_json::from_value(action.clone()).map_err(|error| error.to_string())?; + return self.apply(action); + } + let operation = params.get("operation").and_then(Value::as_str).ok_or("operation is required")?; + if operation == "resizeSplit" { + let path: Vec = + serde_json::from_value(params.get("path").cloned().ok_or("path is required")?).map_err(|error| error.to_string())?; + let size: SplitSize = + serde_json::from_value(params.get("size").cloned().ok_or("size is required")?).map_err(|error| error.to_string())?; + return self.apply(Action::ResizeSplit { path, size }); + } + let panel = Panel::parse(params.get("panel").and_then(Value::as_str).ok_or("panel is required")?)?; + let action = match operation { + "open" => return self.set_visible(panel, true), + "close" => Action::Close { panel }, + "activate" => Action::Activate { panel }, + "setStackOpen" => Action::SetStackOpen { panel, open: params.get("open").and_then(Value::as_bool).ok_or("open must be a boolean")? }, + "resizeStack" => { + let value = params.get("height").ok_or("height is required")?; + let height = if value.is_null() { + None + } else { + Some( + value + .as_f64() + .filter(|height| height.is_finite() && (0.0..=1_000_000.0).contains(height)) + .ok_or("height must be finite and bounded")? as f32, + ) + }; + Action::ResizeStack { panel, height } + } + "float" | "moveFloating" => { + let rect = params.get("rect").and_then(Value::as_array).filter(|rect| rect.len() == 4).ok_or("rect must be [x,y,width,height]")?; + let mut values = [0.0f32; 4]; + for (output, value) in values.iter_mut().zip(rect) { + *output = value + .as_f64() + .filter(|number| number.is_finite() && number.abs() <= 1_000_000.0) + .ok_or("rect values must be finite and bounded")? as f32; + } + if operation == "float" { Action::Float { panel, rect: values } } else { Action::MoveFloating { panel, rect: values } } + } + "move" => { + let anchor = Panel::parse(params.get("target").and_then(Value::as_str).ok_or("target is required")?)?; + let placement = match params.get("zone").and_then(Value::as_str).unwrap_or("center") { + "center" => Placement::Split(Zone::Center), + "left" => Placement::Split(Zone::Left), + "right" => Placement::Split(Zone::Right), + "top" => Placement::Split(Zone::Top), + "bottom" => Placement::Split(Zone::Bottom), + _ => return Err("zone must be center, left, right, top or bottom".into()), + }; + let placement = if let Some(before) = params.get("before") { + if !matches!(placement, Placement::Split(Zone::Center)) { + return Err("before only applies to a center move".into()); + } + Placement::Tab { + before: if before.is_null() { + None + } else { + Some(Panel::parse(before.as_str().ok_or("before must be a panel ID or null")?)?) + }, + } + } else { + placement + }; + Action::Move { panel, anchor, placement } + } + _ => return Err("operation must be open, close, activate, float, move, moveFloating, resizeSplit, setStackOpen or resizeStack".into()), + }; + self.apply(action) + } +} + +/// Mix retains its own arrangement; the timeline-only Clips and MIDI panels stay in Edit. +pub fn mix_default() -> Workspace { + let mut workspace = Workspace::default(); + let _ = workspace.set_visible(Panel::Clips, false); + let _ = workspace.set_visible(Panel::Midi, false); + workspace +} + +fn sync(workspace: &mut Workspace, app: &crate::SoundApp, edit: bool) -> Result<(), String> { + workspace.set_visible(Panel::Tracks, app.ui.show_tracks_list)?; + workspace.set_visible(Panel::Memory, app.ui.show_memory_locations)?; + workspace.set_visible(Panel::History, app.ui.show_undo_history)?; + if edit { + workspace.set_visible(Panel::Clips, app.ui.show_clip_list)?; + workspace.set_visible(Panel::Midi, app.ui.show_midi_editor)?; + } + Ok(()) +} + +fn publish_visibility(app: &mut crate::SoundApp, workspace: &Workspace, edit: bool) { + app.ui.show_tracks_list = workspace.layout.contains(&Panel::Tracks); + app.ui.show_memory_locations = workspace.layout.contains(&Panel::Memory); + app.ui.show_undo_history = workspace.layout.contains(&Panel::History); + if edit { + app.ui.show_clip_list = workspace.layout.contains(&Panel::Clips); + app.ui.show_midi_editor = workspace.layout.contains(&Panel::Midi); + } +} + +fn store(app: &mut crate::SoundApp, workspace: Workspace, edit: bool) { + if edit { + app.ui.docking = workspace; + } else { + app.ui.mix_docking = workspace; + } +} + +pub fn command(app: &mut crate::SoundApp, reset: bool, params: &Value) -> Result { + let edit = app.ui.window == crate::MainWindow::Edit; + let previous = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + let result = command_for(app, reset, params, edit)?; + let next = if edit { &app.ui.docking } else { &app.ui.mix_docking }; + cancel_replaced_workspace(app, &previous, next, edit); + Ok(result) +} + +fn cancel_replaced_workspace(app: &crate::SoundApp, previous: &Workspace, next: &Workspace, edit: bool) { + if (previous.layout != next.layout || previous.hidden != next.hidden) + && let Some(ctx) = &app.docking_context + { + craft_ui::docking::cancel_drag::(ctx, egui::Id::new(("soundcraft-docking", edit))); + } +} + +/// Restoring complete preferences/configurations uses the same ownership boundary as commands. +pub(crate) fn cancel_replaced_state(app: &crate::SoundApp, next: &crate::UiState) { + cancel_replaced_workspace(app, &app.ui.docking, &next.docking, true); + cancel_replaced_workspace(app, &app.ui.mix_docking, &next.mix_docking, false); +} + +fn command_for(app: &mut crate::SoundApp, reset: bool, params: &Value, edit: bool) -> Result { + if !edit && ["panel", "target"].iter().any(|field| matches!(params.get(*field).and_then(Value::as_str), Some("clips" | "midi"))) { + return Err("Clips and MIDI panels belong to the Edit window".into()); + } + let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + if !reset && !edit { + workspace.validate_mix()?; + } + if reset { + workspace = if edit { Workspace::default() } else { mix_default() }; + sync(&mut workspace, app, edit)?; + } else { + sync(&mut workspace, app, edit)?; + workspace.command(params)?; + if !edit { + workspace.validate_mix()?; + } + } + publish_visibility(app, &workspace, edit); + let result = json!({"docking": workspace}); + store(app, workspace, edit); + Ok(result) +} + +/// Existing Arrange commands reposition shared floating panels along with the legacy windows. +pub(crate) fn arrange(app: &mut crate::SoundApp) -> Result<(), String> { + let edit = app.ui.window == crate::MainWindow::Edit; + let mut workspace = if edit { app.ui.docking.clone() } else { app.ui.mix_docking.clone() }; + if edit { + workspace.validate()?; + } else { + workspace.validate_mix()?; + } + let previous = workspace.clone(); + let floating: Vec<_> = workspace.layout.floating.iter().filter_map(|group| group.panels.first().map(|panel| (*panel, group.rect))).collect(); + for (index, (panel, mut rect)) in floating.into_iter().enumerate() { + rect[0] = 32.0 + index as f32 * 28.0; + rect[1] = 80.0 + index as f32 * 28.0; + workspace.apply(Action::MoveFloating { panel, rect })?; + } + cancel_replaced_workspace(app, &previous, &workspace, edit); + store(app, workspace, edit); + Ok(()) +} + +pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWindow) { + app.docking_context = Some(ui.ctx().clone()); + let t = crate::theme::Tokens::current(); + let edit = window == crate::MainWindow::Edit; + let mut workspace = if edit { std::mem::take(&mut app.ui.docking) } else { std::mem::replace(&mut app.ui.mix_docking, mix_default()) }; + let before_sync = workspace.clone(); + if let Err(error) = (if edit { workspace.validate() } else { workspace.validate_mix() }).and_then(|()| sync(&mut workspace, app, edit)) { + app.ui.status = format!("Panel layout could not be restored: {error}"); + workspace = if edit { Workspace::default() } else { mix_default() }; + let _ = sync(&mut workspace, app, edit); + } + cancel_replaced_workspace(app, &before_sync, &workspace, edit); + let mut style = DockStyle::from_ui(ui); + style.min_pane = 96.0; + style.tab_height = MIDI_TAB_HEIGHT; + style.background = t.panel_bg; + style.tab_background = t.panel_bg2; + style.active_background = t.row_selected; + style.text = t.text; + style.inactive_text = t.text_dim; + style.border = egui::Stroke::new(1.0, t.border); + style.accent = t.accent; + style.font = crate::theme::regular(11.5); + let output = DockArea::new(egui::Id::new(("soundcraft-docking", edit))).show_with_limits( + ui, + &workspace.layout, + &style, + |panel| panel.label().into(), + permissions, + panel_limits, + |ui, panel| match panel { + Panel::Canvas => { + ui.painter().rect_filled(ui.max_rect(), 0.0, t.window_bg); + if edit { + crate::edit_window::main_area(app, ui); + } else { + crate::mix_window::content(app, ui); + } + } + Panel::Tracks => crate::panels::tracks_and_groups(app, ui), + Panel::Clips => crate::panels::clip_list(app, ui), + Panel::Midi => { + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("soundcraft-midi-body"), ui.max_rect())); + minimum_body(ui, egui::vec2(420.0, 260.0), |ui| crate::midi_editor::show(app, ui)); + } + Panel::Memory => crate::panels::memory_locations(app, ui), + Panel::History => crate::panels::undo_history(app, ui), + }, + ); + let _ = sync(&mut workspace, app, edit); + publish_visibility(app, &workspace, edit); + store(app, workspace, edit); + for action in output.actions { + if let Err(error) = serde_json::to_value(action) + .map_err(|error| error.to_string()) + .and_then(|action| command_for(app, false, &json!({"action": action}), edit)) + { + app.ui.status = format!("Panel move was not applied: {error}"); + } + } +} + +/// Docking may make a specialist view smaller than its fixed tool/ruler geometry. Keep that +/// geometry scrollable inside its clipped panel instead of producing inverted rectangles. +pub(crate) fn minimum_body(ui: &mut egui::Ui, minimum: egui::Vec2, body: impl FnOnce(&mut egui::Ui)) { + let available = ui.available_size(); + if available.x >= minimum.x && available.y >= minimum.y { + body(ui); + return; + } + let extent = available.max(minimum); + egui::ScrollArea::both().id_salt("minimum-body").auto_shrink([false, false]).show(ui, |ui| { + let (rect, _) = ui.allocate_exact_size(extent, egui::Sense::hover()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + body(&mut child); + }); +} + +fn panel_limits(panel: &Panel) -> PanelLimits { + let (min, max_width) = match panel { + Panel::Canvas => (egui::vec2(300.0, 240.0), 1_000_000.0), + Panel::Tracks => (egui::vec2(140.0, 120.0), 520.0), + Panel::Clips => (egui::vec2(180.0, 120.0), 720.0), + Panel::Midi => (egui::vec2(420.0, 260.0), 1_000_000.0), + Panel::Memory => (egui::vec2(260.0, 160.0), 1_000_000.0), + Panel::History => (egui::vec2(200.0, 160.0), 720.0), + }; + PanelLimits { min, max: egui::vec2(max_width, 1_000_000.0) } +} diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 0ea03a2..906ae1e 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -2,7 +2,7 @@ use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; use crate::widgets::{rec_toggle, selector_box, text_toggle}; -use crate::{Gesture, SoundApp, icons, panels, toolbar}; +use crate::{Gesture, SoundApp, icons, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; @@ -66,27 +66,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { egui::Panel::top("edit_toolbar").exact_size(toolbar::HEIGHT).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| toolbar::show(app, ui)); egui::Panel::bottom("edit_status").exact_size(22.0).frame(egui::Frame::NONE.fill(t.toolbar_bg)).show(ui, |ui| status_bar(app, ui)); egui::Panel::bottom("lower_dock_tabs").exact_size(22.0).frame(egui::Frame::NONE.fill(t.panel_bg2)).show(ui, |ui| dock_tabs(app, ui)); - if app.ui.show_midi_editor { - egui::Panel::bottom("midi_editor") - .resizable(true) - .default_size(320.0) - .min_size(260.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| crate::midi_editor::show(app, ui)); - } if app.ui.show_universe { egui::Panel::top("universe").exact_size(64.0).frame(egui::Frame::NONE.fill(Tokens::current().universe_bg)).show(ui, |ui| universe(app, ui)); } - if app.ui.show_tracks_list { - egui::Panel::left("tracks_list") - .exact_size(168.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| panels::tracks_and_groups(app, ui)); - } - if app.ui.show_clip_list { - egui::Panel::right("clip_list").exact_size(230.0).frame(egui::Frame::NONE.fill(t.panel_bg)).show(ui, |ui| panels::clip_list(app, ui)); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| main_area(app, ui)); + crate::docking::show(app, ui, crate::MainWindow::Edit); } /// Universe: the whole session in miniature; the box is the visible area (drag or click to move). @@ -177,7 +160,17 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { ); } -fn main_area(app: &mut SoundApp, ui: &mut Ui) { +pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) { + let session = app.engine.session(); + let mut ruler_height_total = session.edit.rulers.iter().map(|ruler| ruler_height(ruler)).sum::(); + if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") { + ruler_height_total += ruler_height("tempo_editor"); + } + let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0); + crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui)); +} + +fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let full = ui.max_rect(); let rulers: Vec = app.engine.session().edit.rulers.clone(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6da9b92..955bd5a 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -7,6 +7,7 @@ pub mod control; pub mod credits; pub mod dialogs; +pub mod docking; pub mod edit_window; pub mod extra_windows; pub mod fonts; @@ -47,6 +48,9 @@ pub enum MainWindow { #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[serde(default)] pub struct UiState { + pub docking: docking::Workspace, + #[serde(default = "docking::mix_default")] + pub mix_docking: docking::Workspace, pub window: MainWindow, pub show_tracks_list: bool, pub show_clip_list: bool, @@ -102,6 +106,8 @@ pub struct UiState { impl Default for UiState { fn default() -> Self { UiState { + docking: docking::Workspace::default(), + mix_docking: docking::mix_default(), window: MainWindow::Edit, show_tracks_list: true, show_clip_list: true, @@ -229,6 +235,8 @@ pub struct SoundApp { fonts_installed: bool, /// The palette last applied to the context (light?), so visuals are set only on a change. theme_applied: Option, + /// Last panel host context, used to cancel renderer-owned gestures on explicit replacement. + pub(crate) docking_context: Option, /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, pub quit_requested: bool, @@ -286,6 +294,7 @@ impl SoundApp { fonts_ready: false, fonts_installed: false, theme_applied: None, + docking_context: None, sim: None, quit_requested: false, arrange_request: false, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 89aca57..8ae3899 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -7,6 +7,8 @@ use soundcraft_engine::catalog; /// UI-layer commands: (id, label, catalog path or "", shortcut). pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ + ("ui.dock", "Arrange Panel", "", None), + ("ui.dock.reset", "Reset Panel Layout", "Window > Reset Panel Layout", None), ("window.mix", "Mix", "Window > Mix", Some("Cmd+=")), ("window.edit", "Edit", "Window > Edit", Some("Cmd+=")), ("window.toggle_mix_edit", "Toggle Mix/Edit", "", Some("Cmd+=")), @@ -194,6 +196,7 @@ pub fn tree() -> Vec { for line in catalog::catalog() { insert_path(&mut roots, line); } + insert_path(&mut roots, "Window > Reset Panel Layout"); for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } @@ -277,7 +280,11 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option { /// Enabled and checked state shared by all menu presentations. pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { let enabled = match id { - Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true, + Some(i) + if i.starts_with("window.") || i.starts_with("view.mix_section") || matches!(i, "audiosuite.process" | "ui.dock" | "ui.dock.reset") => + { + true + } Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), None => false, }; @@ -427,6 +434,9 @@ fn wants_dialog(id: &str) -> bool { /// Handle UI-layer commands. Returns None when `id` is not a UI command. pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option> { + if matches!(id, "ui.dock" | "ui.dock.reset") { + return Some(crate::docking::command(app, id == "ui.dock.reset", p)); + } if let Some(v) = crate::extra_windows::run(app, id, p) { return Some(Ok(v)); } @@ -494,6 +504,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_configurations), "window.config_new" | "window.config_update" => { + if let Err(error) = app.ui.docking.validate().and_then(|()| app.ui.mix_docking.validate_mix()) { + return Some(Err(error)); + } let mut snap = app.ui.clone(); snap.configurations.clear(); let v = serde_json::to_value(&snap).unwrap_or_default(); @@ -508,6 +521,9 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { + if let Err(error) = crate::docking::arrange(app) { + app.ui.status = format!("Panel windows could not be arranged: {error}"); + } app.arrange_request = true; json!({}) } @@ -567,8 +583,12 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option(cur) { Ok(ns) => { + if let Err(error) = ns.docking.validate().and_then(|()| ns.mix_docking.validate_mix()) { + return Some(Err(error)); + } + crate::docking::cancel_replaced_state(app, &ns); app.ui = ns; json!({"ui": app.ui}) } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..0a6b84e 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -1,10 +1,11 @@ //! The Mix window: one channel strip per track. +use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, surround_panner, text_toggle, + PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip, + surround_panner, text_toggle, }; -use crate::{SoundApp, panels}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{ChannelFormat, Route, SurroundPan, Track, TrackId, TrackKind}; @@ -15,28 +16,23 @@ pub fn strip_width(narrow: bool) -> f32 { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::current(); - if app.ui.show_tracks_list { - egui::Panel::left("mix_tracks_list") - .exact_size(168.0) - .frame(egui::Frame::NONE.fill(t.panel_bg)) - .show(ui, |ui| panels::tracks_and_groups(app, ui)); - } - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.window_bg)).show(ui, |ui| { - let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); - // Per-channel peaks for multichannel meters (read once per frame). - let snap = app - .player - .as_ref() - .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) - .map(|p| p.meters()); - egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { - ui.horizontal_top(|ui| { - ui.spacing_mut().item_spacing.x = 1.0; - for id in ids { - strip(app, ui, id, snap.as_ref()); - } - }); + crate::docking::show(app, ui, crate::MainWindow::Mix); +} + +pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { + let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); + // Per-channel peaks for multichannel meters (read once per frame). + let snap = app + .player + .as_ref() + .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) + .map(|p| p.meters()); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { + ui.horizontal_top(|ui| { + ui.spacing_mut().item_spacing.x = 1.0; + for id in ids { + strip(app, ui, id, snap.as_ref()); + } }); }); } @@ -247,7 +243,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } else { t.auto_write }; - let resp = selector_box(&mut c, am_r.width(), am_r.height(), &format!("auto {}", mode.label()), col); + let full_label = format!("auto {}", mode.label()); + let label = if mode == soundcraft_model::AutomationMode::TouchLatch { + if narrow { "t/l" } else { mode.label() } + } else if narrow { + mode.label() + } else { + &full_label + }; + let resp = selector_box_with_tooltip(&mut c, am_r.width(), am_r.height(), label, col, &full_label); egui::Popup::menu(&resp).show(|ui| { for m in soundcraft_model::AutomationMode::ALL { if ui.selectable_label(m == mode, m.label()).clicked() { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 33c4324..9ee4ba2 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -168,9 +168,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { /// Floating windows. pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); - memory_locations(app, ctx); big_counter(app, ctx); - undo_history(app, ctx); plugin_windows(app, ctx); about(app, ctx); session_info(app, ctx); @@ -277,92 +275,78 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_big_counter = open; } -fn memory_locations(app: &mut SoundApp, ctx: &egui::Context) { - let mut open = app.ui.show_memory_locations; - if !open { - return; - } - egui::Window::new("Memory Locations").open(&mut open).default_size(vec2(480.0, 280.0)).show(ctx, |ui| { - ui.horizontal(|ui| { - if ui.button("+ Marker").clicked() { - let _ = app.run("markers.add", json!({})); +pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { + ui.horizontal(|ui| { + if ui.button("+ Marker").clicked() { + let _ = app.run("markers.add", json!({})); + } + if ui.button("+ Selection").clicked() { + let _ = app.run("markers.add", json!({"kind": "selection"})); + } + }); + let rows: Vec<(u32, String, String, String, String)> = { + let s = app.engine.session(); + let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + s.markers + .iter() + .map(|m| { + let kind = match m.kind { + soundcraft_model::MarkerKind::Marker => "marker", + soundcraft_model::MarkerKind::Selection => "selection", + soundcraft_model::MarkerKind::None => "none", + }; + let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() }; + (m.number, m.name.clone(), f(m.start), len, kind.to_string()) + }) + .collect() + }; + egui::ScrollArea::vertical().show(ui, |ui| { + egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| { + for h in ["#", "Name", "Location", "Length", "Type", ""] { + ui.label(egui::RichText::new(h).strong()); } - if ui.button("+ Selection").clicked() { - let _ = app.run("markers.add", json!({"kind": "selection"})); - } - }); - let rows: Vec<(u32, String, String, String, String)> = { - let s = app.engine.session(); - let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); - s.markers - .iter() - .map(|m| { - let kind = match m.kind { - soundcraft_model::MarkerKind::Marker => "marker", - soundcraft_model::MarkerKind::Selection => "selection", - soundcraft_model::MarkerKind::None => "none", - }; - let len = if m.kind == soundcraft_model::MarkerKind::Selection { f(m.end - m.start) } else { String::new() }; - (m.number, m.name.clone(), f(m.start), len, kind.to_string()) - }) - .collect() - }; - egui::ScrollArea::vertical().show(ui, |ui| { - egui::Grid::new("mem_grid").striped(true).num_columns(6).show(ui, |ui| { - for h in ["#", "Name", "Location", "Length", "Type", ""] { - ui.label(egui::RichText::new(h).strong()); + ui.end_row(); + for (n, name, loc, len, kind) in rows { + if ui.button(n.to_string()).on_hover_text("Recall").clicked() { + let _ = app.run("markers.recall", json!({"number": n})); + } + let key = egui::Id::new(("mem_name", n)); + let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); + let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); + if r.changed() { + ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); + } + if r.lost_focus() && edit != name { + let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); + ui.ctx().memory_mut(|m| m.data.remove::(key)); + } + ui.label(egui::RichText::new(loc).font(mono(11.0))); + ui.label(egui::RichText::new(len).font(mono(11.0))); + ui.label(kind); + if ui.small_button("✕").on_hover_text("Delete").clicked() { + let _ = app.run("markers.delete", json!({"number": n})); } ui.end_row(); - for (n, name, loc, len, kind) in rows { - if ui.button(n.to_string()).on_hover_text("Recall").clicked() { - let _ = app.run("markers.recall", json!({"number": n})); - } - let key = egui::Id::new(("mem_name", n)); - let mut edit: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| name.clone()); - let r = ui.add(egui::TextEdit::singleline(&mut edit).desired_width(140.0)); - if r.changed() { - ui.ctx().memory_mut(|m| m.data.insert_temp(key, edit.clone())); - } - if r.lost_focus() && edit != name { - let _ = app.run("markers.edit", json!({"number": n, "rename": edit})); - ui.ctx().memory_mut(|m| m.data.remove::(key)); - } - ui.label(egui::RichText::new(loc).font(mono(11.0))); - ui.label(egui::RichText::new(len).font(mono(11.0))); - ui.label(kind); - if ui.small_button("✕").on_hover_text("Delete").clicked() { - let _ = app.run("markers.delete", json!({"number": n})); - } - ui.end_row(); - } - }); + } }); }); - app.ui.show_memory_locations = open; } -fn undo_history(app: &mut SoundApp, ctx: &egui::Context) { - let mut open = app.ui.show_undo_history; - if !open { - return; - } - egui::Window::new("Undo History").open(&mut open).default_size(vec2(260.0, 300.0)).show(ctx, |ui| { - let hist = app.engine.undo_history(); - let n = hist.len(); - egui::ScrollArea::vertical().show(ui, |ui| { - for (i, h) in hist.iter().enumerate() { - if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { - for _ in 0..(n - i - 1) { - let _ = app.run("edit.undo", json!({})); - } +pub(crate) fn undo_history(app: &mut SoundApp, ui: &mut Ui) { + let hist = app.engine.undo_history(); + let n = hist.len(); + egui::ScrollArea::vertical().show(ui, |ui| { + for (i, h) in hist.iter().enumerate() { + if ui.selectable_label(i + 1 == n, format!("{} {h}", i + 1)).clicked() { + for _ in 0..(n - i - 1) { + let _ = app.run("edit.undo", json!({})); } } - if let Some(r) = app.engine.redo_label() { - ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); - } - }); + } + if let Some(r) = app.engine.redo_label() { + ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); + } }); - app.ui.show_undo_history = open; } fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index da913a8..d4eab05 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1,24 +1,33 @@ //! Small custom widgets in the studio style. +use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; + use crate::icons; use crate::theme::{Tokens, bold, regular}; use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +/// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar. +fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle { + let t = Tokens::current(); + let visuals = |fill, icon| ButtonVisuals { fill, stroke: Stroke::new(1.0, t.button_border), icon }; + IconButtonStyle { + normal: visuals(t.button, normal_icon), + hovered: visuals(t.button_hi, normal_icon), + pressed: None, + selected: visuals(selected_fill, Color32::WHITE), + corner_radius: CornerRadius::same(radius), + focus_stroke: Stroke::new(1.0, t.accent), + } +} + /// A toolbar button with an icon; `on` draws the blue selected state. pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &str) -> Response { let t = Tokens::current(); - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - t.accent - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(3), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); - let col = if on { Color32::WHITE } else { t.text }; - let s = r.height().min(r.width()) * 0.78; - icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, col); + let style = button_style(t.accent, t.text, 3); + let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + let s = r.height().min(r.width()) * 0.78; + icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint); + }); resp.on_hover_text(tip) } @@ -39,24 +48,22 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color resp.on_hover_text(tip) } -/// Record-enable button (red dot). +/// Record-enable button (red dot); the caller owns track arming and command dispatch. pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { - let t = Tokens::current(); - let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - t.rec - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); - ui.painter().circle_filled(r.center(), size.y * 0.24, if on { Color32::WHITE } else { Color32::from_rgb(190, 190, 190) }); + let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2); + let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + ui.painter().circle_filled(r.center(), size.y * 0.24, tint); + }); resp.on_hover_text(tip) } /// A dropdown-looking selector box. Returns the response (caller opens a menu on click). pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32) -> Response { + selector_box_with_tooltip(ui, width, height, text, color, text) +} + +/// A selector with a compact visible label and the complete meaning on hover. +pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); ui.painter().rect( @@ -68,10 +75,15 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col ); let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); - let galley = ui.painter().layout(text.to_string(), regular((height * 0.62).clamp(8.0, 12.0)), color, (width - height).max(10.0)); - let pos = pos2(r.min.x + (r.width() - height * 0.6 - galley.size().x) * 0.5, r.center().y - galley.size().y * 0.5); + let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0)); + let mut job = egui::text::LayoutJob::simple(text.to_owned(), regular((height * 0.62).clamp(8.0, 12.0)), color, text_rect.width().max(1.0)); + job.wrap.max_rows = 1; + job.wrap.break_anywhere = true; + job.wrap.overflow_character = Some('…'); + let galley = ui.painter().layout_job(job); + let pos = text_rect.center() - galley.size() * 0.5; ui.painter().with_clip_rect(r.shrink(1.0)).galley(pos, galley, color); - resp + resp.on_hover_text(tooltip) } /// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. @@ -371,3 +383,197 @@ mod tests { } } } + +#[cfg(test)] +mod selector_visual_regressions { + use super::*; + + fn scaled_input(size: egui::Vec2, scale: f32) -> egui::RawInput { + let mut input = egui::RawInput { screen_rect: Some(Rect::from_min_size(egui::Pos2::ZERO, size)), ..Default::default() }; + input.viewports.entry(egui::ViewportId::ROOT).or_default().native_pixels_per_point = Some(scale); + input + } + + #[test] + fn capture_selector_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 1.5, 2.0] { + let mut ready = false; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(320.0, 120.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, regions: &mut serde_json::Map| { + if !ready { + crate::fonts::install(ui.ctx()); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + regions.clear(); + for (index, label) in ["auto read", "no group", "A very long mixer group name"].into_iter().enumerate() { + ui.horizontal(|ui| { + for width in [46.0, 74.0, 110.0] { + let r = selector_box(ui, width, 16.0, label, Tokens::current().text).rect; + regions.insert(format!("selector-{index}-{width}"), serde_json::json!([r.left(), r.top(), r.width(), r.height()])); + } + }); + } + }, + serde_json::Map::new(), + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("soundcraft-selectors-dark-{scale}x"); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&serde_json::json!({ + "screen": [320.0, 120.0], "scale": scale, "regions": harness.state() + })) + .unwrap(), + ) + .unwrap(); + } + } + + fn mixer_app(narrow: bool, mode: soundcraft_model::AutomationMode) -> crate::SoundApp { + let mut app = crate::SoundApp::new(soundcraft_engine::Engine::default(), None, Default::default()); + app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap(); + app.run("track.new", serde_json::json!({"name": "Music"})).unwrap(); + let ids: Vec<_> = app.engine.session().tracks.iter().map(|track| track.id).collect(); + for id in ids { + app.run("mix.automation_mode", serde_json::json!({"track": id.0, "mode": mode.label()})).unwrap(); + } + app.ui.narrow_mix = narrow; + app.ui.show_tracks_list = false; + app.ui.mix_views = vec!["io".into()]; + app + } + + #[test] + fn actual_mixer_keeps_every_active_automation_mode_legible() { + for narrow in [false, true] { + for mode in soundcraft_model::AutomationMode::ALL { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + crate::theme::apply(&ctx, false); + let mut app = mixer_app(narrow, mode); + let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui)); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(320.0, 640.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let long = format!("auto {}", mode.label()); + let short = if mode == soundcraft_model::AutomationMode::TouchLatch { "t/l" } else { mode.label() }; + let texts: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) + if text.galley.job.text == long || text.galley.job.text == mode.label() || text.galley.job.text == short => + { + Some(text) + } + _ => None, + }) + .collect(); + assert_eq!(texts.len(), 2, "both tracks paint their automation mode: {mode:?}"); + for text in texts { + assert_eq!(text.galley.rows.len(), 1, "{mode:?} narrow={narrow} at {scale}x: automation text wraps"); + assert!( + !text.galley.elided, + "{mode:?} narrow={narrow} at {scale}x: the active mode must remain distinguishable without hovering" + ); + } + } + } + } + } + + #[test] + fn capture_actual_mixer_visual_fixtures() { + let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for mode in soundcraft_model::AutomationMode::ALL { + for narrow in [false, true] { + for scale in [1.0, 1.5, 2.0] { + let mut ready = false; + let mut harness = + egui_kittest::Harness::builder().with_size(vec2(320.0, 640.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, app: &mut crate::SoundApp| { + if !ready { + crate::fonts::install(ui.ctx()); + crate::theme::apply(ui.ctx(), false); + ready = true; + return; + } + crate::mix_window::show(app, ui); + }, + mixer_app(narrow, mode), + ); + harness.input_mut().max_texture_side = Some(8192); + harness.run_steps(4); + let stem = format!("soundcraft-mixer-narrow-{narrow}-{}-{scale}x", mode.label().replace('/', "-")); + harness.render().unwrap().save(directory.join(format!("{stem}.png"))).unwrap(); + let strips = harness + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Rect(rect) + if (rect.rect.width() - crate::mix_window::strip_width(narrow)).abs() < 0.1 && rect.rect.height() >= 560.0 => + { + Some(rect.rect) + } + _ => None, + }) + .fold(Rect::NOTHING, Rect::union); + assert!(strips.is_finite()); + std::fs::write( + directory.join(format!("{stem}.json")), + serde_json::to_vec_pretty(&serde_json::json!({ + "screen": [320.0, 640.0], "scale": scale, "mode": mode.label(), "narrow": narrow, + "regions": {"mixer-selectors": [strips.left(), strips.top(), strips.width(), 120.0]} + })) + .unwrap(), + ) + .unwrap(); + } + } + } + } + + #[test] + fn compact_mixer_selectors_keep_labels_on_one_visible_line() { + for width in [46.0, 74.0, 110.0] { + for label in ["auto read", "no group", "A very long mixer group name"] { + for scale in [1.0, 1.5, 2.0] { + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + crate::theme::apply(&ctx, false); + let mut rect = Rect::NOTHING; + let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| { + rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect; + }); + output.textures_delta.clear(); + assert_eq!(ctx.content_rect().size(), vec2(320.0, 120.0)); + assert_eq!(ctx.pixels_per_point(), scale); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.job.text == label => Some(text), + _ => None, + }) + .expect("selector label is painted"); + assert_eq!(text.galley.rows.len(), 1, "{label:?} at {width} must not wrap into clipped rows"); + let bounds = text.visual_bounding_rect(); + assert!(bounds.top() >= rect.top() && bounds.bottom() <= rect.bottom(), "{label:?}: {bounds:?} outside {rect:?}"); + assert!( + bounds.left() >= rect.left() && bounds.right() <= rect.right() - 16.0 * 0.8, + "text must leave room for the dropdown arrow" + ); + } + } + } + } +} diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index b7b34e5..1e51a2d 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -383,10 +383,15 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { if ui.button(format!("{} {name}", i + 1)).clicked() && let Ok(mut st) = serde_json::from_value::(v.clone()) { - st.configurations = app.ui.configurations.clone(); - st.theme = app.ui.theme; - st.show_configurations = true; - app.ui = st; + if let Err(error) = st.docking.validate().and_then(|()| st.mix_docking.validate_mix()) { + app.ui.status = format!("Window configuration could not be restored: {error}"); + } else { + st.configurations = app.ui.configurations.clone(); + st.theme = app.ui.theme; + st.show_configurations = true; + crate::docking::cancel_replaced_state(app, &st); + app.ui = st; + } } if ui.small_button("✕").clicked() { app.ui.configurations.remove(i); diff --git a/crates/ui-egui/tests/docking_workflows.rs b/crates/ui-egui/tests/docking_workflows.rs new file mode 100644 index 0000000..c2cdf33 --- /dev/null +++ b/crates/ui-egui/tests/docking_workflows.rs @@ -0,0 +1,159 @@ +//! Real Edit/Mix panel workflows, persistence, and command ownership without OS windows. +use egui::{Event, Modifiers, PointerButton, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp, UiState, docking::Panel}; + +fn harness(state: Option, width: f32, scale: f32) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name":"Voice"})).unwrap(); + if let Some(state) = state { + app.ui = state; + } + let mut h = Harness::builder().with_size(vec2(width, 900.0)).with_pixels_per_point(scale).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn drag(h: &mut Harness<'_, SoundApp>, from: egui::Pos2, to: egui::Pos2) { + h.event(Event::PointerMoved(from)); + h.run_steps(2); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(2); + for step in 1..=8 { + h.event(Event::PointerMoved(from.lerp(to, step as f32 / 8.0))); + h.run_steps(1); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(4); +} + +#[test] +fn real_drag_float_close_reopen_reload_retains_transport_and_mixer_commands() { + let mut h = harness(None, 1400.0, 1.0); + let source = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").rect().center(); + let target = h.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").rect().center(); + drag(&mut h, source, target); + assert_eq!(h.state().ui.docking.layout.location(&Panel::Clips).unwrap().anchor, Some(Panel::Tracks)); + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary); + h.run_steps(3); + h.get_by_label("Float panel").click(); + h.run_steps(5); + let rect = h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect; + h.get_by_role_and_label(egui::accesskit::Role::Tab, "Clips").click_button(PointerButton::Secondary); + h.run_steps(3); + h.get_by_label("Close panel").click(); + h.run_steps(4); + assert!(!h.state().ui.show_clip_list); + h.state_mut().run("ui.dock", json!({"operation":"open","panel":"clips"})).unwrap(); + h.run_steps(4); + assert_eq!(h.state().ui.docking.layout.floating.iter().find(|g| g.panels.contains(&Panel::Clips)).unwrap().rect, rect); + let saved = serde_json::to_string(&h.state().ui).unwrap(); + let expected = serde_json::to_value(&h.state().ui.docking).unwrap(); + let mut reloaded = harness(Some(serde_json::from_str(&saved).unwrap()), 1400.0, 1.0); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), expected); + reloaded.state_mut().run("ui.dock", json!({"operation":"move","panel":"clips","target":"tracks","zone":"center"})).unwrap(); + reloaded.run_steps(4); + reloaded.get_by_role_and_label(egui::accesskit::Role::Tab, "Tracks and Groups").click(); + reloaded.run_steps(3); + reloaded.get_by_label("Trim (F6)").click(); + reloaded.run_steps(3); + assert_eq!(reloaded.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); + assert!(!reloaded.state().engine.transport.playing); + let edit = serde_json::to_value(&reloaded.state().ui.docking).unwrap(); + reloaded.state_mut().ui.window = MainWindow::Mix; + reloaded.run_steps(5); + reloaded.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[900,70,220,420]})).unwrap(); + reloaded.run_steps(4); + assert_eq!(serde_json::to_value(&reloaded.state().ui.docking).unwrap(), edit); + assert!(!reloaded.state().ui.mix_docking.layout.contains(&Panel::Clips)); + let record = reloaded.get_by_label("Record enable"); + // A real pointer click must remain outside the floating panel's occluding rectangle. + assert!(!egui::Rect::from_min_size(egui::pos2(900.0, 70.0), vec2(220.0, 420.0)).contains(record.rect().center())); + record.click(); + reloaded.run_steps(3); + assert!(reloaded.state().engine.session().tracks[0].mixer.record_arm); + assert!(!reloaded.state().engine.transport.recording); +} + +#[test] +fn invalid_docking_requests_are_atomic_in_edit_and_mix() { + let mut h = harness(None, 1400.0, 1.0); + for window in [MainWindow::Edit, MainWindow::Mix] { + h.state_mut().ui.window = window; + h.run_steps(4); + let before = serde_json::to_value(&h.state().ui).unwrap(); + for params in [ + json!({"operation":"close","panel":"canvas"}), + json!({"operation":"move","panel":"tracks","target":"canvas","zone":"center"}), + json!({"operation":"float","panel":"tracks","rect":[0,0,-1,100]}), + ] { + assert!(h.state_mut().run("ui.dock", params).is_err()); + assert_eq!(serde_json::to_value(&h.state().ui).unwrap(), before); + } + if window == MainWindow::Mix { + assert!(h.state_mut().run("ui.dock", json!({"operation":"open","panel":"midi"})).is_err()); + } + assert_eq!(h.state().engine.session().tracks.len(), 1); + } +} + +#[test] +fn docking_renders_edit_and_mix_default_floating_and_redocked() { + for width in [1000.0, 1400.0] { + for scale in [1.0, 2.0] { + for window in [MainWindow::Edit, MainWindow::Mix] { + let mut h = harness(Some(UiState { window, ..Default::default() }), width, scale); + h.state_mut().engine = soundcraft_engine::demo::demo_engine(); + for state in ["default", "floating", "redocked"] { + if state == "floating" { + h.state_mut().run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[100,80,260,500]})).unwrap(); + } else if state == "redocked" { + h.state_mut().run("ui.dock", json!({"operation":"move","panel":"tracks","target":"canvas","zone":"left"})).unwrap(); + } + h.run_steps(5); + h.state().ui.docking.validate().unwrap(); + h.state().ui.mix_docking.validate().unwrap(); + if let Ok(directory) = std::env::var("CRAFT_UI_DOCKING_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/sound-{window:?}-{width}-{scale}-{state}.png")).unwrap(); + } + } + } + } + } +} + +#[test] +fn reset_is_enabled_and_resolves_in_native_and_in_window_menu_trees() { + use soundcraft_ui_egui::menus; + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let path = "Window > Reset Panel Layout"; + let extra: Vec<_> = menus::UI_COMMANDS.iter().map(|(id, _, path, _)| (*path, *id)).collect(); + assert_eq!(menus::command_for_path(path, &extra).as_deref(), Some("ui.dock.reset")); + let tree = menus::tree(); + let window = tree.iter().find(|node| node.label == "Window").unwrap(); + assert!(window.children.iter().any(|node| node.path == path)); + assert!(menus::item_state(&app, path, Some("ui.dock.reset")).0); + app.run("ui.dock", json!({"operation":"float","panel":"tracks","rect":[50,60,260,420]})).unwrap(); + menus::invoke_menu(&mut app, "ui.dock.reset", path); + assert!(!app.ui.docking.layout.floating.iter().any(|group| group.panels.contains(&Panel::Tracks))); +} + +#[test] +fn legacy_preferences_restore_the_theme_and_validate_default_docking() { + let mut value = serde_json::to_value(UiState { theme: soundcraft_ui_egui::theme::ThemeMode::Light, ..Default::default() }).unwrap(); + let object = value.as_object_mut().unwrap(); + object.remove("docking"); + object.remove("mix_docking"); + let restored: UiState = serde_json::from_value(value).unwrap(); + assert_eq!(restored.theme, soundcraft_ui_egui::theme::ThemeMode::Light); + restored.docking.validate().unwrap(); + restored.mix_docking.validate_mix().unwrap(); +} diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs index 45ec0ec..56cb12d 100644 --- a/crates/ui-egui/tests/midi_panel.rs +++ b/crates/ui-egui/tests/midi_panel.rs @@ -13,7 +13,7 @@ fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { } fn panel(ctx: &egui::Context) -> Rect { - egui::containers::panel::PanelState::load(ctx, Id::new("midi_editor")).unwrap().outer_rect + ctx.data(|data| data.get_temp::(Id::new("soundcraft-midi-body"))).unwrap() } fn button(pos: Pos2, pressed: bool) -> Event { @@ -39,8 +39,8 @@ fn check_resize(with_clip: bool) { // to catch a panel that looks resized but snaps back after release. for height in [440.0, 280.0, 200.0] { let before = panel(&ctx); - let start = pos2(before.center().x, before.top() + 1.0); - let end = pos2(start.x, before.bottom() - height); + let start = pos2(before.center().x, before.top() - 26.0 - 2.0); + let end = pos2(start.x, before.bottom() - height - 26.0 - 2.0); frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); frame(&ctx, &mut app, vec![button(start, true)]); for step in 1..=6 { @@ -65,6 +65,12 @@ fn check_resize(with_clip: bool) { frame(&ctx, &mut app, vec![]); } assert_eq!(panel(&ctx), resized); + let saved = serde_json::to_string(&app.ui).unwrap(); + app.ui = serde_json::from_str(&saved).unwrap(); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(panel(&ctx), resized); assert_eq!(*app.engine.session(), notes); } @@ -77,3 +83,70 @@ fn midi_panel_resizes_with_a_selected_clip() { fn midi_panel_resizes_without_a_selected_clip() { check_resize(false); } + +#[test] +fn layout_reset_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("reset"); +} + +#[test] +fn arrange_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("arrange"); +} + +#[test] +fn legacy_visibility_sync_cancels_a_held_midi_divider_without_editing_the_session() { + check_held_replacement("visibility"); +} + +fn check_held_replacement(operation: &str) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks":["Keys"]})).unwrap(); + app.ui.show_memory_locations = operation == "arrange"; + let session = app.engine.session().clone(); + let ctx = egui::Context::default(); + for _ in 0..6 { + frame(&ctx, &mut app, vec![]); + } + let initial = panel(&ctx); + let start = pos2(initial.center().x, initial.top() - 28.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(start)]); + frame(&ctx, &mut app, vec![button(start, true)]); + let first = start - vec2(0.0, 80.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(first)]); + frame(&ctx, &mut app, vec![]); + assert!((panel(&ctx).height() - 400.0).abs() < 1.0, "first drag must resize"); + let second = start - vec2(0.0, 120.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(second)]); + frame(&ctx, &mut app, vec![]); + assert!((panel(&ctx).height() - 440.0).abs() < 1.0, "a normal second held move must continue resizing"); + match operation { + "reset" => { + app.run("ui.dock.reset", json!({})).unwrap(); + } + "arrange" => { + app.run("window.arrange_cascade", json!({})).unwrap(); + } + "visibility" => { + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + app.run("window.midi_editor", json!({})).unwrap(); + frame(&ctx, &mut app, vec![]); + } + _ => panic!("unknown synthetic operation"), + } + let reset = serde_json::to_value(&app.ui.docking).unwrap(); + let after_reset = start - vec2(0.0, 160.0); + frame(&ctx, &mut app, vec![Event::PointerMoved(after_reset)]); + frame(&ctx, &mut app, vec![]); + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset, "old held divider must not resize the replacement layout"); + let expected = if operation == "reset" { 320.0 } else { 440.0 }; + assert!((panel(&ctx).height() - expected).abs() < 1.0); + frame(&ctx, &mut app, vec![button(after_reset, false)]); + for _ in 0..4 { + frame(&ctx, &mut app, vec![]); + } + assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset); + assert_eq!(*app.engine.session(), session); +} diff --git a/crates/ui-egui/tests/shared_controls.rs b/crates/ui-egui/tests/shared_controls.rs new file mode 100644 index 0000000..71ae5de --- /dev/null +++ b/crates/ui-egui/tests/shared_controls.rs @@ -0,0 +1,217 @@ +//! Interaction and optional offscreen evidence for the application's painted controls. + +use egui::{Event, Key, Modifiers, PointerButton, Response, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp, theme, widgets}; + +#[derive(Default)] +struct ButtonState { + record: bool, + selected: bool, + disabled: bool, + clicks: usize, + response: Option, +} + +impl ButtonState { + fn label(&self) -> &'static str { + if self.record { "Record enable" } else { "Loop playback" } + } +} + +fn button_frame(ctx: &egui::Context, state: &mut ButtonState, events: Vec) -> egui::FullOutput { + let mut output = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + let response = ui + .add_enabled_ui(!state.disabled, |ui| { + if state.record { + widgets::rec_toggle(ui, vec2(38.0, 28.0), state.selected, state.label()) + } else { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", state.selected, state.label()) + } + }) + .inner; + state.clicks += usize::from(response.clicked()); + state.response = Some(response); + }); + output.textures_delta.clear(); + output +} + +fn key(key: Key, pressed: bool) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE } +} + +fn pointer(pos: egui::Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +#[test] +fn tool_and_record_buttons_preserve_distinct_selected_colors_and_caller_state() { + for record in [false, true] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { record, selected: true, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + let center = state.response.as_ref().unwrap().rect.center(); + let out = button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + let expected = if record { theme::Tokens::current().rec } else { theme::Tokens::current().accent }; + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) if r.fill == expected))); + if record { + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Circle(c) if c.fill == egui::Color32::WHITE))); + } + let update = out.platform_output.accesskit_update.unwrap(); + let node = &update.nodes.iter().find(|(_, n)| n.label() == Some(state.label())).unwrap().1; + assert_eq!(node.role(), egui::accesskit::Role::Button); + assert_eq!(node.toggled(), Some(egui::accesskit::Toggled::True)); + button_frame(&ctx, &mut state, vec![pointer(center, false)]); + assert_eq!(state.clicks, 1); + assert!(state.selected, "selection and record arming remain caller-owned"); + } +} + +#[test] +fn tool_and_record_buttons_handle_keyboard_disabled_state_and_cancelled_clicks() { + for record in [false, true] { + for key_code in [Key::Enter, Key::Space] { + let ctx = egui::Context::default(); + ctx.enable_accesskit(); + let mut state = ButtonState { record, ..Default::default() }; + button_frame(&ctx, &mut state, vec![]); + button_frame(&ctx, &mut state, vec![]); + let response = state.response.clone().unwrap(); + ctx.memory_mut(|m| m.request_focus(response.id)); + let out = button_frame(&ctx, &mut state, vec![]); + assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Rect(r) + if r.rect == response.rect.shrink(2.0) && r.stroke.color == theme::Tokens::current().accent))); + button_frame(&ctx, &mut state, vec![key(key_code, true)]); + button_frame(&ctx, &mut state, vec![key(key_code, false)]); + assert_eq!(state.clicks, 1); + let center = response.rect.center(); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true)]); + button_frame(&ctx, &mut state, vec![Event::PointerMoved(pos2(300.0, 150.0))]); + button_frame(&ctx, &mut state, vec![pointer(pos2(300.0, 150.0), false)]); + assert_eq!(state.clicks, 1); + state.disabled = true; + button_frame(&ctx, &mut state, vec![Event::PointerMoved(center), pointer(center, true), key(key_code, true)]); + let out = button_frame(&ctx, &mut state, vec![pointer(center, false), key(key_code, false)]); + assert_eq!(state.clicks, 1); + assert!(!state.response.as_ref().unwrap().enabled()); + assert!(out.platform_output.accesskit_update.unwrap().nodes.iter().any(|(_, n)| n.label() == Some(state.label()) && n.is_disabled())); + } + } +} + +#[test] +fn toolbar_keyboard_activation_changes_tools_without_transport_or_marker_actions() { + let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Trim (F6)").click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().edit.tool, soundcraft_model::Tool::Trim); + let markers = h.state().engine.session().markers.len(); + for (label, tool, key) in + [("Pencil (F10)", soundcraft_model::Tool::Pencil, Key::Enter), ("Grabber (F8)", soundcraft_model::Tool::Grabber, Key::Space)] + { + h.get_by_label(label).focus(); + h.run_steps(2); + assert!(h.get_by_label(label).is_focused()); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().engine.session().edit.tool, tool); + assert!(!h.state().engine.transport.playing); + assert!(!h.state().engine.transport.recording); + assert_eq!(h.state().engine.session().markers.len(), markers); + } +} + +#[test] +fn track_record_button_arms_the_track_without_starting_transport() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", serde_json::json!({"name": "Voice"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Record enable").click(); + h.run_steps(3); + assert!(h.state().engine.session().tracks[0].mixer.record_arm); + for (key, armed) in [(Key::Enter, false), (Key::Space, true)] { + h.get_by_label("Record enable").focus(); + h.run_steps(2); + h.key_press(key); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.record_arm, armed); + assert!(!h.state().engine.transport.playing); + assert!(!h.state().engine.transport.recording); + assert!(h.state().engine.session().markers.is_empty()); + } +} + +#[test] +fn shared_controls_visuals() { + let Some(directory) = std::env::var_os("CRAFT_UI_SUITE_SHOTS").map(std::path::PathBuf::from) else { return }; + std::fs::create_dir_all(&directory).unwrap(); + for scale in [1.0, 2.0] { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + for (name, size) in [("normal", vec2(1600.0, 1000.0)), ("narrow", vec2(1000.0, 700.0))] { + h.set_size(size); + h.run_steps(6); + h.render().unwrap().save(directory.join(format!("soundcraft-{name}-dark-{scale}x.png"))).unwrap(); + } + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, rects: &mut Vec| { + theme::apply(ui.ctx(), false); + rects.clear(); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + rects.push(widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop Playback").rect); + rects.push(widgets::icon_button(ui, vec2(36.0, 26.0), "trim", selected, "Trim").rect); + rects.push(widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable").rect); + } + }); + }); + } + }, + Vec::new(), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-dark-{scale}x.png"))).unwrap(); + for (name, index, pressed) in [("hover", 0, false), ("selected-hover", 3, false), ("disabled-hover", 6, false), ("pressed", 0, true)] { + let center = controls.state()[index].center(); + controls.event(Event::PointerMoved(center)); + if pressed { + controls.event(pointer(center, true)); + } + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-{name}-dark-{scale}x.png"))).unwrap(); + } + controls.event(pointer(controls.state()[0].center(), false)); + controls.event(Event::PointerGone); + controls.run_steps(2); + controls.key_press(Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("soundcraft-controls-focus-dark-{scale}x.png"))).unwrap(); + } +} diff --git a/crates/ui-egui/tests/theme_docking.rs b/crates/ui-egui/tests/theme_docking.rs new file mode 100644 index 0000000..ed32415 --- /dev/null +++ b/crates/ui-egui/tests/theme_docking.rs @@ -0,0 +1,83 @@ +//! Synthetic appearance checks for populated shared panels and custom controls. +use egui::{Color32, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme, widgets}; + +#[test] +fn shared_panels_and_controls_follow_manual_and_live_system_appearance() { + // Keep palette changes in one owning test: upstream custom painters use process-global tokens. + for scale in [1.0, 2.0] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks":["Keys"]})).unwrap(); + for id in ["window.midi_editor", "window.memory_locations", "window.undo_history"] { + app.run(id, json!({})).unwrap(); + } + let session = app.engine.session().clone(); + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.input_mut().max_texture_side = Some(8192); + for (name, mode, system, light) in [ + ("dark", theme::ThemeMode::Dark, egui::Theme::Light, false), + ("light", theme::ThemeMode::Light, egui::Theme::Dark, true), + ("system-light", theme::ThemeMode::System, egui::Theme::Light, true), + ("system-dark", theme::ThemeMode::System, egui::Theme::Dark, false), + ] { + h.state_mut().ui.theme = mode; + h.input_mut().system_theme = Some(system); + for (width_name, size) in [("normal", vec2(1400.0, 1000.0)), ("narrow", vec2(1000.0, 800.0))] { + h.set_size(size); + h.run_steps(5); + assert_eq!(theme::is_light(), light); + assert_eq!(h.state().ui.theme, mode); + assert_eq!(*h.state().engine.session(), session); + h.state().ui.docking.validate().unwrap(); + if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(directory.join(format!("sound-{name}-{width_name}-{scale}x.png"))).unwrap(); + } + } + let ctx = egui::Context::default(); + theme::apply(&ctx, light); + let tokens = theme::Tokens::current(); + let mut output = ctx.run_ui(egui::RawInput::default(), |ui| { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback"); + widgets::rec_toggle(ui, vec2(30.0, 18.0), true, "Record enable"); + ui.add_enabled_ui(false, |ui| widgets::icon_button(ui, vec2(38.0, 28.0), "loop", false, "Disabled loop")); + }); + output.textures_delta.clear(); + for expected in [tokens.accent, tokens.rec] { + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Rect(rect) if rect.fill == expected))); + } + assert!(output.shapes.iter().any(|shape| matches!(&shape.shape, egui::Shape::Circle(circle) if circle.fill == Color32::WHITE))); + if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) { + let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state( + move |ui, _: &mut ()| { + theme::apply(ui.ctx(), light); + for enabled in [true, false] { + ui.add_enabled_ui(enabled, |ui| { + ui.horizontal(|ui| { + for selected in [false, true] { + widgets::icon_button(ui, vec2(38.0, 28.0), "loop", selected, "Loop playback"); + widgets::rec_toggle(ui, vec2(30.0, 18.0), selected, "Record enable"); + } + }); + }); + } + }, + (), + ); + controls.run_steps(4); + controls.render().unwrap().save(directory.join(format!("sound-controls-{name}-{scale}x.png"))).unwrap(); + controls.key_press(egui::Key::Tab); + controls.run_steps(2); + controls.render().unwrap().save(directory.join(format!("sound-controls-focus-{name}-{scale}x.png"))).unwrap(); + } + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 3432699..7145bd2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,6 +3,13 @@ SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. +The UI package pins `craft-ui` for common icon-button and track record-enable interactions. +SoundCraft supplies each state's colors, border, radius and icon painter; selection and command +dispatch remain in the toolbar and track controls. The single-shape frame option preserves the +existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling, +nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI +requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration. + ``` L0 time audio-io midi (standalone: no workspace deps) L1 dsp clap-host (plugins; clap-host is the only unsafe crate) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index a64c4c8..32a4932 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -12,6 +12,18 @@ input), and every command is reachable from: | MCP (headless) | `soundcraft-cli mcp [--demo]` | | MCP (bridged to the app) | `soundcraft-cli mcp --connect ` | +## Isolated automation profiles + +Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`. +The nonempty value is used as the complete settings directory. Unset retains the normal platform paths; +an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS` +when testing preference persistence across restarts. + +Run UI acceptance with `--no-audio`. This disables the output player; it does not disable recording +input opened by arm/monitor commands. An automated UI smoke run must never arm a track, enable input +monitoring, or start recording. Control input injects egui events; it does not establish operating-system +input delivery or screen-reader acceptance. Native screenshots use the presented viewport frame. + ## Wire format Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line: @@ -72,3 +84,22 @@ the native or web integration at launch and on later frames, while the saved cho When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves System to Dark. + +## Panel docking + +Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Commands target the active Edit or Mix view; floating panels stay inside the current viewport. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. The document/session view is protected. Layouts, active tabs, panel visibility, and floating rectangles are saved with the app's existing UI preferences. These operations do not edit document contents. + +Panel IDs: `tracks`, `clips`, `midi`, `memory`, `history`. The protected center is `canvas`. + +`ui.dock` accepts `operation` and `panel`. Operations are `open`, `close`, `activate`, `float` (with finite positive `rect: [x, y, width, height]`), or `move` (with `target` and `zone: center|left|right|top|bottom`). Center groups with the target; side zones split beside it. `ui.dock.reset` restores the default arrangement. Invalid moves return an error without changing the layout. + +Invoke these UI commands through `engine.execute`, for example `{"command":"ui.dock","params":{"operation":"float","panel":"tracks","rect":[100,100,320,440]}}`. + +Advanced `ui.dock` operations use the same validated transaction as pointer gestures: +`moveFloating` updates `rect`, `resizeSplit` accepts a boolean child `path` and +`size` (`{"Ratio":0.5}`, `{"FixedFirst":280}`, or `{"FixedSecond":280}`), +`setStackOpen` takes `open`, and `resizeStack` takes a point `height` or null. +A center `move` can include `before` (panel ID or null to append) for tab order. +The equivalent `{"action": ...}` form accepts a serialized shared docking action; +this is also the form emitted by the renderer. Protected-canvas and app-specific panel +rules apply equally to both forms. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 9ccb8fe..f15b16c 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -35,7 +35,7 @@ const LAYERS: &[(&str, u8)] = &[ ]; /// Standalone format crates: no workspace dependencies at all (publishable on their own). const STANDALONE: &[&str] = &["time", "audio-io", "midi", "video", "dsp"]; -const UI_CRATES: &[&str] = &["egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; +const UI_CRATES: &[&str] = &["craft-ui", "egui", "eframe", "winit", "egui_kittest", "rfd", "egui-wgpu", "egui_glow"]; const EXEMPT: &[&str] = &["soundcraft", "soundcraft-cli", "soundcraft-web", "xtask"]; fn layers() -> Result<(), String> { From 404940b40dc1be6399b981517828581bcd775880 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:26:05 +1100 Subject: [PATCH 26/46] Import multichannel audio onto a track of the same width. A new track for an imported file was clamped to stereo, so a 5.1 file lost every channel past the left and right. The track now follows the file's channel count. Importing onto an existing track is unchanged. --- crates/engine/src/cmd/file.rs | 25 ++++++++++++++++++++++++- crates/engine/src/io.rs | 2 +- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..248ddff 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -390,7 +390,6 @@ mod tests { assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); 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(); @@ -428,4 +427,28 @@ mod tests { assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn import_surround_creates_a_matching_track() { + let channels: Vec> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels }; + let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap(); + let mut e = Engine::default(); + let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap(); + assert_eq!(v["channels"], 6); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51); + let src = e.session().sources[0].id; + let audio = e.session().pool.get(src).unwrap(); + assert_eq!(audio.buffer.channels.len(), 6); + assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3); + + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap(); + let id = e.session().tracks[0].id; + crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51); + assert_eq!(e.session().tracks[0].clips().len(), 1); + } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..26fca8f 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -56,7 +56,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option Some(t) => t, None => { let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); - s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem)) + s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) } }; let cid = s.new_clip_id(); From 09d64ab2ec34ad777853157aed26b050dc1b9a41 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:26 +1100 Subject: [PATCH 27/46] Export MIDI controllers and honour the tracks filter. file.export_midi copied notes only, so controller, pressure, and pitch-bend events were dropped, and the tracks parameter was ignored. The file now includes those events, and a tracks list limits the export. Naming only non-MIDI tracks is an error instead of exporting every other MIDI track. --- crates/engine/src/cmd/file.rs | 58 ++++++++++++++++++++++++++++++++++- crates/engine/src/io.rs | 17 +++++++--- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..4b189fd 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -98,7 +98,8 @@ pub fn specs() -> Vec { }), cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string(); - let bytes = crate::io::export_midi(e)?; + 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}")))?; Ok(json!({"path": path, "bytes": bytes.len()})) }), @@ -426,6 +427,61 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + + fn midi_engine() -> Engine { + use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + let mut s = Session::default(); + let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys")); + let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums")); + let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room")); + let mut keys_seq = Sequence::default(); + keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 }); + let mut drum_seq = Sequence::default(); + drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 }); + drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 }); + let a = s.new_clip_id(); + let b = s.new_clip_id(); + crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq)); + crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq)); + Engine::new(s) + } + + fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> { + let smf = soundcraft_midi::read_smf(bytes).unwrap(); + let mut out = Vec::new(); + for t in &smf.tracks { + for c in &t.sequence.ctrls { + if let soundcraft_midi::CtrlKind::Cc(n) = c.kind { + out.push((t.name.clone(), n, c.value)); + } + } + } + out + } + + #[test] + fn midi_export_keeps_controllers_and_honours_tracks() { + let mut e = midi_engine(); + let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let all = dir.join("all.mid"); + e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&all).unwrap()); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}"); + assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}"); + assert!(ctrls.iter().all(|(n, _, _)| n != "Room")); + + let keys = dir.join("keys.mid"); + e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap(); + let ctrls = cc_numbers(&std::fs::read(&keys).unwrap()); + assert_eq!(ctrls.len(), 1, "{ctrls:?}"); + assert_eq!(ctrls[0].0, "Keys"); + + let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err(); + assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..edc29f1 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -180,8 +180,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b Ok(out) } -/// Export all MIDI clips as a format-1 SMF. -pub fn export_midi(e: &Engine) -> Result> { +/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks. +pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { let s = e.session(); let mut smf = soundcraft_midi::Smf { format: 1, @@ -191,16 +191,25 @@ pub fn export_midi(e: &Engine) -> Result> { markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(), key_sigs: Vec::new(), }; - for t in s.tracks.iter().filter(|t| t.kind.is_midi()) { + let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect(); + if only.is_some() && chosen.is_empty() { + return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into())); + } + for t in chosen { let mut seq = soundcraft_midi::Sequence::default(); for c in t.clips() { if let ClipContent::Midi { sequence } = &c.content { let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); for n in &sequence.notes { let mut n = *n; - n.start += base; + n.start = n.start.saturating_add(base); seq.notes.push(n); } + for ev in &sequence.ctrls { + let mut ev = *ev; + ev.tick = ev.tick.saturating_add(base); + seq.ctrls.push(ev); + } } } seq.sort(); From 2afbfd94776ef1789156787b1919852973a361e4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:23:08 +1100 Subject: [PATCH 28/46] Save a session copy without retargeting the open document. Save Session Copy In wrote through the normal save, which renamed the session, marked it clean, and pointed it at the copy. The open session now stays dirty, named, and path-ed as it was, while the copy is a separate folder. --- crates/engine/src/cmd/file.rs | 30 +++++++++++++++++++++++++++--- crates/engine/src/io.rs | 6 ++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..2d24472 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -27,9 +27,7 @@ pub fn specs() -> Vec { }), cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string(); - let keep = e.path.clone(); - let written = crate::io::save_session(e, &path)?; - e.path = keep; + let written = crate::io::save_session_copy(e, &path)?; Ok(json!({"path": path, "audio_files_written": written})) }), cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| { @@ -426,6 +424,32 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + + fn save_copy_leaves_the_open_session_alone() { + let (mut e, _) = tone_engine(); + let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let original = dir.join("Original.scraft"); + e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap(); + e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap(); + assert!(e.is_dirty()); + let copy_dir = dir.join("copy"); + std::fs::create_dir_all(©_dir).unwrap(); + let copy = copy_dir.join("Copy.scraft"); + e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref())); + assert_eq!(e.session().name, "Original"); + assert!(e.is_dirty()); + assert_eq!(e.session().tracks.len(), 2); + + let mut from_original = Engine::default(); + from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap(); + assert_eq!(from_original.session().tracks.len(), 1); + let mut from_copy = Engine::default(); + from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap(); + assert_eq!(from_copy.session().tracks.len(), 2); + assert!(from_copy.session().track_by_name("Extra").is_some()); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..583b5a6 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -471,6 +471,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result { write_session(e.session(), path, true).map(|(_, n, _)| n) } +/// File › Save Session Copy In: a normal session folder that does not rename, retarget or +/// clean the session that stays open. +pub fn save_session_copy(e: &Engine, path: &str) -> Result { + write_session(e.session(), path, false).map(|(_, n, _)| n) +} + /// Write `session` to `path`; returns the session with updated media paths, the number of audio /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { From 849cc79675a9ca8edddbe5e7bef15fd25b52c26b Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sat, 10 Oct 2026 16:22:53 +1100 Subject: [PATCH 29/46] Keep each consolidation in its own audio file. A second consolidate of the same track reused Audio Files/{name}.wav, and saving then skipped the rename because that path already belonged to the earlier source. New sources now take a free name, and a save splits sources that already share a path instead of overwriting one file. --- crates/engine/src/cmd/file.rs | 37 +++++++++++++++++++++++++ crates/engine/src/io.rs | 52 ++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index d58af41..6ebc4c2 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -426,6 +426,43 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + + fn tone_engine() -> (Engine, u64) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); + let track = e.session().tracks[0].id; + let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800)); + e.session_mut().edit.selected_tracks = vec![track]; + (e, track.0) + } + + #[test] + fn repeated_consolidate_keeps_distinct_audio_files() { + let (mut e, track) = tone_engine(); + e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap(); + let cid = e.session().tracks[0].clips()[0].id; + e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap(); + e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap(); + let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect(); + assert_eq!(consolidated.len(), 2, "{consolidated:?}"); + assert_ne!(consolidated[0], consolidated[1]); + + let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Tone.scraft"); + e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap(); + let mut opened = Engine::default(); + opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap(); + let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect(); + assert_eq!(files.len(), 2, "{:?}", opened.session().sources); + let a = std::fs::read(&files[0]).unwrap(); + let b = std::fs::read(&files[1]).unwrap(); + assert_ne!(a, b, "the second consolidation replaced the first file"); let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index cb3fc18..f09fc50 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -11,6 +11,21 @@ use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. const MAX_IMPORT_SAMPLES: usize = 1 << 30; +/// A relative `Audio Files/` path no other source in `s` already uses. +fn unique_audio_rel(s: &Session, stem: &str) -> String { + let stem = sanitize_name(stem); + let mut path = format!("Audio Files/{stem}.wav"); + let mut n = 2u32; + while s.sources.iter().any(|src| src.path == path) { + path = format!("Audio Files/{stem}-{n}.wav"); + n = n.saturating_add(1); + if n > 10_000 { + break; + } + } + path +} + /// Register decoded audio as a new source. Resamples to the session rate if needed. pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId { let sr = s.sample_rate.hz(); @@ -20,10 +35,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio } let id = SourceId(s.alloc()); let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); + let stored = match path { + Some(p) => p.to_string(), + None => unique_audio_rel(s, &stem), + }; s.sources.push(Source { id, name: stem.clone(), - path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string), + path: stored, channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX), frames: buf.frames() as u64, sample_rate: sr, @@ -480,10 +499,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); let mut written = 0; - for src in &mut s.sources { + let shared_paths: Vec = + s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); + for (i, src) in s.sources.iter_mut().enumerate() { let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name)); let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists(); - if exists_rel && !src.unsaved { + let shared = shared_paths.get(i).copied().unwrap_or(false); + if exists_rel && !src.unsaved && !shared { continue; } let Some(audio) = s.pool.get(src.id) else { continue }; @@ -499,7 +521,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess } } else { let mut k = 1; - while dir.join(&target).exists() && !(exists_rel && target == src.path) { + while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) { target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name)); k += 1; if k > 999 { @@ -622,6 +644,28 @@ mod save_tests { assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn save_gives_sources_that_share_a_path_their_own_files() { + let mut s = Session::default(); + let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] }; + let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] }; + add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav); + add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav); + let mut e = Engine::new(s); + let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("Shared.scraft"); + save_session(&mut e, &path.to_string_lossy()).unwrap(); + let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect(); + assert_eq!(paths.len(), 2); + assert_ne!(paths[0], paths[1], "{paths:?}"); + let a = std::fs::read(dir.join(&paths[0])).unwrap(); + let b = std::fs::read(dir.join(&paths[1])).unwrap(); + assert_ne!(a, b); + let _ = std::fs::remove_dir_all(&dir); + } } #[cfg(test)] From 4410486fc21561a9db0dc33961a2017469127e0d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:37:37 -0400 Subject: [PATCH 30/46] Release 0.5.0: linux-riscv64 tarball, version bump (#165) - release.yml: new linux-riscv64 job, cross-compiled on ubuntu-24.04 against ports.ubuntu.com riscv64 libs (the linux job's -dev packages incl. libasound2-dev:riscv64), CLI smoke-tested under qemu-riscv64; wired into the release job's needs so SHA256SUMS covers it. - packaging/linux/package.sh: CROSS_ARCH/CROSS_TARGET/CROSS_COMPILE/EMULATOR support. - clap-host: c_char -> u8 without a cast that is a no-op (clippy error) where c_char is u8. - Version 0.4.0 -> 0.5.0; docs list the riscv64 tarball. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 72 +++++++++++++++++++++++++++++++++- AGENTS.md | 2 +- Cargo.lock | 36 ++++++++--------- Cargo.toml | 2 +- README.md | 2 + contributors/contributors.json | 65 ++++++++++++++++++------------ crates/clap-host/src/ffi.rs | 3 +- docs/releasing.md | 1 + packaging/linux/package.sh | 27 +++++++++---- 9 files changed, 155 insertions(+), 55 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b1c0a39..0fec370 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -250,6 +250,76 @@ jobs: path: dist/release/*.flatpak if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI (which links no ALSA) is smoke-tested under QEMU. Plugin hosting (CLAP, VST3) is + # pure-Rust bindings plus dlopen, so it builds here unchanged; cpal uses ALSA (no JACK). + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} + SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@v2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$SOUNDCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + # The riscv64 twins of the linux job's -dev packages; libasound2-dev is cpal's ALSA backend. + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + pkg-config file desktop-file-utils appstream \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 + - name: Build and package (tar.gz) + env: + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the package + run: | + file target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'RISC-V' + # cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml). + riscv64-linux-gnu-readelf -d target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'libasound' + tar -tzf "dist/release/soundcraft-$SOUNDCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E 'bin/soundcraft$' + - uses: actions/upload-artifact@v4 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + freebsd: name: FreeBSD x86_64 needs: version @@ -328,7 +398,7 @@ jobs: release: name: Draft GitHub Release # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts. - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/AGENTS.md b/AGENTS.md index e0f7c70..4fdf3cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc `cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit. ## Release -Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak manifest), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. +Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`. ## Roadmap `ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry. diff --git a/Cargo.lock b/Cargo.lock index 8b26758..b1cf3aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3403,7 +3403,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.4.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -3419,7 +3419,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "serde", @@ -3429,7 +3429,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "proptest", @@ -3441,7 +3441,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "serde_json", @@ -3451,7 +3451,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.4.0" +version = "0.5.0" dependencies = [ "clap-sys", "libloading", @@ -3463,7 +3463,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "proptest", @@ -3485,7 +3485,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "proptest", @@ -3506,7 +3506,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3516,7 +3516,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.4.0" +version = "0.5.0" dependencies = [ "log", "rayon", @@ -3532,7 +3532,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3545,7 +3545,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.4.0" +version = "0.5.0" dependencies = [ "cpal", "log", @@ -3558,7 +3558,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.4.0" +version = "0.5.0" dependencies = [ "proptest", "serde", @@ -3568,7 +3568,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.4.0" +version = "0.5.0" dependencies = [ "egui", "egui_kittest", @@ -3593,7 +3593,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.4.0" +version = "0.5.0" dependencies = [ "image", "proptest", @@ -3604,7 +3604,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.4.0" +version = "0.5.0" dependencies = [ "libloading", "log", @@ -3619,7 +3619,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.4.0" +version = "0.5.0" dependencies = [ "eframe", "egui", @@ -5166,7 +5166,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.4.0" +version = "0.5.0" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 95b9a8e..20d25e5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.4.0" +version = "0.5.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index add0398..1cc27c6 100644 --- a/README.md +++ b/README.md @@ -293,6 +293,8 @@ and web backend defaults are unchanged. | Fedora/RHEL/openSUSE | `soundcraft--linux-x86_64.rpm` | `soundcraft--linux-aarch64.rpm` | | | Tarball | `soundcraft--linux-x86_64.tar.gz` | `soundcraft--linux-aarch64.tar.gz` | Unpack anywhere | +RISC-V (riscv64): `soundcraft--linux-riscv64.tar.gz`, a tarball only (glibc 2.39+, e.g. Ubuntu 24.04). + ### FreeBSD | Build | File | diff --git a/contributors/contributors.json b/contributors/contributors.json index 8c94277..5b4969d 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,36 +2,36 @@ "schema": 1, "repo": "storytold/soundcraft", "branch": "main", - "head": "caa4872df2aa81a21e401071d2e0d09002658e9e", - "head_date": "2026-10-10T00:50:08Z", - "commits": 98, + "head": "330a6983973dc2043bb1fa0f0ba67fa07ae50685", + "head_date": "2026-10-10T18:08:49Z", + "commits": 111, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 5, - "commits": 56, - "lines_added": 79132, - "lines_deleted": 1983, + "prs": 6, + "commits": 58, + "lines_added": 80505, + "lines_deleted": 2222, "binary_added": 23, "binary_deleted": 0, "first_commit": "2026-10-06T00:08:04Z", - "last_commit": "2026-10-10T00:50:08Z" + "last_commit": "2026-10-10T18:08:49Z" }, { "login": "reoniazx", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 7, - "lines_added": 453, - "lines_deleted": 62, + "prs": 5, + "commits": 9, + "lines_added": 649, + "lines_deleted": 64, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T06:10:03Z", - "last_commit": "2026-10-09T15:12:12Z" + "last_commit": "2026-10-10T05:15:18Z" }, { "login": "switch87", @@ -115,14 +115,14 @@ "login": "vbasky", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 4, - "lines_added": 829, - "lines_deleted": 137, + "prs": 10, + "commits": 11, + "lines_added": 1039, + "lines_deleted": 174, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T00:12:26Z", - "last_commit": "2026-10-09T22:37:04Z" + "last_commit": "2026-10-10T05:22:24Z" }, { "login": "DWAA1660", @@ -167,14 +167,27 @@ "login": "grovebit", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 198, - "lines_deleted": 5, + "prs": 2, + "commits": 2, + "lines_added": 218, + "lines_deleted": 15, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T21:45:39Z", - "last_commit": "2026-10-09T21:45:39Z" + "last_commit": "2026-10-10T01:34:26Z" + }, + { + "login": "coygeek", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 125, + "lines_deleted": 91, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T01:28:07Z", + "last_commit": "2026-10-10T01:28:07Z" } ], "models": [ @@ -183,9 +196,9 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 71, - "lines_added": 80906, - "lines_deleted": 2305 + "commits": 73, + "lines_added": 82279, + "lines_deleted": 2544 }, { "id": "anthropic/claude-haiku/5.5", diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 6cbac6f..16aaf33 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -71,8 +71,9 @@ unsafe fn read_cstr(p: *const c_char) -> Option { } /// Reads a fixed-size `char[N]` name buffer, stopping at the first NUL or the end of the array. +/// `c_char` is `i8` on x86 and `u8` on ARM/RISC-V Linux; the byte reinterpretation is the same for both. fn read_name(buf: &[c_char]) -> String { - let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect(); + let bytes: Vec = buf.iter().take_while(|c| **c != 0).map(|c| u8::from_ne_bytes(c.to_ne_bytes())).collect(); String::from_utf8_lossy(&bytes).into_owned() } diff --git a/docs/releasing.md b/docs/releasing.md index e68b9a5..c277b79 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,6 +50,7 @@ web build and are checked, the signing jobs are refused, and no release is draft | Windows 11 ARM64 | `soundcraft--windows-arm64.msi`, `soundcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled; `windows-arm64.yml` installs and runs it on ARM64) | | Linux x86_64 | `soundcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `soundcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `soundcraft--linux-riscv64.tar.gz` (glibc 2.39+) | `ubuntu-24.04` (cross-compiled; CLI smoke-tested under QEMU) | | AppImage updates | `soundcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | | Flatpak x86_64, aarch64 | `soundcraft--linux-{x86_64,aarch64}.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackages the Linux tarball) | | FreeBSD 14 x86_64 | `soundcraft--freebsd-x86_64.tar.gz` (a `/usr/local` tree) | FreeBSD 14.3 VM on `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index 65e9a60..a949dba 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package SoundCraft for Linux ( is x86_64 or aarch64): +# Build and package SoundCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/soundcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/soundcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -13,6 +13,10 @@ # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. +# +# Cross-compiling (CI's linux-riscv64 job, tar.gz only): set CROSS_ARCH (riscv64), CROSS_TARGET +# (the Rust target), CROSS_COMPILE (the binutils prefix, for strip) and optionally EMULATOR +# (e.g. qemu-riscv64) to smoke-test the CLI. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -25,15 +29,16 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,17p' "$0"; exit 0 ;; + -h | --help) sed -n '2,19p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; + riscv64) DEB_ARCH=riscv64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; esac export SOUNDCRAFT_MAINTAINER="${SOUNDCRAFT_MAINTAINER:-SoundCraft maintainers }" @@ -42,9 +47,13 @@ BASENAME="soundcraft-$VERSION-linux-$ARCH" echo "==> SoundCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/soundcraft" "$STAGE/usr/bin/soundcraft" install -Dm755 "$BIN/soundcraft-cli" "$STAGE/usr/bin/soundcraft-cli" -strip "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/soundcraft" "$STAGE/usr/bin/soundcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -135,6 +144,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/soundcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/soundcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/soundcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" From 0434bb13976040672e6a432cae06103ca620978a Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 18:52:14 -0600 Subject: [PATCH 31/46] UI.9: recover incompatible saved panel layouts independently --- apps/soundcraft/src/config.rs | 60 ++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/apps/soundcraft/src/config.rs b/apps/soundcraft/src/config.rs index 71abd34..1ade4ee 100644 --- a/apps/soundcraft/src/config.rs +++ b/apps/soundcraft/src/config.rs @@ -37,8 +37,12 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option) - } } -/// Read a bounded native settings file and reject invalid docking trees before using them. +/// Read bounded settings, recovering unreadable docking metadata independently of other settings. pub fn load_ui(path: &std::path::Path) -> Result, String> { + read_ui(path, true) +} + +fn read_ui(path: &std::path::Path, recover_docking: bool) -> Result, String> { use std::io::Read as _; let file = match std::fs::File::open(path) { Ok(file) => file, @@ -50,7 +54,20 @@ pub fn load_ui(path: &std::path::Path) -> Result MAX_UI_BYTES { return Err("Settings file exceeds the 4 MiB limit".into()); } - let state: soundcraft_ui_egui::UiState = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?; + let mut saved: serde_json::Value = serde_json::from_slice(&bytes).map_err(|error| format!("Settings could not be restored: {error}"))?; + if recover_docking && let Some(fields) = saved.as_object_mut() { + for (field, mix) in [("docking", false), ("mix_docking", true)] { + let unreadable = + fields.get(field).is_some_and(|value| match serde_json::from_value::(value.clone()) { + Ok(workspace) => if mix { workspace.validate_mix() } else { workspace.validate() }.is_err(), + Err(_) => true, + }); + if unreadable { + fields.remove(field); + } + } + } + let state: soundcraft_ui_egui::UiState = serde_json::from_value(saved).map_err(|error| format!("Settings could not be restored: {error}"))?; state.docking.validate()?; state.mix_docking.validate_mix()?; Ok(Some(state)) @@ -61,7 +78,8 @@ const MAX_UI_BYTES: u64 = 4 * 1024 * 1024; /// Write settings atomically. Preserve an unreadable or malformed existing file for recovery. pub fn save_ui(path: &std::path::Path, ui: &soundcraft_ui_egui::UiState) -> Result<(), String> { use std::io::Write as _; - load_ui(path)?; + // Loading can recover a future layout for this session, but saving must preserve its source. + read_ui(path, false)?; ui.docking.validate()?; ui.mix_docking.validate_mix()?; let mut saved = ui.clone(); @@ -119,7 +137,10 @@ mod config_tests { invalid_mix.mix_docking = soundcraft_ui_egui::docking::Workspace::default(); let invalid_bytes = serde_json::to_vec(&invalid_mix).unwrap(); std::fs::write(&path, &invalid_bytes).unwrap(); - assert!(load_ui(&path).is_err()); + let recovered = load_ui(&path).unwrap().unwrap(); + assert_eq!(recovered.theme, app.ui.theme); + assert_eq!(serde_json::to_value(recovered.docking).unwrap(), serde_json::to_value(&app.ui.docking).unwrap()); + recovered.mix_docking.validate_mix().unwrap(); assert!(save_ui(&path, &app.ui).is_err()); assert_eq!(std::fs::read(&path).unwrap(), invalid_bytes); std::fs::write(&path, b"damaged settings").unwrap(); @@ -129,6 +150,37 @@ mod config_tests { std::fs::remove_dir_all(directory).unwrap(); } + #[test] + fn future_edit_and_mix_layouts_preserve_other_preferences_and_the_original_file() { + use soundcraft_ui_egui::{Services, SoundApp}; + let directory = std::env::temp_dir().join(format!("soundcraft-future-dock-prefs-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let path = directory.join("ui.json"); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("ui.dock", serde_json::json!({"operation":"float","panel":"tracks","rect":[80,90,260,400]})).unwrap(); + app.ui.theme = soundcraft_ui_egui::theme::ThemeMode::Light; + app.ui.narrow_mix = true; + for field in ["docking", "mix_docking"] { + for invalid in [serde_json::json!({"layout":{"root":{"FutureLayout":{}}}}), serde_json::json!(false)] { + let mut saved = serde_json::to_value(&app.ui).unwrap(); + saved[field] = invalid; + assert!(serde_json::from_value::(saved.clone()).is_err()); + let bytes = serde_json::to_vec(&saved).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let recovered = load_ui(&path).unwrap().unwrap(); + assert_eq!(recovered.theme, app.ui.theme); + assert!(recovered.narrow_mix); + recovered.docking.validate().unwrap(); + recovered.mix_docking.validate_mix().unwrap(); + let other = if field == "docking" { "mix_docking" } else { "docking" }; + assert_eq!(serde_json::to_value(&recovered).unwrap()[other], saved[other]); + assert!(save_ui(&path, &recovered).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), bytes); + } + } + std::fs::remove_dir_all(directory).unwrap(); + } + #[test] fn oversized_settings_are_rejected_and_preserved() { let directory = std::env::temp_dir().join(format!("soundcraft-bounded-prefs-{}", std::process::id())); From 814ae99a32681c3ced5bb9547f16f205cd20048e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 19:27:18 -0600 Subject: [PATCH 32/46] UI shared controls: isolate palette ownership in rendering tests --- crates/ui-egui/src/theme.rs | 10 +++++++++- crates/ui-egui/src/widgets.rs | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 66c3380..c667e6f 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -6,6 +6,13 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// Whether the Light palette is active. Dark is the default (the incumbent is dark-only). static LIGHT_THEME: AtomicBool = AtomicBool::new(false); +/// Unit tests that change the process-wide palette must own it until their assertions finish. +#[cfg(test)] +pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> { + static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + /// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -443,7 +450,8 @@ mod tests { #[test] fn application_frames_follow_system_appearance_and_preserve_manual_palettes() { - // Keep all frame assertions in one test: custom-painted tokens share LIGHT_THEME. + // Mixer rendering tests also change the process-wide custom-painted palette. + let _palette = lock_test_palette(); fn frame(app: &mut SoundApp, ctx: &egui::Context, system_theme: Option, light: bool) { let mode = app.ui.theme; let colors = |app: &SoundApp| { diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index d4eab05..1eca07c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -396,6 +396,7 @@ mod selector_visual_regressions { #[test] fn capture_selector_visual_fixtures() { + let _palette = crate::theme::lock_test_palette(); let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; std::fs::create_dir_all(&directory).unwrap(); for scale in [1.0, 1.5, 2.0] { @@ -451,6 +452,7 @@ mod selector_visual_regressions { #[test] fn actual_mixer_keeps_every_active_automation_mode_legible() { + let _palette = crate::theme::lock_test_palette(); for narrow in [false, true] { for mode in soundcraft_model::AutomationMode::ALL { for scale in [1.0, 1.5, 2.0] { @@ -491,6 +493,7 @@ mod selector_visual_regressions { #[test] fn capture_actual_mixer_visual_fixtures() { + let _palette = crate::theme::lock_test_palette(); let Some(directory) = std::env::var_os("CRAFT_UI_VISUAL_FIXTURES").map(std::path::PathBuf::from) else { return }; std::fs::create_dir_all(&directory).unwrap(); for mode in soundcraft_model::AutomationMode::ALL { @@ -544,6 +547,7 @@ mod selector_visual_regressions { #[test] fn compact_mixer_selectors_keep_labels_on_one_visible_line() { + let _palette = crate::theme::lock_test_palette(); for width in [46.0, 74.0, 110.0] { for label in ["auto read", "no group", "A very long mixer group name"] { for scale in [1.0, 1.5, 2.0] { From 592991789e8a5da9b31350b1b8b9fa17222037cb Mon Sep 17 00:00:00 2001 From: Coy Geek <65363919+coygeek@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:46:49 -0700 Subject: [PATCH 33/46] fix(theme): restore Light Mix Instrument label contrast Use the current populated insert fill and slot outline for Instrument slots. This restores readable Light and System-Light labels while preserving the original Dark colors, hover behavior and picker route. Add a root-UI regression that checks actual emitted colors in four palettes, preserves session/audio data, replaces the built-in instrument, checks parameter defaults and restores the session through undo. Include the authorized SMF match-guard prerequisite and a contract test for first-name-wins and ignored metadata. All 16 focused SMF tests and the complete populated Instrument regression passed. Full CI passed formatting and cleared the SMF lint, then stopped on unchanged metronome and MP3 Clippy expressions. Later CI stages did not run. Full local CI before publication was explicitly waived. Closes #161 --- crates/midi/src/smf.rs | 6 +- crates/midi/tests/smf.rs | 18 ++ crates/ui-egui/src/mix_window.rs | 4 +- .../ui-egui/tests/mix_instrument_contrast.rs | 172 ++++++++++++++++++ 4 files changed, 194 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/tests/mix_instrument_contrast.rs diff --git a/crates/midi/src/smf.rs b/crates/midi/src/smf.rs index 345d3e8..36c18a0 100644 --- a/crates/midi/src/smf.rs +++ b/crates/midi/src/smf.rs @@ -267,10 +267,8 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?; match kind { 0x2F => break, // End of Track - 0x03 => { - if name.is_none() { - name = Some(String::from_utf8_lossy(data).into_owned()); - } + 0x03 if name.is_none() => { + name = Some(String::from_utf8_lossy(data).into_owned()); } 0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())), 0x51 => { diff --git a/crates/midi/tests/smf.rs b/crates/midi/tests/smf.rs index 5b89e34..f1861d8 100644 --- a/crates/midi/tests/smf.rs +++ b/crates/midi/tests/smf.rs @@ -91,6 +91,24 @@ fn round_trip() { assert_eq!(write_smf(&again).unwrap(), bytes); } +#[test] +fn first_track_name_wins_and_ignored_meta_preserves_following_notes() { + let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat(); + for first in [&b"First"[..], &b""[..], &[0xFF][..]] { + let body = [ + meta(0x03, first), + meta(0x03, b"Second"), + meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored. + vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0], + ] + .concat(); + let smf = read_smf(&file(0, 960, &[body])).unwrap(); + assert_eq!(smf.tracks.len(), 1); + assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first)); + assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]); + } +} + #[test] fn write_rejects_bad_values() { let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() }; diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 20591fe..26d0ba9 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -410,12 +410,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { let ins = track.instrument.as_ref(); let info = ins.and_then(|i| plugin_info(&i.plugin)); let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click()); - let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg }; + let fill = if info.is_some() { t.insert_on } else { t.slot_bg }; ui.painter().rect( r, CornerRadius::same(2), if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }, - Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), + Stroke::new(1.0, t.slot_border), StrokeKind::Inside, ); ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text); diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs new file mode 100644 index 0000000..fb33610 --- /dev/null +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -0,0 +1,172 @@ +use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; + +#[derive(Debug)] +struct Slot { + rect: Rect, + fill: Color32, + text: Color32, + border: Color32, +} + +// Read the rectangle and foreground emitted by the real Mix drawing path, rather than +// testing a palette token that a custom-painted control might never use. +fn slot(output: &FullOutput, label: &str) -> Slot { + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Text(text) if text.galley.job.text == label => Some(text), + _ => None, + }) + .unwrap_or_else(|| panic!("missing painted label {label}")); + let center = text.visual_bounding_rect().center(); + let rect = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect), + _ => None, + }) + .unwrap_or_else(|| panic!("missing painted slot for {label}")); + let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color); + assert_eq!(text.opacity_factor, 1.0); + assert_eq!(foreground.a(), 255); + assert_eq!(rect.fill.a(), 255); + Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color } +} + +fn contrast(a: Color32, b: Color32) -> f64 { + let luminance = |color: Color32| { + let linear = |channel: u8| { + let s = f64::from(channel) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +#[test] +fn populated_mix_instrument_uses_readable_palette_in_real_frames() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + assert_eq!(app.ui.theme, ThemeMode::Dark); + // Keep demo audio loaded for the preservation check while isolating the new strips. + for track in &mut app.engine.session_mut().tracks { + track.hidden = true; + } + app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap(); + let instrument = app.engine.session().edit.selected_tracks[0]; + assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth"); + app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap(); + app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap(); + // An unassigned instrument is a supported model state; track.new normally supplies a synth. + let empty = app.engine.session().edit.selected_tracks[0]; + app.engine.session_mut().track_mut(empty).unwrap().instrument = None; + app.run("window.mix", json!({})).unwrap(); + app.run("view.mix_section", json!({"section": "minimal"})).unwrap(); + for section in ["instruments", "inserts_ae"] { + app.run("view.mix_section", json!({"section": section})).unwrap(); + } + let session = app.engine.session().clone(); + assert!(!session.pool.is_empty(), "the fixture must include decoded audio"); + let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from); + let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0); + if proof_dir.is_some() { + builder = builder.wgpu(); + } + let mut harness = builder.build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + harness.input_mut().max_texture_side = Some(8192); + let mut failures = Vec::new(); + for (name, mode, system, light) in [ + ("light", ThemeMode::Light, Some(egui::Theme::Dark), true), + ("dark", ThemeMode::Dark, Some(egui::Theme::Light), false), + ("system-light", ThemeMode::System, Some(egui::Theme::Light), true), + ("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false), + ] { + harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap(); + harness.input_mut().system_theme = system; + harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0))); + for _ in 0..4 { + harness.step(); + } + let ordinary = slot(harness.output(), "SubSynth"); + let insert = slot(harness.output(), "EQ7"); + let empty = slot(harness.output(), "no instrument"); + let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK }; + assert_eq!(insert.fill, palette.insert_on); + assert_eq!(empty.fill, palette.slot_bg); + assert_eq!(ordinary.text, palette.text); + if ordinary.fill != insert.fill || ordinary.border != insert.border { + failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}")); + } + for hovered in [false, true] { + if hovered { + harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center())); + harness.step(); + harness.step(); + } + let painted = slot(harness.output(), "SubSynth"); + let ratio = contrast(painted.text, painted.fill); + println!( + "{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}", + painted.text.to_array(), + painted.fill.to_array(), + painted.rect + ); + if ratio < 4.5 { + failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1")); + } + if !light { + let original = Color32::from_rgb(52, 62, 80); + assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original }); + assert_eq!(painted.border, Color32::from_rgb(16, 16, 16)); + } + if let Some(dir) = &proof_dir { + harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap(); + } + } + assert_eq!(harness.state().ui.theme, mode); + assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool"); + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + + // Exercise the same painted control's ordinary click/picker path after palette changes. + let at = slot(harness.output(), "SubSynth").rect.center(); + for pressed in [true, false] { + harness.input_mut().events.push(Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed, + modifiers: Default::default(), + }); + harness.step(); + } + harness.step(); // Let the popup finish its initial sizing pass. + harness.get_by_label_contains("Built-in").hover(); + harness.step(); + harness.step(); + harness.get_by_label("Drum Synth").click(); + harness.step(); + harness.step(); + let app = harness.state(); + let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap(); + assert_eq!(replacement.plugin, "drum_synth"); + for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params { + assert_eq!(replacement.params.get(param.id), Some(¶m.default)); + } + assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks); + assert_eq!(app.engine.session().pool, session.pool); + assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker"); + harness.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(harness.state().engine.session(), &session); +} From 75d3901ab431a61f69c5e5f275308cbbe9fb47c0 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 02:03:08 -0400 Subject: [PATCH 34/46] CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock, which is nearly every PR. See craftrules release/playbook.md §2. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/freebsd.yml | 12 ++++++++---- .github/workflows/windows-arm64.yml | 12 +++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8974daf..9a71640 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -3,16 +3,20 @@ # player falls back to its silent clock when the VM has no sound device. name: FreeBSD +# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push +# or dependency change: a drifted dependency still shows up within a day, and always before a +# release ships. PRs that change the FreeBSD packaging itself still run it. on: + schedule: + - cron: "47 3 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - "crates/playback/**" - .github/workflows/freebsd.yml + - packaging/freebsd/** + - packaging/env.sh workflow_dispatch: concurrency: diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index bafdca6..1dc25eb 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -4,19 +4,17 @@ # out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) name: Windows ARM64 +# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a +# day, and always before a release ships), and on PRs that change the Windows packaging itself. on: + schedule: + - cron: "53 5 * * *" push: - branches: [main] + branches: [release] pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - .github/workflows/windows-arm64.yml - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up workflow_dispatch: concurrency: From b2a3b0f861a5fec4e2cc34d5eeb1bcc46b9be79a Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:10:06 +0100 Subject: [PATCH 35/46] Keep armed tracks' meters live while the transport is stopped Record-enabled and input-monitored tracks were heard while stopped, but their meters froze (#42). The audio thread returned before copying the meters whenever it wasn't playing. The UI faded every meter to zero while stopped and stopped redrawing once they reached zero. After a selection had played to its end, the stale end point also silenced input monitoring until the next Play. The audio thread now copies the meters for every block it renders and forgets the end point and loop when playback stops. While a track is armed or monitoring input, the UI keeps feeding and redrawing its meters, and the Mix window's per-channel meters include armed tracks. --- crates/playback/src/lib.rs | 38 +++++++++++++++++++++++++++----- crates/ui-egui/src/lib.rs | 10 +++++++-- crates/ui-egui/src/mix_window.rs | 6 +---- 3 files changed, 42 insertions(+), 12 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..a48934d 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -171,6 +171,8 @@ impl AudioState { } Cmd::Stop => { self.playing = false; + self.end = None; + self.looped = None; self.mix.reset(); } Cmd::Locate(at) => { @@ -209,6 +211,7 @@ impl AudioState { } else if let Some(e) = self.end { if self.pos >= e { self.playing = false; + self.end = None; self.shared.playing.store(false, Ordering::Relaxed); n = 0; } else { @@ -239,11 +242,6 @@ impl AudioState { } } self.pending_len = n; - if !self.playing { - return; - } - self.pos += n as i64; - self.shared.position.store(self.pos, Ordering::Relaxed); if let Ok(mut m) = self.shared.meters.try_lock() { m.main.copy_from(&self.mix.main_meter); for (k, v) in &self.mix.meters { @@ -255,6 +253,10 @@ impl AudioState { } } } + if self.playing { + self.pos += n as i64; + self.shared.position.store(self.pos, Ordering::Relaxed); + } } /// Fill an interleaved device buffer. @@ -896,4 +898,30 @@ mod tests { assert!(p <= 1000, "position {p} should stay inside the loop"); assert!(shared.playing.load(Ordering::Relaxed)); } + + #[test] + fn armed_tracks_are_heard_and_metered_after_playback_stops() { + use soundcraft_model::{ChannelFormat, TrackKind}; + let mut s = soundcraft_model::Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.track_mut(t).unwrap().mixer.record_arm = true; + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0); + let ring = record::InputRing::new(1, 48_000); + tx.send(Cmd::Input(Arc::clone(&ring))).unwrap(); + tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap(); + let mut out = vec![0.0f32; 4096]; + st.fill(&mut out, 2); + assert!(!shared.playing.load(Ordering::Relaxed)); + ring.push(&[0.5; 4096]); + st.fill(&mut out, 2); + assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard"); + assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered"); + } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..72f9c6d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -512,6 +512,12 @@ impl SoundApp { best.map(|(_, p)| p) } + /// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard. + fn meters_live(&self) -> bool { + self.engine.transport.playing + || (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)) + } + /// Open the input device once (for recording and input monitoring) and feed the player. fn ensure_input(&mut self) { if self.recorder.is_some() || self.recorder_failed || self.player.is_none() { @@ -749,7 +755,7 @@ impl SoundApp { self.stop_play(); } } - if !self.engine.transport.playing { + if !self.meters_live() { for d in self.meters.values_mut() { feed_meter(d, [0.0, 0.0], 0.0, dt); } @@ -805,7 +811,7 @@ impl SoundApp { { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + if self.meters_live() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { ctx.request_repaint(); } control::collect_screenshots(self, ctx); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 0a6b84e..1173d6b 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -22,11 +22,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { let ids: Vec = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect(); // Per-channel peaks for multichannel meters (read once per frame). - let snap = app - .player - .as_ref() - .filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor)) - .map(|p| p.meters()); + let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters()); egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| { ui.horizontal_top(|ui| { ui.spacing_mut().item_spacing.x = 1.0; From 7208c610cd2618eaef2e59608e7f438d9c3bbd42 Mon Sep 17 00:00:00 2001 From: grovebit <337789029+grovebit@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:14:15 +0100 Subject: [PATCH 36/46] Make Clear All Clip Indicators clear them Track > Clear All Clip Indicators (Alt+C) was a stub: the red clip indicators on the meters stayed lit until another session was opened. The command now clears them on every track's meter and the main meter. --- crates/ui-egui/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 72f9c6d..fe62e80 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -329,6 +329,9 @@ impl SoundApp { // Re-selecting a scrolling mode resumes the playhead follow. self.edit_layout.follow_hold = false; } + if id == "track.clear_clip_indicators" { + self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false); + } Ok(v) } Err(e) => { @@ -985,6 +988,16 @@ mod tests { assert!(!app.engine.is_dirty()); } + #[test] + fn clear_all_clip_indicators_clears_every_meter() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.meters.entry(kick).or_default().clip = true; + app.main_meter.clip = true; + app.run("track.clear_clip_indicators", json!({})).unwrap(); + assert!(!app.meters[&kick].clip && !app.main_meter.clip); + } + #[test] fn automation_first_pass_after_start_is_due_without_overflow() { // `last_write_at` starts (and resets) at `i64::MIN`: the previous From 240aa848a04811ffff75cf193e69d9d4c404ae68 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 11 Oct 2026 11:52:00 -0400 Subject: [PATCH 37/46] ci: refactor workflows to use storytold/craft-common-actions (#184) * ci: refactor workflows to use reusable artcraft-common-actions * ci: point reusable workflows to storytold/craft-common-actions * ci: pin storytold/craft-common-actions to commit 27a978f and pass secrets explicitly * ci: use @main (unpinned) for craft-common-actions with explanation comment --- .github/workflows/freebsd.yml | 26 +- .github/workflows/packaging-lint.yml | 48 +-- .github/workflows/release.yml | 443 +-------------------------- .github/workflows/windows-arm64.yml | 72 +---- 4 files changed, 26 insertions(+), 563 deletions(-) diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index 8974daf..dd5767b 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -24,27 +24,5 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@v4 - - name: Build and test in a FreeBSD VM - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main \ No newline at end of file diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index ce126e3..f54b053 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -19,49 +19,5 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@v4 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/soundcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - if grep -oE '"$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" - appstreamcli validate --no-net --explain "$RUNNER_TEMP/ai.storyteller.soundcraft.metainfo.xml" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.soundcraft" and m["command"] == "soundcraft", m["id"] - assert "--socket=pulseaudio" in m["finish-args"], "audio needs --socket=pulseaudio" - for key in ("runtime", "runtime-version", "sdk", "finish-args", "add-extensions"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0fec370..084c253 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,11 +1,3 @@ -# Release pipeline: every push to `release` builds signed installers for every platform and -# creates or updates the draft GitHub Release "SoundCraft v". See docs/releasing.md. -# -# The jobs that sign (macOS, Windows) and the one that drafts the release run in the `release` -# environment (restricted to the `release` branch), which holds the signing secrets. Every secret -# is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# other jobs sign nothing and get no secrets, so a dispatch on any branch builds and checks them -# (a dry run); the signed jobs are refused there, and so the draft release is skipped. name: Release on: @@ -19,429 +11,26 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - SOUNDCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release`: this job reads no secrets (and gates every other job). - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@v4 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### SoundCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@v2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@v4 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - uses: Swatinem/rust-cache@v2 - with: - key: release-windows-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - shell: bash - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build, sign, package - shell: pwsh - env: - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} - AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} - run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }} - - uses: actions/upload-artifact@v4 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@v4 - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - libasound2-dev pkg-config \ - desktop-file-utils appstream python3-yaml zsync - curl -fsSL -o "$RUNNER_TEMP/nfpm.deb" \ - "https://github.com/goreleaser/nfpm/releases/download/v${NFPM_VERSION}/nfpm_${NFPM_VERSION}_${{ matrix.deb }}.deb" - sudo dpkg -i "$RUNNER_TEMP/nfpm.deb" - - uses: dtolnay/rust-toolchain@stable - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/soundcraft$|metainfo|applications' - ldd target/release/soundcraft - # cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml). - ldd target/release/soundcraft | grep -q libasound - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.soundcraft.yml')); assert m['id'] == 'ai.storyteller.soundcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|storytold|soundcraft|latest|soundcraft-*-linux-${{ matrix.arch }}.AppImage.zsync' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - # No `environment: release`: this job signs nothing, so it gets no release secrets. - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - - name: Build the Flatpak bundle - run: packaging/linux/flatpak-bundle.sh "dist/linux/soundcraft-$SOUNDCRAFT_VERSION-linux-${{ matrix.arch }}.tar.gz" - - uses: actions/upload-artifact@v4 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI (which links no ALSA) is smoke-tested under QEMU. Plugin hosting (CLAP, VST3) is - # pure-Rust bindings plus dlopen, so it builds here unchanged; cpal uses ALSA (no JACK). - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@v2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - # The riscv64 twins of the linux job's -dev packages; libasound2-dev is cpal's ALSA backend. - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - pkg-config file desktop-file-utils appstream \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 - - name: Build and package (tar.gz) - env: - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - name: Check the package - run: | - file target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'RISC-V' - # cpal's ALSA backend: the one audio library linked at build time (see nfpm.yaml). - riscv64-linux-gnu-readelf -d target/riscv64gc-unknown-linux-gnu/release/soundcraft | grep -q 'libasound' - tar -tzf "dist/release/soundcraft-$SOUNDCRAFT_VERSION-linux-riscv64.tar.gz" | grep -E 'bin/soundcraft$' - - uses: actions/upload-artifact@v4 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM (same image as freebsd.yml). - runs-on: ubuntu-latest - timeout-minutes: 150 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - if: inputs.version != '' - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Build and package in a FreeBSD VM - # v1.5.9, pinned by commit. - uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc - with: - release: "14.3" - usesh: true - mem: 12288 - # Passed through by name; never templated into the script. - envs: "SOUNDCRAFT_VERSION SOUNDCRAFT_BUILD_DATE SOUNDCRAFT_BUILD_SHA" - # alsa-lib: cpal's audio backend on FreeBSD (ALSA over OSS via alsa-plugins-oss at runtime). - prepare: | - pkg install -y bash curl git pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM. - export CARGO_TARGET_DIR=/var/tmp/soundcraft-target CARGO_BUILD_JOBS=4 - bash packaging/freebsd/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-freebsd - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - SOUNDCRAFT_VERSION: ${{ needs.version.outputs.version }} - SOUNDCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - TRUNK_VERSION: 0.21.14 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@v2 - with: - key: release-web - - name: Install trunk - run: | - curl -fsSL "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" \ - | tar -xz -C "$HOME/.cargo/bin" trunk - trunk --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$SOUNDCRAFT_VERSION" - - name: Build and zip - run: packaging/web/package.sh - - uses: actions/upload-artifact@v4 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - # Needs the signed jobs, which only run on the `release` branch: a branch dispatch never drafts. - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@v4 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="SoundCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: soundcraft + app-title: SoundCraft + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index bafdca6..b8db87f 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -26,70 +26,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d # stable - with: - targets: aarch64-pc-windows-msvc - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@v4 - with: - name: soundcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@v4 - with: - name: soundcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'SoundCraft' - foreach ($exe in 'soundcraft.exe', 'soundcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'soundcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "soundcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok soundcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'soundcraft.exe')) { throw 'uninstall left soundcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: soundcraft + app-dir: SoundCraft From 27d934e726e4a8373f7db3ebdd05f806b8378d13 Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:09:39 +0200 Subject: [PATCH 38/46] macOS: build the DMG with hdiutil create so the app passes codesign --strict hdiutil makehybrid -hfs put a com.apple.FinderInfo on every file in the image, so the signed app inside the DMG (and any ditto copy of it) failed codesign --verify --deep --strict. Build the image with hdiutil create -srcfolder (retried, since create can hit "Resource busy" on CI), clear build-time xattrs before signing, and verify the app inside the finished DMG and a ditto copy of it with --strict. Ported from storytold/pdfcraft#823 and storytold/lightcraft#959. Fixes #176 Co-Authored-By: Claude Opus 5.5 --- packaging/macos/dmg/README.md | 4 +-- packaging/macos/package.sh | 55 ++++++++++++++++++++++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/packaging/macos/dmg/README.md b/packaging/macos/dmg/README.md index 5bbc169..0cacbb7 100644 --- a/packaging/macos/dmg/README.md +++ b/packaging/macos/dmg/README.md @@ -2,8 +2,8 @@ What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the `Applications` link side by side. `package.sh` copies these files into the image; nothing here is -generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no -Finder scripting on CI, nothing extra installed in the signing job). +generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder +scripting on CI, nothing extra installed in the signing job). | File | What | |---|---| diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index a898e3d..8208aac 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -109,6 +109,10 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \ plutil -lint "$APP/Contents/Info.plist" printf 'APPL????' >"$APP/Contents/PkgInfo" +# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing: +# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle. +xattr -cr "$APP" + # Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature). # Today the only nested code is the main executable; frameworks/helpers would be signed here too. sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft" @@ -133,15 +137,52 @@ ln -s /Applications "$STAGE/Applications" mkdir -p "$STAGE/.background" cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff" cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store" -rm -f "$DMG" "$WORK/raw.dmg" -# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`, -# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions. -# The volume name has no version: .DS_Store finds the background through an alias that includes it. -hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE" -hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG" -rm -f "$WORK/raw.dmg" +rm -f "$DMG" +# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a +# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or +# similar detritus" on the app in the image and on a copy installed from it (#176). `create` +# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so +# it gets a few tries. The volume name has no version: .DS_Store finds the background through an +# alias that includes it. +# Usage: hdiutil_retry ; tries five times with growing pauses. +hdiutil_retry() { + local what="$1" attempt + shift + for attempt in 1 2 3 4 5; do + if hdiutil "$@"; then + return 0 + fi + [ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; } + warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s" + sleep $((attempt * 10)) + done +} +hdiutil_retry create create -srcfolder "$STAGE" -volname "SoundCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG" sign "$DMG" codesign --verify --strict --verbose=2 "$DMG" +# The app users get must still pass --strict: inside the finished image and after a ditto copy out +# of it, so a valid container signature can't hide a damaged app (or a makehybrid-style regression). +MOUNT="$WORK/dmg-check" +COPY="$WORK/dmg-copy" +rm -rf "$MOUNT" "$COPY" +mkdir -p "$MOUNT" "$COPY" +hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null +dmg_ok=1 +codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0 +if [ "$dmg_ok" = 1 ]; then + { ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" && + codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0 +fi +if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then + spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0 +fi +[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true +hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0 +rm -rf "$COPY" +if [ "$dmg_ok" != 1 ]; then + echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2 + exit 1 +fi if [ "$NOTARIZE" = 1 ]; then notarize "$DMG" xcrun stapler staple "$DMG" From e102ed36567a9d3418b9436e1923494d2e4d22da Mon Sep 17 00:00:00 2001 From: Fabio Lussi <198260595+itrycodinganditsbad@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:37:56 +0200 Subject: [PATCH 39/46] Windows: compile DX12 shaders with FXC wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL search path finds. SoundCraft ships none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's indirect-validation pipeline, the device is lost and the app exits before the first frame. Pin the compiler to FXC, which is part of Windows; WGPU_DX12_COMPILER still overrides it. Fixes #185 Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++ apps/soundcraft/src/graphics.rs | 56 ++++++++++++++++++++++++++++++--- apps/soundcraft/src/main.rs | 4 +-- 3 files changed, 57 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 33d08f1..b297d75 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux and web backend defaults are unchanged. +DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or +`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"` +(or `"auto"`) selects wgpu's DXC handling again for troubleshooting. + ### macOS | Build | File | Notes | diff --git a/apps/soundcraft/src/graphics.rs b/apps/soundcraft/src/graphics.rs index ccd0c45..2a932a6 100644 --- a/apps/soundcraft/src/graphics.rs +++ b/apps/soundcraft/src/graphics.rs @@ -1,10 +1,14 @@ //! Windows graphics instance configuration, applied before wgpu can initialize any driver. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{Backends, Dx12Compiler}, +}; -/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally -/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { +/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit +/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence. +pub fn configure(options: &mut NativeOptions, backend_override: Option, compiler_override: Option) { if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { // eframe's default includes GL. Creating that backend can crash inside AMD's // atio6axx.dll before adapter selection or any Rust error handling runs, so the @@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option // out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12 // workaround without mutating process environment variables. create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12); + // Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships + // none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's + // indirect-validation pipeline and loses the device before the first frame + // (issue #185). FXC uses the compiler that is part of Windows. + create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc); + log::info!( + "Windows graphics: backends={:?}, DX12 compiler={:?}", + create.instance_descriptor.backends, + create.instance_descriptor.backend_options.dx12.shader_compiler + ); } } @@ -21,7 +35,7 @@ mod tests { fn configured_backends(backend_override: Option) -> Backends { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + configure(&mut options, backend_override, None); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; @@ -38,6 +52,38 @@ mod tests { assert_eq!(configured_backends(None), Backends::DX12); } + #[test] + fn windows_default_uses_fxc_instead_of_searching_for_dxc() { + let mut options = NativeOptions::default(); + if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + // Auto can find an unrelated DXC without its matching DXIL runtime (issue #185). + create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto; + } + configure(&mut options, None, None); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc)); + } + + #[test] + fn explicit_compiler_override_is_preserved() { + for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] { + let mut options = NativeOptions::default(); + configure(&mut options, None, Some(compiler.clone())); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) { + (Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {} + (Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => { + assert_eq!(actual, expected); + } + (expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"), + } + } + } + #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a54e7e1..8004acf 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -225,9 +225,9 @@ fn main() -> eframe::Result { if let Some(icon) = app_icon() { options.viewport = options.viewport.with_icon(icon); } - // Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs). + // Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs). #[cfg(target_os = "windows")] - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); + graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env()); eframe::run_native( "SoundCraft", options, From e60c41c2c83bbc9879f711db660b52a4edf39194 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 18:03:21 -0400 Subject: [PATCH 40/46] Release workflow: pass the Azure signing secrets so Windows builds are signed Since the release workflow moved to storytold/craft-common-actions, it has passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads values from the release environment, but only for names the caller lists, so the AZURE_* names that Trusted Signing uses came through empty and Windows builds would ship unsigned. Same fix as storytold/craft-launcher#174. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 084c253..d60e820 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,3 +34,9 @@ jobs: APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} From eddc846f5137b893bd2c92be45c2ac96d94f101e Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 19:33:37 -0300 Subject: [PATCH 41/46] perf: adapt meter polling and repaint rate --- crates/playback/src/lib.rs | 30 ++++++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 20 ++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..fd23f1a 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -633,6 +633,21 @@ impl Player { pub fn meters(&self) -> MeterSnapshot { self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone() } + + /// Copy meters into reusable UI storage. Once track/channel counts are stable this performs + /// no allocation, unlike cloning a new snapshot every video frame. + pub fn copy_meters(&self, out: &mut MeterSnapshot) { + let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner); + copy_meter_snapshot(&source, out); + } +} + +fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { + out.tracks.retain(|id, _| source.tracks.contains_key(id)); + for (id, meter) in &source.tracks { + out.tracks.entry(*id).or_default().copy_from(meter); + } + out.main.copy_from(&source.main); } impl Side { @@ -730,6 +745,21 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre mod tests { use super::*; + #[test] + fn reusable_meter_snapshot_updates_and_drops_stale_tracks() { + let keep = TrackId(1); + let stale = TrackId(2); + let mut source = MeterSnapshot::default(); + source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 }); + source.main.peaks = vec![0.75, 0.5]; + let mut out = MeterSnapshot::default(); + out.tracks.insert(stale, StripMeter::default()); + copy_meter_snapshot(&source, &mut out); + assert_eq!(out.tracks.len(), 1); + assert_eq!(out.tracks[&keep], source.tracks[&keep]); + assert_eq!(out.main, source.main); + } + #[test] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 955bd5a..b991aef 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -220,6 +220,8 @@ pub struct SoundApp { pub dialogs: dialogs::Dialogs, pub meters: HashMap, pub main_meter: MeterDisplay, + /// Reused multichannel snapshot; populated at an adaptive rate by `logic`. + meter_snapshot: soundcraft_playback::MeterSnapshot, pub gesture: Option, pub edit_layout: edit_window::EditLayout, pub midi: midi_editor::MidiEditorState, @@ -247,6 +249,7 @@ pub struct SoundApp { last_write_at: Samples, pub frame_ms: f32, last_frame: Option, + last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, } @@ -280,6 +283,7 @@ impl SoundApp { dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), main_meter: MeterDisplay::default(), + meter_snapshot: soundcraft_playback::MeterSnapshot::default(), gesture: None, edit_layout: edit_window::EditLayout::default(), midi: midi_editor::MidiEditorState::default(), @@ -302,6 +306,7 @@ impl SoundApp { last_write_at: i64::MIN, frame_ms: 0.0, last_frame: None, + last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), } } @@ -725,7 +730,13 @@ impl SoundApp { if self.engine.transport.playing { self.engine.transport.position = p.position(); } - let snap = p.meters(); + let now = self.last_frame.unwrap_or_default(); + let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 }; + if now - self.last_meter_poll >= meter_interval { + p.copy_meters(&mut self.meter_snapshot); + self.last_meter_poll = now; + } + let snap = &self.meter_snapshot; for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default(); feed_meter(d, m.peak, m.gain_reduction_db, dt); @@ -805,7 +816,12 @@ impl SoundApp { { self.ensure_input(); } - if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() { + if self.is_playing() { + 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(); } control::collect_screenshots(self, ctx); From 9850a41f2993c8f2f6181782247994a678458c1f Mon Sep 17 00:00:00 2001 From: Jafz2001 Date: Sun, 11 Oct 2026 19:36:39 -0300 Subject: [PATCH 42/46] perf: reuse realtime mix buffers --- crates/mix/src/lib.rs | 59 ++++++++++++++++++++++++++++---------- crates/playback/src/lib.rs | 37 +++++++++++++++--------- 2 files changed, 68 insertions(+), 28 deletions(-) diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ef6b8ef..967b29e 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -308,6 +308,8 @@ pub struct MixEngine { adopt: HashMap<(TrackId, usize), PreparedInstance>, /// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]). retired: Vec>, + /// Ownership workspace reused for parallel strip processing. + work: Vec<(usize, Strip)>, } /// What `sync` needs to fill a slot. @@ -370,6 +372,7 @@ impl MixEngine { external: false, adopt: HashMap::new(), retired: Vec::new(), + work: Vec::new(), } } @@ -568,16 +571,17 @@ impl MixEngine { } let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive); let order = std::mem::take(&mut self.order); - let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); + let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca); let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); // Phase 1: independent strips (audio, instrument, MIDI) in parallel. - let mut work: Vec<(&Track, Strip)> = order - .iter() - .filter_map(|&i| s.tracks.get(i)) - .filter(live) - .filter(|t| !is_aux(t)) - .filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st))) - .collect(); + let mut work = std::mem::take(&mut self.work); + work.clear(); + for &i in &order { + let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue }; + if let Some(st) = self.strips.remove(&t.id) { + work.push((i, st)); + } + } for (_, st) in work.iter_mut() { if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); @@ -585,7 +589,11 @@ impl MixEngine { } let busses = std::mem::take(&mut self.busses); let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording }; - run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo)); + run_strips(&mut work, |(i, st)| { + if let Some(t) = s.tracks.get(*i) { + process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo); + } + }); self.busses = busses; // Delay compensation: align every strip to the slowest; direct-to-main outputs also wait // for the slowest aux return (aux latencies are from the previous block; they are stable). @@ -594,7 +602,8 @@ impl MixEngine { let lmax_a = if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 }; self.latency = lmax_t + lmax_a; - for (t, st) in work.iter_mut() { + for (i, st) in work.iter_mut() { + let Some(t) = s.tracks.get(*i) else { continue }; let ch = st.buf.len(); st.align.set(lmax_t.saturating_sub(st.latency), ch); st.align.process(&mut st.buf, frames); @@ -604,15 +613,20 @@ impl MixEngine { } st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch); } - for (t, st) in work.iter_mut() { - self.route_strip(t, st, pos, frames); + for (i, st) in work.iter_mut() { + if let Some(t) = s.tracks.get(*i) { + self.route_strip(t, st, pos, frames); + } } - for (t, st) in work { - self.strips.insert(t.id, st); + for (i, st) in work.drain(..) { + if let Some(t) = s.tracks.get(i) { + self.strips.insert(t.id, st); + } } + self.work = work; let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder); // Phase 2: auxes in dependency order (they read busses). - for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) { + for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) { let Some(mut st) = self.strips.remove(&t.id) else { continue }; if st.scratch.len() < self.max_block { st.scratch.resize(self.max_block, 0.0); @@ -2085,6 +2099,21 @@ mod tests { assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + #[test] + fn strip_workspace_keeps_its_allocation_between_blocks() { + let mut s = Session::default(); + for _ in 0..6 { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + } + let mut eng = MixEngine::new(48_000.0, 512); + let mut out = vec![vec![0.0; 512]; 2]; + eng.render(&s, 0, 512, &mut out); + let capacity = eng.work.capacity(); + assert!(capacity >= 6); + eng.render(&s, 512, 512, &mut out); + assert_eq!(eng.work.capacity(), capacity); + } + #[test] fn stopped_monitoring_does_not_click() { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index eb1db9b..c429f84 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -86,7 +86,6 @@ struct AudioState { playing: bool, end: Option, looped: Option, - block: Vec>, /// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index. pending: Vec>, /// Stereo fold-down gains per main channel (used when the device has fewer channels). @@ -122,7 +121,6 @@ impl AudioState { playing: false, end: None, looped: None, - block: vec![vec![0.0; BLOCK]; nch], pending: vec![vec![0.0; BLOCK]; nch], pending_len: 0, read: 0.0, @@ -151,8 +149,7 @@ impl AudioState { self.mix.adopt(instances); // The main format changed (a document edit, not steady state): resize. let nch = main_channels(&s); - if self.block.len() != nch { - self.block = vec![vec![0.0; BLOCK]; nch]; + if self.pending.len() != nch { self.pending = vec![vec![0.0; BLOCK]; nch]; self.pending_len = 0; self.read = 0.0; @@ -197,8 +194,8 @@ impl AudioState { } /// Render the next block at the session rate into `pending`. - fn render_next(&mut self) { - let mut n = BLOCK; + fn render_next(&mut self, quantum: usize) { + let mut n = quantum.clamp(1, BLOCK); if let Some(l) = self.looped && self.pos >= l.end { @@ -231,13 +228,8 @@ impl AudioState { } self.mix.monitor_only = !self.playing; self.mix.recording = self.recording; - self.mix.render(&self.session, self.pos, n, &mut self.block); + self.mix.render(&self.session, self.pos, n, &mut self.pending); self.send_garbage(); - for (d, s) in self.pending.iter_mut().zip(self.block.iter()) { - if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) { - dd.copy_from_slice(ss); - } - } self.pending_len = n; if !self.playing { return; @@ -272,13 +264,15 @@ impl AudioState { } let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0; let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0); + let callback_frames = out.len() / channels; + let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK); for frame in out.chunks_mut(channels) { while self.read >= self.pending_len as f64 { self.read -= self.pending_len as f64; if !self.playing && !monitoring { break; } - self.render_next(); + self.render_next(quantum); if self.pending_len == 0 { break; } @@ -748,6 +742,23 @@ mod tests { assert!(!shared.playing.load(Ordering::Relaxed)); } + #[test] + fn short_device_callbacks_render_short_mix_blocks() { + let s = Arc::new(soundcraft_model::Session::default()); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + let mut out = vec![0.0f32; 2 * 64]; + st.fill(&mut out, 2); + assert_eq!(shared.position.load(Ordering::Relaxed), 64); + } + #[test] fn surround_main_maps_to_device_channels_or_folds_down() { use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind}; From 113292cc1ae450e4929d00dce6a4dc9b0fab2081 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:43:40 -0400 Subject: [PATCH 43/46] Close the export-clips test the rebase left open Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6ebc4c2..fe00b94 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -426,18 +426,7 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); - - fn tone_engine() -> (Engine, u64) { - let mut e = Engine::default(); - e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap(); - let track = e.session().tracks[0].id; - let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect(); - let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; - let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav); - let id = e.session_mut().new_clip_id(); - crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800)); - e.session_mut().edit.selected_tracks = vec![track]; - (e, track.0) + let _ = std::fs::remove_dir_all(&dir); } #[test] From 75430d2472b57d369c6ebba40179e11c4e732af3 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:43:41 -0400 Subject: [PATCH 44/46] Close the export-clips test the rebase left open Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 2d24472..4836209 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -424,7 +424,10 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn save_copy_leaves_the_open_session_alone() { let (mut e, _) = tone_engine(); let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id())); From 55dfcc95d1087001c8ed3d82ac662dfab34a456c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 11 Oct 2026 19:43:41 -0400 Subject: [PATCH 45/46] Close the export-clips test the rebase left open Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/file.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4b189fd..e37600a 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -427,6 +427,8 @@ mod tests { let mut wavs: Vec<_> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); wavs.sort(); assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } fn midi_engine() -> Engine { use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence}; From e7c31321f37d7f91746fda6189427c9aedce7b5c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 03:26:29 -0400 Subject: [PATCH 46/46] Volume to clip gain: keep the level outside the selection; keep clip gain points; ramps reach their end - When every lane point lay inside the selection the lane was cleared, so the held level outside fell back to the fader (6 dB louder in the repro). Clear only when all clips lie inside the selection. - The clip's own gain points inside the selection are kept, offset by the lane, instead of being dropped. - Volume/clip-gain ramps now end on the block's last sample, so interpolate over n-1 steps and actually reach the end value. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/mix.rs | 35 ++++++++++++++++++++++++++++++++++- crates/mix/src/lib.rs | 4 ++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 37e1a0f..3a01d3d 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -579,6 +579,10 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { let at = rel(pt.at); pts.push((at, env_db(&c.gain_env, at) + delta(pt.at))); } + // The clip's own gain points inside the selection keep their shape, offset by the lane. + for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) { + pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at)))); + } if sel1 - 1 > sel0 { let at = rel(sel1 - 1); pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1))); @@ -597,7 +601,10 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result { c.gain_env = env; } } - let covers_all = lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); + // Clearing the lane is only safe when no audio plays outside the selection; otherwise the + // held edge values outside would fall back to the fader level. + let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end); + let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end); let lane_m = tr.lane_mut(&AutoParam::Volume); if covers_all { lane_m.clear_range(r.start, r.end); @@ -758,4 +765,30 @@ mod tests { assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}"); } } + + #[test] + fn lane_points_all_inside_the_selection_keep_the_outside_level() { + let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]); + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB"); + } + } + + #[test] + fn clip_gain_points_inside_the_selection_are_kept() { + let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]); + let clip = e.session().track(t).unwrap().clips()[0].id; + e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)]; + let before = rendered(e.session()); + convert(&mut e, t, 2048, 6144); + let after = rendered(e.session()); + for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] { + let delta = rms_db(&before[start..end], &after[start..end]); + assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB"); + } + } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index a0297d3..2f7b75d 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -905,7 +905,7 @@ fn process_strip( for c in strip.buf.iter_mut() { for i in 0..frames { if let Some(x) = c.get_mut(i) { - *x *= lerp_gain(g0, g1, i, frames); + *x *= lerp_gain(g0, g1, i, frames.saturating_sub(1)); } } } @@ -1210,7 +1210,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start); let g0 = clip.gain_at(rel0); let g1 = clip.gain_at(rel1.min(clip.length)); - let n = (seg_end - i) as f32; + let n = (seg_end - i).saturating_sub(1).max(1) as f32; for k in i..seg_end { let rel = pos.saturating_add(k as i64).saturating_sub(clip.start); let v = if (stretch - 1.0).abs() < 1e-9 {