diff --git a/.gitignore b/.gitignore
index 75bd19e..901797f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,4 @@ plan/
/target-*/
/craft-fonts/
plans/
+.DS_Store
diff --git a/Cargo.lock b/Cargo.lock
index e6719ef..0642d45 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -464,6 +464,9 @@ name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+dependencies = [
+ "serde_core",
+]
[[package]]
name = "block2"
@@ -788,6 +791,15 @@ dependencies = [
"cfg-if",
]
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1"
+dependencies = [
+ "crossbeam-utils",
+]
+
[[package]]
name = "crossbeam-deque"
version = "0.8.8"
@@ -825,6 +837,23 @@ 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"
@@ -1290,6 +1319,15 @@ 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"
@@ -1830,6 +1868,17 @@ dependencies = [
"wasm-bindgen",
]
+[[package]]
+name = "keyboard-types"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
+dependencies = [
+ "bitflags 2.13.2",
+ "serde",
+ "unicode-segmentation",
+]
+
[[package]]
name = "khronos-egl"
version = "6.0.0"
@@ -2017,6 +2066,25 @@ dependencies = [
"pxfm",
]
+[[package]]
+name = "muda"
+version = "0.19.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878"
+dependencies = [
+ "crossbeam-channel",
+ "dpi",
+ "keyboard-types",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "png",
+ "thiserror 2.0.21",
+ "windows-sys 0.61.2",
+]
+
[[package]]
name = "multiversion_no_op"
version = "1.0.0"
@@ -3366,6 +3434,7 @@ dependencies = [
"eframe",
"egui",
"log",
+ "muda",
"rfd",
"serde_json",
"soundcraft-engine",
@@ -3448,6 +3517,7 @@ dependencies = [
"proptest",
"serde",
"serde_json",
+ "soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
@@ -3526,6 +3596,7 @@ dependencies = [
name = "soundcraft-ui-egui"
version = "0.3.0"
dependencies = [
+ "dark-light",
"egui",
"egui_kittest",
"image",
@@ -5035,6 +5106,16 @@ 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 6f07dfb..5e69697 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -44,6 +44,7 @@ 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/README.md b/README.md
index 398061e..57e90f9 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,28 @@ Useful shortcuts: Space play/stop, ⌘= Mix/Edi
separate, ⌘D duplicate, Enter new marker, ⌘⇧N
new tracks. **Setup › Keyboard Shortcuts** lists them all.
+### Logs
+
+The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
+settings directory, beside `ui.json`, `Autosave/` and `Presets/` (Linux `~/.config/soundcraft/logs/`,
+or `$XDG_CONFIG_HOME/soundcraft/logs/`; macOS `~/Library/Application Support/SoundCraft/logs/`;
+Windows `%APPDATA%\SoundCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so
+this file is what to attach to a bug report: the engine's panic report, audio devices that failed to
+open or broke, plugins that refused their stored state, CLAP plugins' own messages and files that
+failed to open land there. Each launch moves the previous log to `soundcraft.1.log` (and that one to
+`soundcraft.2.log`), so the log of a run that crashed survives the next start. The file stops
+growing at 16 MiB. `--version` writes no file, and runs with `SOUNDCRAFT_NO_PREFS` log to standard
+error only.
+
+| Variable | Effect |
+|---|---|
+| `RUST_LOG` | Log levels for standard error and the log file. Default: `info` for SoundCraft's own crates, `warn` for everything else. env_logger-style directives replace that, e.g. `RUST_LOG=debug`, `RUST_LOG=warn,soundcraft_mix=trace` or `RUST_LOG=info,wgpu_core=warn`; a directive ending in `*` covers every target starting with it (`soundcraft*=debug`). |
+| `SOUNDCRAFT_NO_PREFS` | No preferences read or written and no log file (agents' test runs). |
+
+The realtime audio thread never writes a record itself: the logger keeps the first one it logs
+(without waiting or allocating), counts the rest, and the UI thread writes them. The logger is
+`apps/soundcraft/src/logging.rs`. The web app logs to the browser console instead.
+
## For agents and scripts
Everything you can click is also a command with an id and JSON parameters, and the same commands
@@ -228,6 +250,21 @@ very welcome, in the issues or on Discord.
Installers and executables are code-signed.
+**If the app doesn't open on Windows:** the desktop app initializes only DirectX 12 by default.
+Letting wgpu also create an OpenGL instance can crash some graphics drivers (AMD's
+`atio6axx.dll`) before the window appears, so the app would flash in Task Manager and quit.
+`WGPU_BACKEND` overrides the default for troubleshooting (for example `dx12` or `vulkan`). In
+PowerShell, from the folder containing the executable:
+
+```powershell
+$env:WGPU_BACKEND = "vulkan"
+& .\soundcraft.exe
+Remove-Item Env:WGPU_BACKEND # restore the default for later launches
+```
+
+An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
+and web backend defaults are unchanged.
+
### macOS
| Build | File | Notes |
diff --git a/ROADMAP.md b/ROADMAP.md
index 4684082..a89da3f 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -29,7 +29,7 @@ the honest status: what works today, what is missing, and how far we are.
| Elastic Audio / TCE / Beat Detective | Pitch-preserving warp on Elastic tracks, TCE to timeline, conform to tempo, Beat Detective and Identify Beat windows; audio-to-MIDI pitch detection | 45 % | 15 |
| Surround | Main and bus formats from stereo to 9.1.6 and Ambisonics, surround panner with divergence/centre/LFE/height, ITU fold-down, multichannel bounce, Renderer window, object/bed routing | 60 % | 12 |
| Video | Picture track with thumbnails, Video and Video Universe windows, H.264 (Baseline/Main/High), ProRes (all flavours) and Motion JPEG decoders in pure Rust, sync offset, relinking. Missing: HEVC/AV1/DNx, timecode-track placement, video export | 55 % | 15 |
-| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog, floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
+| UI fidelity | Edit + Mix windows, toolbar, rulers, track headers, menus for the whole catalog (macOS system menu bar), floating windows, dialogs (every file command has a path prompt), command palette | 70 % | 20 |
| Agent control | CLI, JSON control channel, MCP server (headless + bridged), offscreen UI renders | 90 % (ahead of the incumbent) | 3 |
| Release engineering | Signed macOS universal, Windows x64/x86, Linux AppImage/deb/rpm/tar/Flatpak, FreeBSD, Web/WASM on every push to `release` | 80 % | 3 |
diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs
index 9dc8ec3..2ba9112 100644
--- a/apps/soundcraft-cli/src/main.rs
+++ b/apps/soundcraft-cli/src/main.rs
@@ -37,6 +37,17 @@ fn fail(msg: impl std::fmt::Display) -> ExitCode {
ExitCode::FAILURE
}
+/// The usage block of the module doc comment, up to its closing code fence.
+fn help_text() -> String {
+ include_str!("main.rs")
+ .lines()
+ .skip(3)
+ .take_while(|l| l.trim_end() != "//! ```")
+ .map(|l| l.trim_start_matches("//! "))
+ .collect::>()
+ .join("\n")
+}
+
fn main() -> ExitCode {
let args: Vec = std::env::args().skip(1).collect();
let Some(cmd) = args.first().cloned() else {
@@ -44,13 +55,13 @@ fn main() -> ExitCode {
return ExitCode::FAILURE;
};
let rest: Vec = args.iter().skip(1).cloned().collect();
- match cmd.as_str() {
+ let code = match cmd.as_str() {
"--version" | "-V" | "version" => {
outln!("SoundCraft CLI {}", env!("CARGO_PKG_VERSION"));
ExitCode::SUCCESS
}
"--help" | "-h" | "help" => {
- outln!("{}", include_str!("main.rs").lines().skip(3).take(14).map(|l| l.trim_start_matches("//! ")).collect::>().join("\n"));
+ outln!("{}", help_text());
ExitCode::SUCCESS
}
"info" => info(&rest),
@@ -69,15 +80,19 @@ fn main() -> ExitCode {
ExitCode::SUCCESS
}
other => fail(format!("unknown subcommand `{other}`")),
- }
+ };
+ // The subcommand's engine and its plugin instances are gone by now.
+ soundcraft_engine::shutdown_plugin_hosts();
+ code
}
fn info(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("info FILE") };
if path.ends_with(".scraft") {
let mut e = Engine::default();
- if let Err(err) = soundcraft_engine::io::open_session(&mut e, path) {
- return fail(err);
+ match soundcraft_engine::io::open_session(&mut e, path) {
+ Ok(missing) => warn_missing(&missing),
+ Err(err) => return fail(err),
}
outln!("{}", soundcraft_engine::inspect::session_text(&e));
return ExitCode::SUCCESS;
@@ -115,10 +130,8 @@ fn convert(args: &[String]) -> ExitCode {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase();
- let format = match out_ext.as_str() {
- "aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff,
- "flac" => soundcraft_audio_io::FileFormat::Flac,
- _ => soundcraft_audio_io::FileFormat::Wav,
+ let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
+ return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
let bit_depth = match arg_value(args, "--bit-depth").as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
@@ -135,10 +148,22 @@ fn convert(args: &[String]) -> ExitCode {
}
}
+/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
+fn missing_warnings(missing: &[String]) -> Vec {
+ missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
+}
+
+fn warn_missing(missing: &[String]) {
+ for line in missing_warnings(missing) {
+ eprintln!("{line}");
+ }
+}
+
fn load_engine(args: &[String]) -> Result {
if let Some(p) = arg_value(args, "--in") {
let mut e = Engine::default();
- soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
+ let missing = soundcraft_engine::io::open_session(&mut e, &p).map_err(|e| e.to_string())?;
+ warn_missing(&missing);
Ok(e)
} else if args.iter().any(|a| a == "--demo" || a == "--sample") {
Ok(soundcraft_engine::demo::demo_engine())
@@ -178,6 +203,14 @@ fn time_arg(s: &str) -> Value {
s.parse::().map_or_else(|_| json!(s), |f| json!({"seconds": f}))
}
+/// `ID` or `ID=JSON` → command id and params; malformed JSON is an error, not `{}`.
+fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
+ match spec.split_once('=') {
+ Some((id, p)) => serde_json::from_str::(p).map(|v| (id.to_string(), v)).map_err(|err| format!("{id}: bad JSON: {err}")),
+ None => Ok((spec.to_string(), json!({}))),
+ }
+}
+
fn run(args: &[String]) -> ExitCode {
let mut e = match load_engine(args) {
Ok(e) => e,
@@ -188,9 +221,9 @@ fn run(args: &[String]) -> ExitCode {
if args.get(i).is_some_and(|a| a == "--cmd")
&& let Some(spec) = args.get(i + 1)
{
- let (id, params) = match spec.split_once('=') {
- Some((id, p)) => (id.to_string(), serde_json::from_str::(p).unwrap_or(json!({}))),
- None => (spec.clone(), json!({})),
+ let (id, params) = match parse_cmd_spec(spec) {
+ Ok(v) => v,
+ Err(err) => return fail(err),
};
match e.execute(&id, ¶ms) {
Ok(v) => outln!("{id}: {v}"),
@@ -270,7 +303,13 @@ fn app(args: &[String]) -> ExitCode {
.map(|(_, a)| a)
.collect();
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
- let params = positional.get(1).and_then(|p| serde_json::from_str::(p).ok()).unwrap_or(json!({}));
+ let params = match positional.get(1) {
+ Some(p) => match serde_json::from_str::(p) {
+ Ok(v) => v,
+ Err(err) => return fail(format!("{method}: bad JSON: {err}")),
+ },
+ None => json!({}),
+ };
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute.
let (m, p) = if method.contains('.')
@@ -336,3 +375,36 @@ fn parity(args: &[String]) -> ExitCode {
}
ExitCode::SUCCESS
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn missing_media_gets_one_warning_each() {
+ assert!(missing_warnings(&[]).is_empty());
+ let w = missing_warnings(&["a.wav".to_string(), "b.wav: decode failed".to_string()]);
+ assert_eq!(w, vec!["warning: missing media: a.wav", "warning: missing media: b.wav: decode failed"]);
+ }
+
+ #[test]
+ fn cmd_spec_parses_id_and_json() {
+ assert_eq!(parse_cmd_spec("a.b").unwrap(), ("a.b".to_string(), json!({})));
+ assert_eq!(parse_cmd_spec("a.b={\"x\":1}").unwrap(), ("a.b".to_string(), json!({"x": 1})));
+ }
+
+ #[test]
+ fn cmd_spec_rejects_truncated_json() {
+ let err = parse_cmd_spec("a.b={\"x\":").unwrap_err();
+ assert!(err.starts_with("a.b: bad JSON"), "{err}");
+ }
+
+ #[test]
+ fn help_text_is_only_the_usage_block() {
+ let help = help_text();
+ assert!(help.starts_with("soundcraft-cli info FILE"));
+ assert!(help.ends_with("soundcraft-cli plugins list built-in plugins"));
+ assert!(!help.contains("```"));
+ assert!(!help.contains("#![") && !help.contains("use serde_json"));
+ }
+}
diff --git a/apps/soundcraft-web/index.html b/apps/soundcraft-web/index.html
index b68f2d7..af72b21 100644
--- a/apps/soundcraft-web/index.html
+++ b/apps/soundcraft-web/index.html
@@ -15,6 +15,27 @@
+
Loading SoundCraft…