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
+
+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 {