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…
diff --git a/apps/soundcraft/Cargo.toml b/apps/soundcraft/Cargo.toml index 4e1bc75..cb9dab1 100644 --- a/apps/soundcraft/Cargo.toml +++ b/apps/soundcraft/Cargo.toml @@ -25,3 +25,6 @@ rfd.workspace = true [build-dependencies] # Icon + VERSIONINFO for soundcraft.exe; build.rs only uses it when the target is Windows. winresource = "0.1" + +[target.'cfg(target_os = "macos")'.dependencies] +muda = { version = "0.19.2", default-features = false } diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index 7c0c3e0..51758b9 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let listener = match TcpListener::bind(("127.0.0.1", port)) { Ok(l) => l, Err(e) => { - eprintln!("soundcraft: control server failed to bind 127.0.0.1:{port}: {e}"); + log::error!("control server failed to bind 127.0.0.1:{port}: {e}"); return rx; } }; let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port); - eprintln!("soundcraft: control server listening on 127.0.0.1:{actual}"); + log::info!("control server listening on 127.0.0.1:{actual}"); println!("SOUNDCRAFT_CONTROL_PORT={actual}"); let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || { for stream in listener.incoming().flatten() { @@ -28,7 +28,7 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { } }); if let Err(e) = spawned { - eprintln!("soundcraft: control thread failed: {e}"); + log::error!("control thread failed: {e}"); } rx } diff --git a/apps/soundcraft/src/graphics.rs b/apps/soundcraft/src/graphics.rs new file mode 100644 index 0000000..ccd0c45 --- /dev/null +++ b/apps/soundcraft/src/graphics.rs @@ -0,0 +1,47 @@ +//! Windows graphics instance configuration, applied before wgpu can initialize any driver. + +use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; + +/// 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) { + 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 + // window never appears. Choosing an adapter afterwards is too late: only leaving GL + // 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); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn configured_backends(backend_override: Option) -> Backends { + let mut options = NativeOptions::default(); + configure(&mut options, backend_override); + let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + create.instance_descriptor.backends + } + + #[test] + fn windows_default_initializes_only_dx12() { + // eframe's own default (PRIMARY | GL) would initialize OpenGL as well. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); + assert_eq!(configured_backends(None), Backends::DX12); + } + + #[test] + fn explicit_backend_override_is_preserved() { + for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { + assert_eq!(configured_backends(Some(backends)), backends); + } + } +} diff --git a/apps/soundcraft/src/logging.rs b/apps/soundcraft/src/logging.rs new file mode 100644 index 0000000..53be177 --- /dev/null +++ b/apps/soundcraft/src/logging.rs @@ -0,0 +1,796 @@ +//! The desktop app's logger: `log::` records go to standard error and to a log file. +//! +//! Without a logger every `log::warn!`/`log::error!` in the workspace (the engine's panic report, +//! audio devices that failed to open or broke, plugins that refused their stored state or failed +//! to activate, CLAP plugins' own log messages, decode errors on import) vanished. A launch from a +//! desktop menu has no terminal, so the file is what a bug report can attach: +//! `/logs/soundcraft.log`, beside `ui.json`, `Autosave/` and `Presets/`. Each start +//! moves the previous log to `soundcraft.1.log` (and that one to `.2`), so the log of a run that +//! crashed survives the next launch. Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test +//! runs) log to standard error only, so they don't rotate away the user's own logs. +//! +//! Levels: `info` for SoundCraft's own crates, `warn` for everything else (wgpu and naga are +//! chatty). `RUST_LOG` replaces that with env_logger-style directives: `debug`, +//! `warn,soundcraft_mix=trace`, `wgpu_core=info`. A directive ending in `*` matches every target +//! that starts with it (`soundcraft*=debug`). +//! +//! Records logged before the settings directory is known are kept (up to [`MAX_PENDING`]) and +//! written once the file is attached. Writing never panics: a file that can't be created or +//! written leaves standard error as the only sink. +//! +//! Realtime audio: a record takes a lock and writes to standard error and a file, which the audio +//! callback must never do. Code that runs on the audio thread logs anyway (the cpal error callback, +//! the synth's full event queue, a hosted plugin's failed `process`, a CLAP plugin calling the +//! host's `log` from its audio thread, the panic hook). So the cpal callbacks mark their thread +//! (`soundcraft_playback::mark_audio_thread`; the mix engine carries the mark into the worker +//! threads that render strips for it), and a record from a marked thread is not written +//! there: the first one is kept in a fixed buffer (`try_lock`, no allocation, never waits) and the +//! rest are only counted (an atomic). Another thread writes them out: the UI thread every frame +//! ([`AppLogger::report_audio_thread`]) and any other thread before its own next record. + +use std::fmt::Write as _; +use std::fs::File; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Mutex, OnceLock, PoisonError}; +use std::time::SystemTime; + +use log::LevelFilter; + +/// The current log file's name inside the log directory. +pub const LOG_FILE: &str = "soundcraft.log"; +/// The log directory inside the settings directory. +pub const LOG_DIR: &str = "logs"; +/// How many previous logs are kept (`soundcraft.1.log` … `soundcraft..log`). +pub const KEEP: usize = 2; +/// The log file stops growing past this size (a runaway warning can't fill the disk). +pub const MAX_FILE_BYTES: u64 = 16 * 1024 * 1024; +/// Records kept in memory until the log file is attached. +pub const MAX_PENDING: usize = 512; +/// The built-in filter when `RUST_LOG` is unset or empty. +pub const DEFAULT_FILTER: &str = "warn,soundcraft*=info"; +/// The longest message kept from the audio thread (longer ones are cut, marked with `…`). +pub const REALTIME_MESSAGE_BYTES: usize = 480; +/// The longest target kept from the audio thread. +const REALTIME_TARGET_BYTES: usize = 96; +/// The thread name shown for records from the audio thread. +const AUDIO_THREAD: &str = "audio"; + +/// Per-target level filter parsed from env_logger-style directives. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Filter { + default: LevelFilter, + directives: Vec<(String, LevelFilter)>, +} + +impl Filter { + /// Parse `spec`; unknown levels and empty parts are skipped. Without a bare level, targets no + /// directive names log errors only. + pub fn parse(spec: &str) -> Filter { + let mut f = Filter { default: LevelFilter::Error, directives: Vec::new() }; + for part in spec.split(',').map(str::trim).filter(|p| !p.is_empty()) { + match part.split_once('=') { + Some((name, level)) => { + let name = name.trim(); + if let (false, Some(level)) = (name.is_empty(), parse_level(level)) { + f.directives.push((name.to_owned(), level)); + } + } + None => match parse_level(part) { + Some(level) => f.default = level, + // A bare target name: everything from it (env_logger does the same). + None => f.directives.push((part.to_owned(), LevelFilter::Trace)), + }, + } + } + f + } + + /// The level that applies to `target` (the most specific matching directive wins). + pub fn level_for(&self, target: &str) -> LevelFilter { + self.directives.iter().filter(|(name, _)| matches(name, target)).max_by_key(|(name, _)| name.len()).map_or(self.default, |(_, level)| *level) + } + + /// The most verbose level any target can reach (for `log::set_max_level`). + pub fn max(&self) -> LevelFilter { + self.directives.iter().map(|(_, level)| *level).fold(self.default, Ord::max) + } +} + +fn parse_level(s: &str) -> Option { + s.trim().parse().ok() +} + +/// `name` matches `target` itself and its submodules (`a` matches `a` and `a::b`, not `ab`); +/// `name*` matches every target starting with `name`. +fn matches(name: &str, target: &str) -> bool { + match name.strip_suffix('*') { + Some(prefix) => target.starts_with(prefix), + None => target.strip_prefix(name).is_some_and(|rest| rest.is_empty() || rest.starts_with("::")), + } +} + +/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. +pub fn timestamp(t: SystemTime) -> String { + let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); + let secs = since.as_secs(); + let (days, rem) = (secs / 86_400, secs % 86_400); + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) +} + +/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's +/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd +/// clock can't overflow). +fn civil_from_days(days: u64) -> (u64, u64, u64) { + let z = days.saturating_add(719_468); + let era = z / 146_097; + let doe = z % 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); + (y, m, d) +} + +fn numbered(dir: &Path, n: usize) -> PathBuf { + if n == 0 { dir.join(LOG_FILE) } else { dir.join(format!("soundcraft.{n}.log")) } +} + +/// Shift the previous logs up one (`.log` → `.1.log` → … → `..log`, the oldest dropped) +/// and create a fresh, empty log file in `dir` (created if missing). +pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { + std::fs::create_dir_all(dir)?; + for n in (1..=KEEP).rev() { + match std::fs::rename(numbered(dir, n - 1), numbered(dir, n)) { + Err(e) if e.kind() != std::io::ErrorKind::NotFound => return Err(e), + _ => {} + } + } + let path = numbered(dir, 0); + let file = File::create(&path)?; + Ok((path, file)) +} + +/// One formatted log line: ` [] : \n`. +pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String { + let message = message.strip_suffix('\n').unwrap_or(message); + format!("{ts} {level:<5} [{thread}] {target}: {message}\n") +} + +/// Where formatted lines go besides standard error: kept in memory until a file is attached, +/// then appended to it until it reaches its size cap. +pub struct Sink { + file: Option, + pending: Vec, + dropped: usize, + written: u64, + max_bytes: u64, + capped: bool, +} + +impl Sink { + pub fn new(max_bytes: u64) -> Sink { + Sink { file: None, pending: Vec::new(), dropped: 0, written: 0, max_bytes, capped: false } + } + + /// Append `line` to the file, or keep it until [`Sink::attach`]. + pub fn write(&mut self, line: &str) { + if self.file.is_none() { + if self.capped { + return; + } + if self.pending.len() < MAX_PENDING { + self.pending.push(line.to_owned()); + } else { + self.dropped = self.dropped.saturating_add(1); + } + return; + } + self.append(line); + } + + /// Start writing to `file`, first the lines kept so far. + pub fn attach(&mut self, file: File) { + self.file = Some(file); + for line in std::mem::take(&mut self.pending) { + self.append(&line); + } + if self.dropped > 0 { + let note = format!("{} earlier log lines were dropped before the log file was opened\n", self.dropped); + self.dropped = 0; + self.append(¬e); + } + } + + /// There will be no log file: forget the lines kept for it and keep no more. + pub fn no_file(&mut self) { + self.file = None; + self.pending = Vec::new(); + self.dropped = 0; + self.capped = true; + } + + fn append(&mut self, line: &str) { + if self.capped { + return; + } + let Some(file) = self.file.as_mut() else { return }; + let len = u64::try_from(line.len()).unwrap_or(u64::MAX); + let text = if self.written.saturating_add(len) > self.max_bytes { + self.capped = true; + format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes) + } else { + self.written = self.written.saturating_add(len); + line.to_owned() + }; + // A full disk or a vanished file must not take the app down: drop the file sink (and + // stop buffering, since no file will come). + if file.write_all(text.as_bytes()).is_err() { + self.file = None; + self.capped = true; + } + } +} + +/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full. +struct Bounded { + buf: [u8; N], + len: usize, + cut: bool, +} + +impl Bounded { + fn new() -> Self { + Bounded { buf: [0; N], len: 0, cut: false } + } + + fn as_str(&self) -> &str { + // Only whole characters are ever copied in, so this never fails. + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for Bounded { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let room = N.saturating_sub(self.len); + let mut n = s.len().min(room); + while !s.is_char_boundary(n) { + n = n.saturating_sub(1); + } + if let (Some(dst), Some(src)) = (self.buf.get_mut(self.len..self.len.saturating_add(n)), s.as_bytes().get(..n)) { + dst.copy_from_slice(src); + self.len = self.len.saturating_add(n); + } + if n < s.len() { + self.cut = true; + } + Ok(()) + } +} + +/// The first record the audio thread logged since the last report. +struct Held { + at: SystemTime, + level: log::Level, + target: Bounded, + message: Bounded, +} + +/// Records from the realtime audio thread, waiting for another thread to write them. +#[derive(Default)] +struct Realtime { + /// Records logged on the audio thread since the last report (the held one included). + records: AtomicUsize, + /// The first of them; the audio thread only `try_lock`s it. + held: Mutex>, +} + +impl Realtime { + /// Audio thread: count `record` and keep it if it is the first. Never waits, never allocates. + fn keep(&self, record: &log::Record<'_>) { + self.records.fetch_add(1, Ordering::Relaxed); + if let Ok(mut held) = self.held.try_lock() + && held.is_none() + { + let mut target = Bounded::new(); + let _ = target.write_str(record.target()); + let mut message = Bounded::new(); + let _ = write!(message, "{}", record.args()); + *held = Some(Held { at: SystemTime::now(), level: record.level(), target, message }); + } + } + + /// Another thread: the lines to write for what the audio thread logged (empty when nothing). + fn take_lines(&self) -> String { + if self.records.load(Ordering::Relaxed) == 0 { + return String::new(); + } + let held = self.held.lock().unwrap_or_else(PoisonError::into_inner).take(); + let records = self.records.swap(0, Ordering::Relaxed); + let mut lines = String::new(); + if let Some(h) = &held { + let mut message = h.message.as_str().to_owned(); + if h.message.cut { + message.push('…'); + } + lines.push_str(&format_line(×tamp(h.at), h.level, AUDIO_THREAD, h.target.as_str(), &message)); + } + let more = records.saturating_sub(usize::from(held.is_some())); + if more > 0 { + let thread = std::thread::current(); + let note = format!("{more} more log records from the realtime audio thread were not written (only the first is kept)"); + lines.push_str(&format_line(×tamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), ¬e)); + } + lines + } +} + +/// The installed logger: a [`Filter`], standard error and a [`Sink`]. +pub struct AppLogger { + filter: Filter, + stderr: bool, + sink: Mutex, + realtime: Realtime, +} + +impl AppLogger { + pub fn new(filter: Filter, stderr: bool) -> AppLogger { + AppLogger { filter, stderr, sink: Mutex::new(Sink::new(MAX_FILE_BYTES)), realtime: Realtime::default() } + } + + /// Rotate the logs in `dir` and send records to the fresh file; returns its path. + /// An unusable directory leaves standard error as the only sink ([`AppLogger::no_file`]). + pub fn attach_dir(&self, dir: &Path) -> Result { + match rotate(dir) { + Ok((path, file)) => { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).attach(file); + Ok(path) + } + Err(e) => { + self.no_file(); + Err(format!("{}: {e}", dir.display())) + } + } + } + + /// No log file this run (no settings directory, `SOUNDCRAFT_NO_PREFS`, or the directory can't + /// be used): stop keeping records for one. + pub fn no_file(&self) { + self.sink.lock().unwrap_or_else(PoisonError::into_inner).no_file(); + } + + /// Write what the audio thread logged since the last report (never call it on the audio thread). + pub fn report_audio_thread(&self) { + let lines = self.realtime.take_lines(); + if !lines.is_empty() { + self.emit(&lines); + } + } + + /// Standard error, then the sink; the lock covers one write of already formatted text. + fn emit(&self, text: &str) { + if self.stderr { + // No terminal (a Windows GUI build, a closed pipe) is not an error worth reporting. + let _ = std::io::stderr().write_all(text.as_bytes()); + } + self.sink.lock().unwrap_or_else(PoisonError::into_inner).write(text); + } +} + +impl log::Log for AppLogger { + fn enabled(&self, metadata: &log::Metadata<'_>) -> bool { + metadata.level() <= self.filter.level_for(metadata.target()) + } + + fn log(&self, record: &log::Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + if soundcraft_playback::on_audio_thread() { + self.realtime.keep(record); + return; + } + self.report_audio_thread(); + // Formatted before the sink is locked, so a message that logs while it is formatted can't deadlock. + let thread = std::thread::current(); + let line = + format_line(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string()); + self.emit(&line); + } + + fn flush(&self) {} +} + +/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another +/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the +/// settings directory is known. +pub fn install() -> Option<&'static AppLogger> { + static LOGGER: OnceLock = OnceLock::new(); + let spec = std::env::var("RUST_LOG").ok().filter(|s| !s.trim().is_empty()); + let filter = Filter::parse(spec.as_deref().unwrap_or(DEFAULT_FILTER)); + let max = filter.max(); + let logger = LOGGER.get_or_init(|| AppLogger::new(filter, true)); + log::set_logger(logger).ok()?; + log::set_max_level(max); + Some(logger) +} + +#[cfg(test)] +mod tests { + use super::*; + use log::Log; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::time::{Duration, UNIX_EPOCH}; + + fn temp_dir(tag: &str) -> PathBuf { + static N: AtomicUsize = AtomicUsize::new(0); + let d = std::env::temp_dir().join(format!("soundcraft-logging-{tag}-{}-{}", std::process::id(), N.fetch_add(1, Ordering::Relaxed))); + let _ = std::fs::remove_dir_all(&d); + d + } + + fn read(path: &Path) -> String { + std::fs::read_to_string(path).expect("read log") + } + + fn record(logger: &AppLogger, level: log::Level, target: &'static str, msg: &str) { + logger.log(&log::Record::builder().level(level).target(target).args(format_args!("{msg}")).build()); + } + + /// Run `f` on a thread the playback crate marks as the realtime audio thread. + fn on_audio_thread(f: impl FnOnce() -> T + Send) -> T { + std::thread::scope(|s| { + s.spawn(|| { + soundcraft_playback::mark_audio_thread(); + f() + }) + .join() + .expect("audio thread") + }) + } + + #[test] + fn the_default_filter_shows_soundcraft_info_and_other_crates_warnings() { + let f = Filter::parse(DEFAULT_FILTER); + assert_eq!(f.level_for("soundcraft"), LevelFilter::Info); + assert_eq!(f.level_for("soundcraft::control_server"), LevelFilter::Info); + assert_eq!(f.level_for("soundcraft_playback"), LevelFilter::Info); + assert_eq!(f.level_for("soundcraft_clap_host::ffi"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::device"), LevelFilter::Warn); + assert_eq!(f.level_for("cpal"), LevelFilter::Warn); + assert_eq!(f.max(), LevelFilter::Info); + } + + #[test] + fn directives_follow_env_logger_and_the_most_specific_one_wins() { + let f = Filter::parse("info,wgpu_core=error,soundcraft_mix=trace,soundcraft_mix::pdc=off"); + assert_eq!(f.level_for("eframe"), LevelFilter::Info); + assert_eq!(f.level_for("wgpu_core::instance"), LevelFilter::Error); + assert_eq!(f.level_for("soundcraft_mix::render"), LevelFilter::Trace); + assert_eq!(f.level_for("soundcraft_mix::pdc"), LevelFilter::Off); + assert_eq!(f.max(), LevelFilter::Trace); + // A module name is matched at `::` boundaries, not as a bare prefix. + assert_eq!(f.level_for("wgpu_core_extra"), LevelFilter::Info); + // A trailing `*` is a prefix. + assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_ui_egui::mix_window"), LevelFilter::Debug); + assert_eq!(Filter::parse("warn,soundcraft_ui*=debug").level_for("soundcraft_engine"), LevelFilter::Warn); + // A bare target name sets that target to the most verbose level, as env_logger does. + assert_eq!(Filter::parse("naga").level_for("naga::front"), LevelFilter::Trace); + assert_eq!(Filter::parse("naga").level_for("eframe"), LevelFilter::Error); + // Levels are case-insensitive; the last bare level wins. + assert_eq!(Filter::parse("debug,WARN").level_for("x"), LevelFilter::Warn); + } + + #[test] + fn hostile_specs_never_panic_and_fall_back_sensibly() { + let long = "a".repeat(100_000); + let specs = [ + "", + ",,,", + "=", + "==", + "=debug", + "soundcraft=", + "nonsense=loud", + "🦀=info", + "*", + "*=debug", + "a=b=c", + "warn,,soundcraft=DEBUG", + " \t ", + "\u{0}", + "é*=trace", + &long, + ]; + for spec in specs { + let f = Filter::parse(spec); + for target in ["soundcraft", "", "🦀", "é", "a::b"] { + let _ = f.level_for(target); + } + let _ = f.max(); + } + assert_eq!(Filter::parse("warn,,soundcraft=DEBUG").level_for("soundcraft"), LevelFilter::Debug); + assert_eq!(Filter::parse("nonsense=loud").level_for("nonsense"), LevelFilter::Error); + assert_eq!(Filter::parse("=debug").max(), LevelFilter::Error, "a directive without a name is skipped"); + assert_eq!(Filter::parse("*=debug").level_for("anything"), LevelFilter::Debug, "a lone `*` matches every target"); + assert_eq!(Filter::parse("é*=trace").level_for("éa"), LevelFilter::Trace); + } + + #[test] + fn timestamps_are_utc_with_milliseconds() { + assert_eq!(timestamp(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_millis(1_791_446_357_728)), "2026-10-08T07:59:17.728Z"); + // Leap day, and the day after it. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), "2024-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_199)), "2024-02-29T23:59:59.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(1_709_251_200)), "2024-03-01T00:00:00.000Z"); + // 2000 is a leap year (divisible by 400), 2100 is not. + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z"); + assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); + // Before the epoch (a clock set wrong) clamps instead of panicking. + assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); + } + + #[test] + fn rotation_keeps_the_previous_logs_and_starts_an_empty_file() { + let dir = temp_dir("rotate"); + for run in 1..=4 { + let (path, _file) = rotate(&dir).expect("rotate"); + assert_eq!(path, dir.join(LOG_FILE)); + assert_eq!(read(&path), ""); + std::fs::write(&path, format!("run {run}")).expect("write"); + } + assert_eq!(read(&dir.join(LOG_FILE)), "run 4"); + assert_eq!(read(&dir.join("soundcraft.1.log")), "run 3"); + assert_eq!(read(&dir.join("soundcraft.2.log")), "run 2"); + assert!(!dir.join("soundcraft.3.log").exists(), "only {KEEP} old logs are kept"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn rotation_into_an_unusable_directory_is_an_error_not_a_panic() { + let dir = temp_dir("blocked"); + std::fs::create_dir_all(dir.parent().expect("parent")).expect("tmp"); + std::fs::write(&dir, "a file where the directory should be").expect("block"); + assert!(rotate(&dir).is_err()); + assert!(rotate(&dir.join("below")).is_err()); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_line_carries_time_level_thread_target_and_message() { + assert_eq!( + format_line( + "2026-10-08T07:59:17.728Z", + log::Level::Warn, + "main", + "soundcraft_mix", + "eq_7band: the plugin did not accept its stored state" + ), + "2026-10-08T07:59:17.728Z WARN [main] soundcraft_mix: eq_7band: the plugin did not accept its stored state\n" + ); + // A multi-line message keeps its lines; the record still ends in exactly one newline. + assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n")); + } + + #[test] + fn lines_logged_before_the_file_exists_are_written_when_it_is_attached() { + let dir = temp_dir("pending"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write("early 1\n"); + sink.write("early 2\n"); + sink.attach(file); + sink.write("late\n"); + assert_eq!(read(&path), "early 1\nearly 2\nlate\n"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_pending_buffer_is_bounded_and_says_how_much_it_dropped() { + let dir = temp_dir("bounded"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING + 5 { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), MAX_PENDING); + assert_eq!(text.matches("5 earlier log lines were dropped").count(), 1, "{text}"); + assert!(text.contains("line 0\n") && !text.contains(&format!("line {MAX_PENDING}\n"))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn exactly_max_pending_lines_drop_nothing() { + let dir = temp_dir("exact"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(MAX_FILE_BYTES); + for i in 0..MAX_PENDING { + sink.write(&format!("line {i}\n")); + } + sink.attach(file); + let text = read(&path); + assert_eq!(text.lines().count(), MAX_PENDING); + assert!(!text.contains("dropped"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_file_stops_at_its_size_cap_with_one_note() { + let dir = temp_dir("cap"); + let (path, file) = rotate(&dir).expect("rotate"); + let mut sink = Sink::new(100); + sink.attach(file); + for i in 0..50 { + sink.write(&format!("line {i:02} with some padding\n")); + } + let text = read(&path); + assert_eq!(text.lines().filter(|l| l.starts_with("line ")).count(), 3, "{text}"); + assert_eq!(text.matches("log file reached 100 bytes").count(), 1, "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_logger_filters_records_and_writes_them_to_the_attached_file() { + let dir = temp_dir("logger"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + record(&logger, log::Level::Info, "soundcraft", "SoundCraft starting"); + record(&logger, log::Level::Info, "wgpu_core::device", "too chatty"); + let path = logger.attach_dir(&dir).expect("attach"); + assert_eq!(path, dir.join(LOG_FILE)); + record(&logger, log::Level::Warn, "wgpu_hal::vulkan", "a real warning"); + record(&logger, log::Level::Debug, "soundcraft", "below info"); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines.first().is_some_and(|l| l.contains(" INFO [") && l.ends_with("soundcraft: SoundCraft starting")), "{text}"); + assert!(lines.get(1).is_some_and(|l| l.ends_with("wgpu_hal::vulkan: a real warning")), "{text}"); + assert!(logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("soundcraft_engine").build())); + assert!(!logger.enabled(&log::Metadata::builder().level(log::Level::Info).target("naga").build())); + // A second launch rotates: the first log becomes `.1`. + let second = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + assert_eq!(second.attach_dir(&dir).expect("attach again"), dir.join(LOG_FILE)); + assert_eq!(read(&dir.join("soundcraft.1.log")), text); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn records_from_many_threads_are_whole_lines() { + let dir = temp_dir("threads"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + std::thread::scope(|s| { + for _ in 0..4 { + s.spawn(|| { + for _ in 0..100 { + record(&logger, log::Level::Info, "soundcraft", "from a worker"); + } + }); + } + }); + let text = read(&path); + assert_eq!(text.lines().count(), 400); + assert!(text.lines().all(|l| l.ends_with("soundcraft: from a worker")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn attaching_to_an_unusable_directory_reports_an_error_and_keeps_logging() { + let dir = temp_dir("unusable"); + std::fs::write(&dir, "not a directory").expect("block"); + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "soundcraft", "kept for a file"); + let e = logger.attach_dir(&dir).expect_err("a file is not a log directory"); + assert!(e.contains(&dir.display().to_string()), "{e}"); + record(&logger, log::Level::Error, "x", "still fine"); + let sink = logger.sink.lock().expect("sink"); + assert!(sink.file.is_none() && sink.pending.is_empty(), "nothing is kept for a file that will not come"); + drop(sink); + let _ = std::fs::remove_file(&dir); + } + + #[test] + fn a_run_without_a_log_file_keeps_nothing_for_one() { + let logger = AppLogger::new(Filter::parse("info"), false); + record(&logger, log::Level::Info, "soundcraft", "early"); + logger.no_file(); + record(&logger, log::Level::Info, "soundcraft", "late"); + let sink = logger.sink.lock().expect("sink"); + assert!(sink.file.is_none() && sink.pending.is_empty() && sink.dropped == 0); + } + + #[test] + fn a_poisoned_lock_does_not_stop_logging() { + let dir = temp_dir("poison"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = logger.sink.lock(); + panic!("poison the sink"); + })); + assert!(logger.sink.is_poisoned()); + record(&logger, log::Level::Info, "soundcraft", "after the panic"); + assert!(read(&path).contains("after the panic")); + let _ = std::fs::remove_dir_all(&dir); + } + + // Realtime audio: the audio thread never takes the sink lock or touches the file. + + #[test] + fn an_audio_thread_record_never_waits_for_the_sink() { + let dir = temp_dir("rt-nowait"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + // Another thread holds the sink (a slow disk, say) while the audio thread logs. + let held = logger.sink.lock().expect("sink"); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::scope(|s| { + s.spawn(|| { + soundcraft_playback::mark_audio_thread(); + record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged"); + let _ = tx.send(()); + }); + let done = rx.recv_timeout(Duration::from_secs(10)).is_ok(); + drop(held); + assert!(done, "the audio thread blocked on the sink lock"); + }); + assert_eq!(read(&path), "", "nothing is written from the audio thread itself"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_first_audio_thread_record_is_written_later_with_a_count_of_the_rest() { + let dir = temp_dir("rt-report"); + let logger = AppLogger::new(Filter::parse(DEFAULT_FILTER), false); + let path = logger.attach_dir(&dir).expect("attach"); + on_audio_thread(|| { + record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: device unplugged"); + for _ in 0..41 { + record(&logger, log::Level::Warn, "soundcraft_playback", "audio stream error: still unplugged"); + } + // Filtered out: neither kept nor counted. + record(&logger, log::Level::Debug, "soundcraft_clap_host::plugin", "process failed"); + }); + assert_eq!(read(&path), ""); + logger.report_audio_thread(); + let text = read(&path); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 2, "{text}"); + assert!(lines.first().is_some_and(|l| l.contains(" WARN [audio] soundcraft_playback: audio stream error: device unplugged")), "{text}"); + assert!(lines.get(1).is_some_and(|l| l.contains("41 more log records from the realtime audio thread were not written")), "{text}"); + // Reported once. + logger.report_audio_thread(); + assert_eq!(read(&path), text); + // The next record from any other thread reports what the audio thread kept since, first. + on_audio_thread(|| record(&logger, log::Level::Error, "soundcraft_clap_host::ffi", "clap plugin: out of voices")); + record(&logger, log::Level::Info, "soundcraft", "UI thread"); + let text = read(&path); + let tail: Vec<&str> = text.lines().skip(2).collect(); + assert_eq!(tail.len(), 2, "{text}"); + assert!(tail.first().is_some_and(|l| l.contains(" ERROR [audio] soundcraft_clap_host::ffi: clap plugin: out of voices")), "{text}"); + assert!(tail.get(1).is_some_and(|l| l.ends_with("soundcraft: UI thread")), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_long_audio_thread_record_is_cut_at_a_character_boundary() { + let dir = temp_dir("rt-long"); + let logger = AppLogger::new(Filter::parse("info"), false); + let path = logger.attach_dir(&dir).expect("attach"); + let long = "é".repeat(REALTIME_MESSAGE_BYTES); + on_audio_thread(|| record(&logger, log::Level::Warn, "soundcraft", &long)); + logger.report_audio_thread(); + let text = read(&path); + let message = text.split("soundcraft: ").nth(1).expect("message"); + assert!(message.ends_with("…\n"), "{text}"); + assert!(message.len() <= REALTIME_MESSAGE_BYTES + "…\n".len(), "{}", message.len()); + assert!(message.trim_end_matches(['…', '\n']).chars().all(|c| c == 'é')); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index a6cafa1..87da4f0 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -5,20 +5,40 @@ //! `--control ` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server //! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` → //! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods. +//! +//! `log` records go to standard error and `/logs/soundcraft.log` (`RUST_LOG` sets the +//! levels); see [`logging`]. #![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 control_server; +#[cfg(any(target_os = "windows", test))] +mod graphics; +mod logging; +#[cfg(target_os = "macos")] +mod native_menu; use soundcraft_engine::Engine; use soundcraft_ui_egui::{Services, SoundApp, UiState}; use std::sync::Arc; -struct App(SoundApp); +struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option); impl eframe::App for App { fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + // What the realtime audio thread logged is written here, off that thread. + if let Some(logger) = self.1 { + logger.report_audio_thread(); + } + #[cfg(target_os = "macos")] + if let Some(menu) = &mut self.2 { + menu.process(&mut self.0); + } self.0.logic(ctx); + #[cfg(target_os = "macos")] + if let Some(menu) = &mut self.2 { + menu.sync(&self.0); + } // Files dropped on the window: sessions open, audio/MIDI import. let dropped: Vec = ctx.input(|i| i.raw.dropped_files.iter().map(|f| f.path().to_string_lossy().into_owned()).filter(|s| !s.is_empty()).collect()); @@ -28,7 +48,18 @@ impl eframe::App for App { if self.0.quit_requested { ctx.send_viewport_cmd(egui::ViewportCommand::Close); } - let title = format!("{} — SoundCraft", self.0.engine.session().name); + // With the system menu bar there is no egui bar showing the window and dirty state, so the + // title carries them; elsewhere the egui menu bar already does. + let title = if self.0.native_menu_bar { + format!( + "{}{} — SoundCraft — {}", + self.0.engine.session().name, + if self.0.engine.is_dirty() { " *" } else { "" }, + if self.0.ui.window == soundcraft_ui_egui::MainWindow::Edit { "Edit" } else { "Mix" } + ) + } else { + format!("{} — SoundCraft", self.0.engine.session().name) + }; ctx.send_viewport_cmd(egui::ViewportCommand::Title(title)); } fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { @@ -39,6 +70,13 @@ impl eframe::App for App { } fn on_exit(&mut self) { save_prefs(&self.0.ui); + if let Some(logger) = self.1 { + logger.report_audio_thread(); + } + // The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never + // returns: release the player's plugin instances and shut plugin hosting down here. + self.0.player = None; + soundcraft_engine::shutdown_plugin_hosts(); } } @@ -57,8 +95,9 @@ fn open_path(app: &mut SoundApp, p: &str) { } } -fn prefs_path() -> Option { - let base = if cfg!(target_os = "macos") { +/// 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")) @@ -67,12 +106,20 @@ fn prefs_path() -> Option { .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")) - }; - base.map(|b| b.join("ui.json")) + } +} + +fn prefs_path() -> Option { + config_dir().map(|b| b.join("ui.json")) +} + +/// Runs without preferences (`SOUNDCRAFT_NO_PREFS`, agents' test runs) neither read nor write them. +fn prefs_enabled() -> bool { + std::env::var_os("SOUNDCRAFT_NO_PREFS").is_none() } fn load_prefs() -> Option { - if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return None; } let bytes = std::fs::read(prefs_path()?).ok()?; @@ -80,7 +127,7 @@ fn load_prefs() -> Option { } fn save_prefs(ui: &UiState) { - if std::env::var_os("SOUNDCRAFT_NO_PREFS").is_some() { + if !prefs_enabled() { return; } if let Some(p) = prefs_path() { @@ -132,14 +179,14 @@ fn app_icon() -> Option { } fn main() -> eframe::Result { + // First, so every start-up record (and the engine's panic hook, installed with the first + // Engine) is captured; see `logging`. + let logger = logging::install(); let args: Vec = std::env::args().skip(1).collect(); if args.iter().any(|a| a == "--version" || a == "-V") { println!("SoundCraft {}", env!("CARGO_PKG_VERSION")); return Ok(()); } - if std::env::var_os("RUST_LOG").is_none() { - // Quiet by default; `RUST_LOG=info` for more. - } let demo = args.iter().any(|a| a == "--demo" || a == "--sample"); let no_audio = args.iter().any(|a| a == "--no-audio"); let control_port: Option = args @@ -154,6 +201,24 @@ fn main() -> eframe::Result { .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); + // The log file lives in the settings directory; opened after the arguments, so `--version` + // leaves no file behind. Records logged until now are written to it first. Runs without + // preferences (agents' test runs) log to standard error only, so they don't rotate away the + // user's own logs. + if let Some(logger) = logger { + match config_dir() { + _ if !prefs_enabled() => logger.no_file(), + Some(dir) => match logger.attach_dir(&dir.join(logging::LOG_DIR)) { + Ok(path) => log::info!("log file {}", path.display()), + Err(e) => log::warn!("no log file: {e}"), + }, + None => { + logger.no_file(); + log::warn!("no log file: no settings directory (HOME, XDG_CONFIG_HOME or APPDATA is not set)"); + } + } + } let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; let mut options = eframe::NativeOptions { @@ -168,6 +233,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). + #[cfg(target_os = "windows")] + graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); eframe::run_native( "SoundCraft", options, @@ -186,7 +254,24 @@ fn main() -> eframe::Result { for f in &files { open_path(&mut app, f); } - Ok(Box::new(App(app))) + // If the system menu bar cannot be built, keep the egui menu bar rather than fail to start. + #[cfg(target_os = "macos")] + let menu = match native_menu::NativeMenu::new(&app, &cc.egui_ctx) { + Ok(m) => { + app.native_menu_bar = true; + Some(m) + } + Err(e) => { + log::warn!("system menu bar unavailable, using the in-window menu bar: {e}"); + None + } + }; + Ok(Box::new(App( + app, + logger, + #[cfg(target_os = "macos")] + menu, + ))) }), ) } diff --git a/apps/soundcraft/src/native_menu.rs b/apps/soundcraft/src/native_menu.rs new file mode 100644 index 0000000..563d174 --- /dev/null +++ b/apps/soundcraft/src/native_menu.rs @@ -0,0 +1,125 @@ +//! macOS system menu bar. All actions use the same menu dispatch as the egui host. +use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu}; +use soundcraft_ui_egui::{SoundApp, menus}; +use std::sync::mpsc::{Receiver, channel}; + +struct Entry { + item: CheckMenuItem, + command: Option, + path: String, + enabled: bool, + checked: bool, +} + +pub struct NativeMenu { + menu: Menu, + entries: Vec, + events: Receiver, + /// When `sync` last walked the entries; see `SYNC_EVERY`. + last_sync: Option, +} + +/// `sync` walks every menu leaf (~500), so it runs a few times a second rather than every frame, +/// and right after a menu click. +const SYNC_EVERY: std::time::Duration = std::time::Duration::from_millis(250); + +impl NativeMenu { + /// Called on the UI thread after eframe has created NSApplication. + pub fn new(app: &SoundApp, ctx: &egui::Context) -> muda::Result { + let menu = Menu::new(); + let (tx, events) = channel(); + let mut native = Self { menu, entries: Vec::new(), events, last_sync: None }; + let app_menu = Submenu::new("SoundCraft", true); + native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?; + native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?; + app_menu.append(&PredefinedMenuItem::separator())?; + app_menu.append(&PredefinedMenuItem::services(None))?; + app_menu.append(&PredefinedMenuItem::separator())?; + app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?; + app_menu.append(&PredefinedMenuItem::hide_others(None))?; + app_menu.append(&PredefinedMenuItem::show_all(None))?; + app_menu.append(&PredefinedMenuItem::separator())?; + native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?; + native.menu.append(&app_menu)?; + let aliases = menus::ui_aliases(); + for root in menus::tree() { + let submenu = native.add_branch(app, &root, &aliases)?; + native.menu.append(&submenu)?; + } + native.menu.init_for_nsapp(); + let ctx = ctx.clone(); + MenuEvent::set_event_handler(Some(move |event| { + if tx.send(event).is_ok() { + ctx.request_repaint(); + } + })); + Ok(native) + } + + fn add_branch(&mut self, app: &SoundApp, node: &menus::MenuNode, aliases: &[(&str, &str)]) -> muda::Result { + let submenu = Submenu::new(&node.label, true); + for child in &node.children { + if child.children.is_empty() { + self.add_item(app, &submenu, &child.label, &child.path, menus::command_for_path(&child.path, aliases))?; + } else { + submenu.append(&self.add_branch(app, child, aliases)?)?; + } + } + Ok(submenu) + } + + fn add_item(&mut self, app: &SoundApp, parent: &Submenu, label: &str, path: &str, command: Option) -> muda::Result<()> { + let (enabled, checked) = menus::item_state(app, path, command.as_deref()); + // egui owns keyboard shortcuts, so a key press is dispatched only once. + let item = CheckMenuItem::new(label, enabled, checked, None); + parent.append(&item)?; + self.entries.push(Entry { item, command, path: path.into(), enabled, checked }); + Ok(()) + } + + pub fn process(&mut self, app: &mut SoundApp) { + while let Ok(event) = self.events.try_recv() { + // A click changes state (and Cocoa flips the item's check mark): sync on the next pass. + self.last_sync = None; + if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id) + && let Some(command) = &entry.command + && menus::item_state(app, &entry.path, Some(command)).0 + { + if entry.path.is_empty() && command.starts_with("window.") { + // The application menu's About / Session Info open their window (never close it). + let _ = app.run(command, serde_json::json!({"value": true})); + } else { + menus::invoke_menu(app, command, &entry.path); + } + } + } + } + + /// Refresh after engine/control changes; avoid touching Cocoa items with unchanged state. + pub fn sync(&mut self, app: &SoundApp) { + let now = std::time::Instant::now(); + if self.last_sync.is_some_and(|t| now.duration_since(t) < SYNC_EVERY) { + return; + } + self.last_sync = Some(now); + for entry in &mut self.entries { + let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref()); + if enabled != entry.enabled { + entry.item.set_enabled(enabled); + entry.enabled = enabled; + } + // Cocoa toggles check items on click, including ordinary command items. + if checked != entry.checked || entry.item.is_checked() != checked { + entry.item.set_checked(checked); + entry.checked = checked; + } + } + } +} + +impl Drop for NativeMenu { + fn drop(&mut self) { + self.menu.remove_for_nsapp(); + MenuEvent::set_event_handler(None::); + } +} diff --git a/contributors/people.toml b/contributors/people.toml index fad3a62..5079ae9 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -22,6 +22,16 @@ real_name = "Brandon Thomas" display_name = "Brandon Thomas" sync_github_name = false +[people.hamada147] +real_name = "Ahmed Moussa" +display_name = "Ahmed Moussa" +sync_github_name = true + +[people.switch87] +real_name = "Gert Pellin" +display_name = "Gert Pellin" +sync_github_name = false + [people.salignatmoandal] real_name = "Mawen Salignat-Moandal" sync_github_name = true diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index eb1813d..782cb9a 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -257,6 +257,19 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { } } +/// Formats [`encode`] can write, as the extensions that name them. +pub const ENCODE_EXTENSIONS: &str = "wav, aif, aiff, flac"; + +/// The format [`encode`] writes for a file extension or format name (`wav`, `aiff`, `flac`, case-insensitive), or `None` when it cannot write it. +pub fn encode_format_for(name: &str) -> Option { + match name.trim_start_matches('.').to_ascii_lowercase().as_str() { + "wav" | "wave" | "bwf" => Some(FileFormat::Wav), + "aif" | "aiff" => Some(FileFormat::Aiff), + "flac" => Some(FileFormat::Flac), + _ => None, + } +} + /// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB). pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result> { wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None) diff --git a/crates/audio-io/tests/encode_format.rs b/crates/audio-io/tests/encode_format.rs new file mode 100644 index 0000000..766d85b --- /dev/null +++ b/crates/audio-io/tests/encode_format.rs @@ -0,0 +1,13 @@ +use soundcraft_audio_io::{FileFormat, encode_format_for}; + +#[test] +fn only_formats_the_encoder_writes_are_named() { + assert_eq!(encode_format_for("wav"), Some(FileFormat::Wav)); + assert_eq!(encode_format_for("WAV"), Some(FileFormat::Wav)); + assert_eq!(encode_format_for(".aif"), Some(FileFormat::Aiff)); + assert_eq!(encode_format_for("aiff"), Some(FileFormat::Aiff)); + assert_eq!(encode_format_for("flac"), Some(FileFormat::Flac)); + for other in ["mp3", "ogg", "m4a", "xyz", ""] { + assert_eq!(encode_format_for(other), None, "{other}"); + } +} diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 6b6c023..7572ac3 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -278,6 +278,11 @@ pub fn gain_to_db(g: f32) -> f32 { (20.0 * a.log10()).max(MIN_DB) } +/// MIDI note number to frequency in Hz (equal temperament, A4 = note 69 = 440 Hz). +pub fn midi_to_hz(note: f64) -> f64 { + 440.0 * 2f64.powf((note - 69.0) / 12.0) +} + #[cfg(test)] mod trait_default_tests { use super::*; diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 18dacdc..b698856 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -14,6 +14,7 @@ soundcraft-midi.workspace = true soundcraft-dsp.workspace = true soundcraft-clap-host.workspace = true soundcraft-vst3-host.workspace = true +soundcraft-au-host.workspace = true soundcraft-model.workspace = true soundcraft-mix.workspace = true soundcraft-video.workspace = true diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs index 7b39c1f..15ddcd7 100644 --- a/crates/engine/src/cmd/clap.rs +++ b/crates/engine/src/cmd/clap.rs @@ -25,9 +25,13 @@ pub fn specs() -> Vec { )] } -/// A built-in plugin's description, else a hosted CLAP (`clap:`) or VST3 (`vst3:`) plugin's. +/// A built-in plugin's description, else a hosted CLAP (`clap:`), VST3 (`vst3:`) or +/// Audio Units (`au:::`) plugin's. pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { - soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id)).or_else(|| soundcraft_vst3_host::plugin_info(id)) + soundcraft_dsp::plugin_info(id) + .or_else(|| soundcraft_clap_host::plugin_info(id)) + .or_else(|| soundcraft_vst3_host::plugin_info(id)) + .or_else(|| soundcraft_au_host::plugin_info(id)) } #[cfg(test)] @@ -52,4 +56,15 @@ mod tests { assert!(super::plugin_info("eq_7band").is_some()); assert!(super::plugin_info("clap:no.such.plugin").is_none()); } + + /// Apple's own Audio Units ship with every Mac, so `mix.insert` can be checked against one. + #[cfg(target_os = "macos")] + #[test] + fn audio_units_insert_by_id() { + let mut e = Engine::default(); + e.execute("track.new", &json!({})).unwrap(); + let t = e.session().tracks[0].id.0; + let r = e.execute("mix.insert", &json!({"track": t, "plugin": "au:aufx:bpas:appl"})); + assert_eq!(r.map(|v| v["plugin"].clone()), Ok(json!("au:aufx:bpas:appl")), "Apple AUBandpass"); + } } diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index bfbb6df..34fb355 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result { e.transport_requests.push(crate::TransportRequest::Locate(t)); Ok(json!({"at": t})) } + +#[cfg(test)] +mod tests { + use crate::demo::demo_engine; + use serde_json::json; + + /// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position. + #[test] + fn tempo_change_at_position_is_applied() { + let mut e = demo_engine(); + let sr = e.session().sample_rate; + let at = sr.samples(4.0); + e.execute("event.tempo", &json!({"bpm": 133.5, "at": at})).unwrap(); + let s = e.session(); + let tick = s.tempo.samples_to_ticks(at, s.sample_rate); + assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 86bb4b5..796ad8f 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -3,7 +3,7 @@ use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::Session; +use soundcraft_model::{Session, TrackKind}; use soundcraft_time::SampleRate; pub fn specs() -> Vec { @@ -11,8 +11,8 @@ pub fn specs() -> Vec { cmd!(noundo "session.new", "New...", ["File"], Some("Cmd+N"), "{name?: 'Untitled', sample_rate?: 48000, bit_depth?: 24, template?: blank|demo}", always, new_session), cmd!(noundo "session.open", "Open Session...", ["File"], Some("Cmd+O"), "{path}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("session.open", "`path` required"))?.to_string(); - crate::io::open_session(e, &path)?; - Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len()})) + let missing = crate::io::open_session(e, &path)?; + Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing})) }), cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }), cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| { @@ -52,7 +52,7 @@ pub fn specs() -> Vec { "Audio...", ["File", "Import"], Some("Cmd+Shift+I"), - "{path | paths: [..], track?: target track, at?: position, new_tracks?: true}", + "{path | paths: [..], track?: target track, at?: position, new_tracks?: true (default; false uses `track` or the selected audio track)}", always, import_audio ), @@ -208,7 +208,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { return Err(bad("file.import_audio", "`path` or `paths` required")); } let at = position_param(e, "file.import_audio", p, "at")?.unwrap_or(0).max(0); - let target = track_param(e, "file.import_audio", p, "track")?; + let target = import_audio_target(e, p)?; let mut out = Vec::new(); for path in &paths { let bytes = std::fs::read(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; @@ -219,6 +219,21 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { Ok(json!({"imported": out})) } +/// Destination for `file.import_audio`: an explicit `track` wins; otherwise `new_tracks` +/// (default true) creates a new track per file, and `new_tracks: false` uses the selected +/// audio track or errors when none is selected. +fn import_audio_target(e: &Engine, p: &Value) -> Result> { + if let Some(t) = track_param(e, "file.import_audio", p, "track")? { + return Ok(Some(t)); + } + if bool_or(p, "new_tracks", true) { + return Ok(None); + } + let s = e.session(); + let selected = s.edit.selected_tracks.iter().find_map(|id| s.track(*id).filter(|t| t.kind == TrackKind::Audio).map(|t| t.id)); + selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track")) +} + /// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix. fn fold_down_param(p: &Value, id: &str) -> Result { match str_param(p, "fold_down").map(str::trim) { @@ -233,11 +248,10 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let r = range_param(e, "file.bounce_mix", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); - let format = match str_param(p, "format").unwrap_or(ext.as_str()) { - "aif" | "aiff" => soundcraft_audio_io::FileFormat::Aiff, - "flac" => soundcraft_audio_io::FileFormat::Flac, - _ => soundcraft_audio_io::FileFormat::Wav, - }; + let format_name = str_param(p, "format").unwrap_or(ext.as_str()); + let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| { + bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)) + })?; let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() { Some("16") => soundcraft_audio_io::BitDepth::Int16, Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, @@ -270,3 +284,110 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0); Ok(json!({"path": path, "parts": parts.len(), "measures": measures})) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Engine; + use soundcraft_audio_io::{AudioBuffer, EncodeOptions}; + use soundcraft_model::ChannelFormat; + + fn tone_wav(dir: &std::path::Path, name: &str) -> String { + let tone: Vec = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect(); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] }; + let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap(); + let path = dir.join(name); + std::fs::write(&path, bytes).unwrap(); + path.to_string_lossy().into_owned() + } + + #[test] + fn import_audio_honours_new_tracks() { + let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let wav = tone_wav(&dir, "tone.wav"); + + // Default / new_tracks:true: create a new track even when one is selected. + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap(); + let target = e.session().tracks[0].id; + e.session_mut().edit.selected_tracks = vec![target]; + e.execute("file.import_audio", &json!({"path": wav, "new_tracks": true})).unwrap(); + assert_eq!(e.session().tracks.len(), 2); + assert_eq!(e.session().track(target).unwrap().clips().len(), 0); + assert_eq!(e.session().tracks[1].clips().len(), 1); + + // new_tracks:false with a selected audio track: import onto it. + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap(); + let target = e.session().tracks[0].id; + e.session_mut().edit.selected_tracks = vec![target]; + e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().track(target).unwrap().clips().len(), 1); + + // new_tracks:false with no selection and no track: clear error. + let mut e = Engine::default(); + let err = e.execute("file.import_audio", &json!({"path": wav, "new_tracks": false})).unwrap_err(); + assert!(err.to_string().contains("new_tracks: false"), "{err}"); + + // Explicit track wins over new_tracks:true. + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 1, "name": "Target", "format": "mono"})).unwrap(); + e.execute("file.import_audio", &json!({"path": wav, "track": "Target", "new_tracks": true})).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].clips().len(), 1); + + // Omitted new_tracks still creates a track on a blank session. + let mut e = Engine::default(); + e.execute("file.import_audio", &json!({"path": wav})).unwrap(); + assert_eq!(e.session().tracks.len(), 1); + assert_eq!(e.session().tracks[0].kind, TrackKind::Audio); + assert_eq!(e.session().tracks[0].format, ChannelFormat::Mono); + + let _ = std::fs::remove_dir_all(dir); + } + + #[test] + fn bounce_refuses_formats_it_cannot_write() { + let mut e = Engine::default(); + let dir = std::env::temp_dir().join(format!("sc-bounce-ext-{}", std::process::id())); + assert!(std::fs::create_dir_all(&dir).is_ok()); + for name in ["mix.mp3", "mix.ogg", "mix.xyz"] { + let path = dir.join(name); + let err = e.execute("file.bounce_mix", &json!({"path": path.to_string_lossy(), "start": 0, "end": 480})).map(|_| ()).unwrap_err(); + assert!(err.to_string().contains("supported: wav"), "{name}: {err}"); + assert!(!path.exists(), "{name} must not be written"); + } + let ok = dir.join("mix.flac"); + assert!(e.execute("file.bounce_mix", &json!({"path": ok.to_string_lossy(), "start": 0, "end": 480})).is_ok()); + assert!(std::fs::remove_dir_all(&dir).is_ok()); + } + + fn remove_media(dir: &std::path::Path) { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let p = entry.path(); + if p.is_dir() { + remove_media(&p); + } else if p.extension().is_some_and(|x| x != "scraft") { + let _ = std::fs::remove_file(&p); + } + } + } + + #[test] + fn session_open_reports_missing_media() { + let mut e = crate::demo::demo_engine(); + let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id())); + let path = dir.join("Gone.scraft").to_string_lossy().into_owned(); + e.execute("session.save_as", &json!({"path": path})).unwrap(); + let mut ok = Engine::default(); + assert!(ok.execute("session.open", &json!({"path": path})).unwrap()["missing"].as_array().is_some_and(Vec::is_empty)); + remove_media(&dir); + let mut r = Engine::default(); + let res = r.execute("session.open", &json!({"path": path})).unwrap(); + assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs new file mode 100644 index 0000000..6b34657 --- /dev/null +++ b/crates/engine/src/cmd/instrument.rs @@ -0,0 +1,83 @@ +//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI +//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`; +//! the instrument is otherwise only set when the track is created (`track.new`). + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{Insert, TrackKind}; + +pub fn specs() -> Vec { + vec![cmd!( + "mix.instrument", + "Set Instrument", + [], + None, + "{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id", + has_selection, + set_instrument + )] +} + +fn set_instrument(e: &mut Engine, p: &Value) -> Result { + let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.instrument", "`plugin` required"))?; + let info = super::clap::plugin_info(plugin).ok_or_else(|| bad("mix.instrument", format!("unknown plugin `{plugin}`")))?; + if !info.is_instrument { + return Err(bad("mix.instrument", format!("`{plugin}` is not an instrument"))); + } + let t = tracks_required(e, "mix.instrument", p)?.first().copied().ok_or_else(|| bad("mix.instrument", "no track"))?; + if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) { + return Err(bad("mix.instrument", "only instrument tracks have an instrument")); + } + let mut ins = Insert::new(info.id); + for pi in info.params { + ins.params.insert(pi.id.to_string(), pi.default); + } + if let Some(tr) = e.session_mut().track_mut(t) { + tr.instrument = Some(ins); + } + Ok(json!({"track": t, "plugin": info.id})) +} + +#[cfg(test)] +mod tests { + use crate::Engine; + use serde_json::json; + use soundcraft_model::TrackId; + + fn track(e: &mut Engine, kind: &str) -> TrackId { + e.execute("track.new", &json!({"kind": kind})).unwrap(); + e.session().tracks.last().unwrap().id + } + + fn instrument_of(e: &Engine, t: TrackId) -> Option { + e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.plugin.clone()) + } + + #[test] + fn sets_an_instrument_tracks_instrument_with_defaults_and_undoes() { + let mut e = Engine::default(); + let t = track(&mut e, "instrument"); + assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth")); + let r = e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap(); + assert_eq!(r["plugin"], "drum_synth"); + assert_eq!(instrument_of(&e, t).as_deref(), Some("drum_synth")); + let params = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.params.len()); + assert_eq!(params, soundcraft_dsp::plugin_info("drum_synth").map(|p| p.params.len()), "every parameter at its default"); + e.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth")); + } + + #[test] + fn refuses_effects_unknown_plugins_and_other_track_kinds() { + let mut e = Engine::default(); + let inst = track(&mut e, "instrument"); + let audio = track(&mut e, "audio"); + assert!(e.execute("mix.instrument", &json!({"track": inst.0, "plugin": "eq_7band"})).is_err(), "an effect"); + assert!(e.execute("mix.instrument", &json!({"track": inst.0, "plugin": "vst3:00000000000000000000000000000000"})).is_err()); + assert!(e.execute("mix.instrument", &json!({"track": inst.0})).is_err(), "no plugin"); + assert!(e.execute("mix.instrument", &json!({"track": audio.0, "plugin": "drum_synth"})).is_err(), "an audio track"); + assert_eq!(instrument_of(&e, inst).as_deref(), Some("subtractive_synth")); + assert_eq!(instrument_of(&e, audio), None); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 665056a..57ea4cf 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ mod edit_more; mod event; mod event_more; mod file; +mod instrument; mod midi; mod mix; mod more_util; @@ -119,6 +120,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(vst3::specs()); v.extend(surround::specs()); v.extend(plugin_state::specs()); + v.extend(instrument::specs()); v.extend(video::specs()); v }) diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index bc8a2a2..1e7daa0 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec, Vec, Vec) { (k, s, h) } -fn midi_hz(n: f64) -> f64 { - 440.0 * 2f64.powf((n - 69.0) / 12.0) -} - /// Chord roots per bar (A minor, F, C, G progression), as MIDI notes. const PROG: [[u8; 4]; 4] = [[57, 60, 64, 69], [53, 57, 60, 65], [48, 52, 55, 60], [55, 59, 62, 67]]; @@ -127,7 +123,7 @@ fn bass(sr: f64, frames: usize) -> Vec { for (eighth, semi, beats) in pattern { let at = bar * beat * 4 + eighth * beat / 2; let len = (beats * beat as f64) as usize; - let f = midi_hz(root + semi); + let f = soundcraft_dsp::midi_to_hz(root + semi); let mut ph = 0.0f64; for i in 0..len { let Some(o) = out.get_mut(at + i) else { break }; @@ -152,7 +148,7 @@ fn pad(sr: f64, frames: usize) -> (Vec, Vec) { let chord = PROG[bar % 4]; let at = bar * bar_len; for (vi, n) in chord.iter().enumerate() { - let f = midi_hz(f64::from(*n) + 12.0); + let f = soundcraft_dsp::midi_to_hz(f64::from(*n) + 12.0); for (side, buf) in [(-1.0f64, &mut l), (1.0, &mut r)] { let det = 1.0 + side * 0.0035 * (vi as f64 + 1.0); let mut ph = vi as f64 * 0.17; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f5c47fd..88584e0 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -23,6 +23,13 @@ use std::sync::Arc; pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; pub use soundcraft_model as model; +/// Shuts the third-party plugin hosts down, running the module exit of every plugin binary they +/// loaded. Call it at the end of `main`, once every plugin instance (mixer, player) is gone: a +/// plugin that has been used can crash when its module exit only runs from an exit handler. +pub fn shutdown_plugin_hosts() { + soundcraft_vst3_host::shutdown(); +} + #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum EngineError { #[error("unknown command `{0}`")] diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index ecc15af..70cdd1c 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -299,6 +299,9 @@ pub struct MixEngine { pub monitor_only: bool, /// Recording: record-armed tracks hear their input (auto input). pub recording: bool, + /// Mix the metronome (Options > Click) into the output. Playback turns this on; offline renders + /// (bounces, stems, exports) leave it off so the click is never printed into a file. + pub metronome: bool, /// Third-party instances come from [`MixEngine::adopt`] instead of being created in `sync`. external: bool, /// Instances handed over by the host, waiting for `sync` to place them. @@ -363,6 +366,7 @@ impl MixEngine { input: Vec::new(), monitor_only: false, recording: false, + metronome: false, external: false, adopt: HashMap::new(), retired: Vec::new(), @@ -655,6 +659,13 @@ impl MixEngine { self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0); } } + // Metronome: summed after the master fader, so it reaches the speakers without passing + // 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) { + click::render(s, pos, frames, &mut self.main); + } // Output: min(out.len(), main channels) channels; any further output channels are silent. for (c, o) in out.iter_mut().enumerate() { let src = self.main.get(c); @@ -1000,12 +1011,47 @@ fn structure_fingerprint(s: &Session) -> usize { h.finish() as usize } +std::thread_local! { + static AUDIO_THREAD: std::cell::Cell = const { std::cell::Cell::new(false) }; +} + +/// Mark the calling thread as the realtime audio thread (`soundcraft_playback`'s cpal callbacks +/// do, every call: one thread-local store). Strips [`MixEngine::render`] hands to worker threads +/// count as the audio thread while they render for it. Code that might run there asks +/// [`on_audio_thread`] before doing anything that blocks; the desktop app's logger does, so a +/// `log::` record from the audio thread never takes a lock or touches a file. +pub fn mark_audio_thread() { + AUDIO_THREAD.set(true); +} + +/// True on the thread [`mark_audio_thread`] marked, and on a worker while it renders strips for +/// it. Lock-free and allocation-free. +pub fn on_audio_thread() -> bool { + AUDIO_THREAD.get() +} + +/// Run `f` on this thread as (or as not) the audio thread, then restore the thread's own mark +/// (also when `f` unwinds): a pool worker renders for realtime playback and offline bounces alike. +#[cfg(not(target_arch = "wasm32"))] +fn as_audio_thread(audio: bool, f: impl FnOnce()) { + struct Restore(bool); + impl Drop for Restore { + fn drop(&mut self) { + AUDIO_THREAD.set(self.0); + } + } + let _restore = Restore(AUDIO_THREAD.replace(audio)); + f(); +} + /// Process strips, in parallel on native targets when there are enough of them. #[cfg(not(target_arch = "wasm32"))] fn run_strips(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) { use rayon::prelude::*; if work.len() >= 4 { - work.par_iter_mut().for_each(f); + // The workers render on behalf of this thread: on the audio thread, they are it too. + let audio = on_audio_thread(); + work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w))); } else { work.iter_mut().for_each(f); } @@ -1075,6 +1121,67 @@ fn receives_solo(s: &Session, t: &Track) -> bool { .any(|o| o.mixer.solo && (o.mixer.output == Route::Bus(*b) || o.mixer.sends.iter().flatten().any(|sn| sn.target == Route::Bus(*b)))) } +/// Metronome: a short synthesized blip on every beat of the tempo map, accented on beat 1 of +/// each bar. Settings come from `setup.click_countoff` (`click.*` values). +mod click { + use super::*; + + /// Blip length in seconds. + const LEN_S: f64 = 0.03; + + pub fn render(s: &Session, pos: Samples, frames: usize, main: &mut [Vec]) { + let sr = s.sample_rate; + let rate = sr.as_f64(); + let len = (LEN_S * rate) as i64; + let end = pos.saturating_add(frames as i64); + let gain = f64::from(db_to_gain(s.edit.value("click.volume_db", -6.0).clamp(-60.0, 12.0) as f32)); + let accent = (note_hz(s.edit.value("click.accent_note", 84.0)), s.edit.value("click.accent_velocity", 127.0)); + let normal = (note_hz(s.edit.value("click.normal_note", 72.0)), s.edit.value("click.normal_velocity", 100.0)); + // Start one blip length early so a click that began in an earlier block still rings into this one. + let t0 = s.tempo.samples_to_ticks(pos.saturating_sub(len), sr).max(0).saturating_sub(1); + let mut tick = t0.saturating_sub(s.tempo.bar_beat_at_tick(t0).tick); + // Blocks hold a few beats at most; the guard only protects against a malformed tempo map. + for _ in 0..4096 { + let at = s.tempo.tick_to_samples(tick, sr); + if at >= end { + break; + } + if at.saturating_add(len) > pos { + let (hz, vel) = if s.tempo.bar_beat_at_tick(tick).beat == 1 { accent } else { normal }; + let amp = gain * (vel.clamp(1.0, 127.0) / 127.0); + blip(main, at - pos, frames, hz, amp as f32, rate); + } + tick = tick.saturating_add(s.tempo.meter_at_tick(tick).ticks_per_beat()); + } + } + + /// Adds one decaying sine blip whose first sample sits `rel` frames into the block (negative + /// when it began before the block) to every channel. + fn blip(main: &mut [Vec], rel: i64, frames: usize, hz: f64, amp: f32, rate: f64) { + let len = (LEN_S * rate) as usize; + let w = std::f64::consts::TAU * hz / rate; + let first = usize::try_from(-rel).unwrap_or(0); + for i in first..len { + let Ok(at) = usize::try_from(rel + i as i64) else { continue }; + if at >= frames { + break; + } + let env = (-6.0 * i as f64 / len as f64).exp() as f32; + let v = ((w * i as f64).sin() as f32) * env * amp; + for ch in main.iter_mut() { + if let Some(d) = ch.get_mut(at) { + *d += v; + } + } + } + } + + /// Click pitch for a MIDI note setting (clamped to the MIDI range). + fn note_hz(note: f64) -> f64 { + soundcraft_dsp::midi_to_hz(note.clamp(0.0, 127.0)) + } +} + fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut [Vec], _scratch: &mut [f32]) { let ClipContent::Audio { source, offset } = clip.content else { return }; let block = Range { start: pos, end: pos.saturating_add(frames as i64) }; @@ -1642,6 +1749,50 @@ fn create_plugin(id: &str) -> Option> { .or_else(|| soundcraft_au_host::create(id)) } +#[cfg(test)] +mod audio_thread_tests { + use super::*; + + #[test] + fn only_a_marked_thread_is_the_audio_thread() { + assert!(!on_audio_thread()); + let marked = std::thread::spawn(|| { + let before = on_audio_thread(); + mark_audio_thread(); + (before, on_audio_thread()) + }) + .join() + .unwrap(); + assert_eq!(marked, (false, true)); + assert!(!on_audio_thread(), "marking another thread leaves this one alone"); + } + + /// What `on_audio_thread` says inside each strip job, for strips run from a thread that is + /// (or is not) the audio thread. + fn seen_by_strips(audio: bool) -> Vec { + std::thread::spawn(move || { + if audio { + mark_audio_thread(); + } + let mut work = vec![None; 64]; + run_strips(&mut work, |w| { + // Long enough that the pool's workers take part. + std::thread::sleep(std::time::Duration::from_millis(1)); + *w = Some(on_audio_thread()); + }); + work.into_iter().map(|w| w.unwrap()).collect() + }) + .join() + .unwrap() + } + + #[test] + fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() { + assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it"); + assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards"); + } +} + #[cfg(test)] mod tests { use super::*; @@ -1934,6 +2085,76 @@ mod tests { assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); } + #[test] + fn stopped_monitoring_does_not_click() { + let mut s = Session::default(); + s.edit.click = true; + let mut eng = MixEngine::new(48_000.0, 512); + eng.metronome = true; + eng.monitor_only = true; + let mut out = vec![vec![0.0f32; 512]; 2]; + for _ in 0..4 { + eng.render(&s, 0, 512, &mut out); + assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0))); + } + } + + /// Renders `len` frames from zero the way playback does (metronome on), in blocks of `block`. + fn render_playback(s: &Session, len: usize, block: usize, recording: bool) -> Vec> { + let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + eng.metronome = true; + eng.recording = recording; + let mut out = vec![vec![0.0f32; len]; 2]; + let mut tmp = vec![vec![0.0f32; block]; 2]; + let mut done = 0usize; + while done < len { + let n = (len - done).min(block); + eng.render(s, done as i64, n, &mut tmp); + for (o, t) in out.iter_mut().zip(tmp.iter()) { + if let (Some(d), Some(src)) = (o.get_mut(done..done + n), t.get(..n)) { + d.copy_from_slice(src); + } + } + done += n; + } + out + } + + #[test] + fn click_sounds_on_every_beat_only_in_playback() { + let mut s = Session::default(); + let beat = s.sample_rate.as_f64() as usize * 60 / 120; // 120 BPM quarter notes + let len = beat * 8; + s.edit.click = true; + // Offline renders (bounces, stems) never print the metronome. + let offline = render_range(&s, Range::new(0, len as i64), 512); + assert!(offline.iter().all(|c| c.iter().all(|x| *x == 0.0))); + + let out = render_playback(&s, len, 512, false); + for b in 0..8usize { + let at = b * beat; + let on: f32 = out[0][at..at + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + let off: f32 = out[0][at + beat / 2..at + beat / 2 + 200].iter().map(|x| x.abs()).fold(0.0, f32::max); + assert!(on > 0.1, "beat {b} should click, peak {on}"); + assert_eq!(off, 0.0, "between beats must stay silent"); + assert_eq!(out[0][at], out[1][at], "both channels get the click"); + } + // Blocks that start mid-beat render the same clicks as a single pass. + let split = render_playback(&s, len, 97, false); + assert_eq!(out, split); + } + + #[test] + fn click_only_during_record_follows_recording() { + let mut s = Session::default(); + s.edit.click = true; + s.edit.set_flag("click.only_during_record", true); + let idle = render_playback(&s, 48_000, 512, false); + assert!(idle.iter().all(|c| c.iter().all(|x| *x == 0.0))); + let rec = render_playback(&s, 48_000, 512, true); + assert!(rec.iter().any(|c| c.iter().any(|x| x.abs() > 0.1))); + } + /// A session with one track of `fmt` playing a distinct DC level per channel (0.1, 0.2, …). fn multichannel_session(fmt: ChannelFormat, main: ChannelFormat, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index 6bc6b29..eb1db9b 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -35,6 +35,10 @@ use std::sync::{Arc, Mutex, PoisonError}; const BLOCK: usize = 512; +/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it +/// renders on worker threads; the cpal callbacks here set it. +pub use soundcraft_mix::{mark_audio_thread, on_audio_thread}; + /// Meter values for the UI. #[derive(Debug, Clone, Default)] pub struct MeterSnapshot { @@ -99,6 +103,7 @@ struct AudioState { fn new_engine(sr: f32) -> MixEngine { let mut m = MixEngine::new(sr, BLOCK); m.set_external_instances(true); + m.metronome = true; m } @@ -222,7 +227,7 @@ impl AudioState { if self.mix.input.len() != ch { self.mix.input = vec![vec![0.0; BLOCK]; ch]; } - ring.pop_into(&mut self.mix.input, n); + ring.pop_into(&mut self.mix.input, n, self.session.sample_rate.hz()); } self.mix.monitor_only = !self.playing; self.mix.recording = self.recording; @@ -390,11 +395,19 @@ impl Player { let rate = config.sample_rate.0; let channels = usize::from(config.channels); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); - let err_fn = |e: cpal::StreamError| log::warn!("audio stream error: {e}"); + // cpal may call this on the audio thread (on ALSA in a loop while the device + // stays broken): marked, so the logger hands the record off instead of writing it. + let err_fn = |e: cpal::StreamError| { + mark_audio_thread(); + log::warn!("audio stream error: {e}"); + }; use cpal::traits::{DeviceTrait, StreamTrait}; match device.build_output_stream( &config, - move |data: &mut [f32], _: &cpal::OutputCallbackInfo| state.fill(data, channels), + move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { + mark_audio_thread(); + state.fill(data, channels); + }, err_fn, None, ) { diff --git a/crates/playback/src/record.rs b/crates/playback/src/record.rs index d9def4c..09eb684 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -50,13 +50,14 @@ impl Recorder { let config = cfg.config(); let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) }); let sh = Arc::clone(&shared); - let monitor = InputRing::new(usize::from(config.channels)); + let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); let mut backlog: Vec = Vec::new(); let stream = device .build_input_stream( &config, move |data: &[f32], _: &cpal::InputCallbackInfo| { + crate::mark_audio_thread(); mon.push(data); if !sh.armed.load(Ordering::Relaxed) { backlog.clear(); @@ -72,7 +73,11 @@ impl Recorder { backlog.extend_from_slice(data); } }, - |e| log::warn!("input stream error: {e}"), + |e| { + // Possibly the audio thread (see `crate::mark_audio_thread`). + crate::mark_audio_thread(); + log::warn!("input stream error: {e}"); + }, None, ) .map_err(|e| e.to_string())?; @@ -140,47 +145,133 @@ mod tests { } } +/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the +/// fractional read position (in input frames) of the next output sample. +struct RingState { + data: std::collections::VecDeque, + phase: f64, +} + /// A small interleaved FIFO from the input callback to the output callback, for input /// monitoring. Both sides only `try_lock`; it holds at most ~0.5 s and drops the oldest frames. -#[derive(Default)] +/// The input device may run at a different rate than the session, so the reader converts +/// rates with linear interpolation (cheap, allocation-free, fine for monitoring). pub struct InputRing { - data: Mutex>, + state: Mutex, pub channels: std::sync::atomic::AtomicUsize, + /// Input device sample rate in Hz (0 when unknown, which reads as "same as the session"). + rate: u32, + /// Most samples kept: half a second of input, or 24 000 frames when the rate is unknown. + cap: usize, } impl InputRing { - pub fn new(channels: usize) -> Arc { + pub fn new(channels: usize, rate: u32) -> Arc { + let ch = channels.max(1); + let frames = if rate == 0 { 24_000 } else { rate as usize / 2 }; Arc::new(InputRing { - data: Mutex::new(std::collections::VecDeque::with_capacity(48_000 * channels.max(1))), - channels: std::sync::atomic::AtomicUsize::new(channels.max(1)), + state: Mutex::new(RingState { data: std::collections::VecDeque::with_capacity(rate.max(48_000) as usize * ch), phase: 0.0 }), + channels: std::sync::atomic::AtomicUsize::new(ch), + rate, + cap: frames * ch, }) } pub fn push(&self, samples: &[f32]) { - if let Ok(mut d) = self.data.try_lock() { - let cap = 24_000 * self.channels.load(Ordering::Relaxed).max(1); - d.extend(samples.iter().copied()); - while d.len() > cap { - d.pop_front(); + if let Ok(mut s) = self.state.try_lock() { + s.data.extend(samples.iter().copied()); + while s.data.len() > self.cap { + s.data.pop_front(); } } } - /// Fill `out` (planar) with up to `frames` frames; missing frames are silence. - pub fn pop_into(&self, out: &mut [Vec], frames: usize) { + /// Fill `out` (planar, at `session_rate`) with up to `frames` frames, converting from the + /// input rate. Missing frames are silence. + pub fn pop_into(&self, out: &mut [Vec], frames: usize, session_rate: u32) { let ch = self.channels.load(Ordering::Relaxed).max(1); for c in out.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } - let Ok(mut d) = self.data.try_lock() else { return }; - let avail = (d.len() / ch).min(frames); - for f in 0..avail { + let Ok(mut s) = self.state.try_lock() else { return }; + // Input frames consumed per output frame. + let step = if self.rate == 0 || session_rate == 0 { 1.0 } else { f64::from(self.rate) / f64::from(session_rate) }; + for f in 0..frames { + // Drop input frames we have moved past; keep the one at `phase` and the next one. + while s.phase >= 1.0 && s.data.len() >= 2 * ch { + s.data.drain(..ch); + s.phase -= 1.0; + } + if s.phase >= 1.0 || s.data.len() < 2 * ch { + break; + } + let fr = s.phase as f32; for c in 0..ch { - let v = d.pop_front().unwrap_or(0.0); + let a = s.data.get(c).copied().unwrap_or(0.0); + let b = s.data.get(ch + c).copied().unwrap_or(a); if let Some(x) = out.get_mut(c).and_then(|o| o.get_mut(f)) { - *x = v; + *x = a + (b - a) * fr; } } + s.phase += step; } } } + +#[cfg(test)] +mod ring_tests { + use super::InputRing; + + /// A 1 kHz sine recorded at 16 kHz must come out as a 1 kHz sine at 48 kHz, across calls. + #[test] + fn monitor_converts_input_rate_to_session_rate() { + let ring = InputRing::new(1, 16_000); + let input: Vec = (0..4_000).map(|i| (2.0 * std::f64::consts::PI * 1_000.0 * f64::from(i) / 16_000.0).sin() as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 48]]; + let mut k = 0usize; + for _ in 0..10 { + ring.pop_into(&mut out, 48, 48_000); + for v in &out[0] { + let want = (2.0 * std::f64::consts::PI * 1_000.0 * k as f64 / 48_000.0).sin() as f32; + assert!((v - want).abs() < 0.05, "sample {k}: got {v}, want {want}"); + k += 1; + } + } + } + + /// Input faster than the session (96 kHz into 48 kHz): every other frame is read, in order. + #[test] + fn monitor_decimates_when_input_is_faster() { + let ring = InputRing::new(1, 96_000); + let input: Vec = (0..1_000).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 100]]; + ring.pop_into(&mut out, 100, 48_000); + for (k, v) in out[0].iter().enumerate() { + assert_eq!(*v, (2 * k) as f32, "output {k}"); + } + } + + /// Same rate: samples pass through unchanged. + #[test] + fn monitor_passes_through_at_equal_rates() { + let ring = InputRing::new(2, 48_000); + let input: Vec = (0..200).map(|i| i as f32).collect(); + ring.push(&input); + let mut out = vec![vec![0.0f32; 50], vec![0.0f32; 50]]; + ring.pop_into(&mut out, 50, 48_000); + assert_eq!(out[0][0], 0.0); + assert_eq!(out[1][0], 1.0); + assert_eq!(out[0][49], 98.0); + } + + /// Nothing buffered: silence, not stale data. + #[test] + fn monitor_underrun_is_silence() { + let ring = InputRing::new(1, 16_000); + let mut out = vec![vec![1.0f32; 16]]; + ring.pop_into(&mut out, 16, 48_000); + assert!(out[0].iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index c91b324..0439b8b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -21,6 +21,7 @@ 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/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 6815d60..d212634 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -6,6 +6,7 @@ use egui::{Align2, vec2}; use serde_json::{Value, json}; use soundcraft_engine::Engine; use soundcraft_model::{ClipId, TrackId}; +use soundcraft_time::Samples; #[derive(Debug, Clone)] pub enum Dialog { @@ -16,6 +17,7 @@ pub enum Dialog { RenameClip { id: ClipId, name: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, + TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, @@ -38,6 +40,7 @@ impl Dialogs { Dialog::RenameClip { .. } => "rename_clip", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", + Dialog::TempoChange { .. } => "tempo_change", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", @@ -167,6 +170,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), + Dialog::TempoChange { .. } => "Tempo Change (BPM)", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", @@ -258,7 +262,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ui.label( egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.") .small() - .color(Tokens::DARK.text_dim), + .color(Tokens::current().text_dim), ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); @@ -315,6 +319,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some((cmd.clone(), v)); } } + Dialog::TempoChange { at, bpm } => { + ui.horizontal(|ui| { + // Same limits the engine enforces (time crate `valid_bpm`). + ui.add(egui::DragValue::new(bpm).speed(0.1).range(5.0..=1000.0)); + ui.label("bpm"); + }); + if buttons(ui, "OK", enter) { + action = Some(tempo_change_action(*at, *bpm)); + } + } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); @@ -415,13 +429,18 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool { if ui.button("Cancel").clicked() { ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true)); } - if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::DARK.accent)).clicked() { + if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent)).clicked() { pressed = true; } }); pressed } +/// The command the tempo-change dialog runs on OK. +fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) { + ("event.tempo".into(), json!({"bpm": bpm, "at": at})) +} + fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { let Some(process) = app.ui.audiosuite.clone() else { return }; let mut open = true; @@ -495,4 +514,12 @@ mod tests { assert!(d.open_for_command(&e, "file.score_setup")); assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. }))); } + + #[test] + fn tempo_change_dialog_runs_a_real_command_at_its_position() { + let (cmd, params) = tempo_change_action(96_000, 133.5); + assert!(soundcraft_engine::command_specs().iter().any(|c| c.id == cmd), "{cmd} is not a command"); + assert_eq!(params["at"], 96_000); + assert_eq!(params["bpm"], 133.5); + } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 17a54b0..f120c4a 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -32,6 +32,18 @@ pub struct EditLayout { pub rows: Vec<(u64, [f32; 4])>, pub scroll_y: f32, pub content_h: f32, + /// Follow-hold: the view was moved by something other than the playhead + /// follow (a manual pan, scrollbar, universe jump, or programmatic + /// scroll) during playback, so timeline auto-scroll stays paused until + /// the playhead is scrolled back into view (or the transport + /// stops/starts, or a scrolling mode is re-selected). Lets the timeline + /// be inspected anywhere while playing. + pub follow_hold: bool, + /// Session `zoom.scroll` seen by the last overlay frame, for telling our + /// own follow jumps apart from outside view moves. + pub last_scroll: Samples, + /// Follow target issued by the last overlay frame (`None` when none). + pub last_follow_to: Option, } pub fn x_of(s: &Session, tl: Rect, at: Samples) -> f32 { @@ -50,7 +62,7 @@ pub fn track_height(t: &Track) -> f32 { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); 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)); @@ -63,7 +75,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { .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(Color32::from_rgb(18, 18, 19))).show(ui, |ui| universe(app, ui)); + 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") @@ -101,10 +113,10 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) { let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0]; let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total; let vr = Rect::from_min_max(pos2(r.min.x + vis0 * r.width(), r.min.y - 2.0), pos2(r.min.x + vis1.min(1.0) * r.width(), r.max.y + 2.0)); - ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Outside); + ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Tokens::current().universe_view), StrokeKind::Outside); if app.is_playing() { let x = r.min.x + app.position() as f32 / total * r.width(); - ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::DARK.playhead)); + ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead)); } let resp = ui.interact(r, ui.id().with("universe"), Sense::click_and_drag()); if let Some(p) = resp.interact_pointer_pos() @@ -117,7 +129,7 @@ fn universe(app: &mut SoundApp, ui: &mut Ui) { } fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let r = ui.max_rect(); let mut x = r.min.x + 10.0; for (label, on, id) in [ @@ -139,7 +151,7 @@ fn dock_tabs(app: &mut SoundApp, ui: &mut Ui) { } fn status_bar(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let r = ui.max_rect(); let s = app.engine.session(); let left = format!( @@ -166,7 +178,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { } fn main_area(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let full = ui.max_rect(); let rulers: Vec = app.engine.session().edit.rulers.clone(); let mut rulers = rulers; @@ -251,7 +263,7 @@ fn folder_collapsed(s: &Session, t: &Track) -> bool { } fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); ui.painter().rect_filled(sb, 0.0, t.panel_bg); let s = app.engine.session(); let total = (s.content_end() + s.sample_rate.samples(30.0)).max(1) as f64; @@ -349,7 +361,7 @@ fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 { } fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) { - let t = Tokens::DARK; + let t = Tokens::current(); let s = app.engine.session().clone(); ui.painter().rect_filled(area, 0.0, t.ruler_bg); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); @@ -372,13 +384,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ { let at = snap(&s, sample_at(&s, tl, p.x).max(0)); let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr)); - app.dialogs.open = Some(crate::dialogs::Dialog::Number { - cmd: format!("event.tempo@{at}"), - title: "Tempo Change (BPM)".into(), - key: "bpm".into(), - value: bpm, - suffix: "bpm".into(), - }); + app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm }); } let mut last_label = f32::MIN; for ev in s.tempo.tempos() { @@ -472,7 +478,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ col, Stroke::NONE, )); - painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), Color32::from_rgb(236, 236, 236)); + painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text); } // Click on the marker ruler: recall a marker; double-click adds one. let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click()); @@ -516,7 +522,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: Rect, view: Range, f: TimeFormat, bottom: bool) { - let t = Tokens::DARK; + let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; let mut marks: Vec<(Samples, bool)> = Vec::new(); @@ -615,7 +621,7 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: // ---- track rows ------------------------------------------------------------------------------ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let s = app.engine.session(); let Some(track) = s.track(id).cloned() else { return }; let selected = s.edit.selected_tracks.contains(&id); @@ -628,7 +634,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) let main_h = track.height.points(); let lane = Rect::from_min_max(pos2(tl.min.x, row.min.y), pos2(tl.max.x, row.min.y + main_h)); // Header background. - ui.painter().rect_filled(head, 0.0, if selected { Color32::from_rgb(52, 58, 66) } else { t.panel_bg2 }); + ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 }); ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color)); ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border)); track_header(app, ui, &track, head, selected); @@ -670,7 +676,7 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) /// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping). fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let s = app.engine.session().clone(); let mut y = row.min.y + main_h; for (i, pl) in track.playlists.iter().enumerate() { @@ -679,7 +685,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai } let lane = Rect::from_min_max(pos2(tl.min.x, y), pos2(tl.max.x, y + LANE_H)); let head = Rect::from_min_max(pos2(row.min.x + 12.0, y), pos2(tl.min.x, y + LANE_H)); - ui.painter().rect_filled(lane, 0.0, Color32::from_rgb(30, 30, 31)); + ui.painter().rect_filled(lane, 0.0, t.playlist_lane); ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); for c in &pl.clips { @@ -708,7 +714,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai } fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) { - let t = Tokens::DARK; + let t = Tokens::current(); let id = track.id; // Drag the bottom edge to resize (snaps to the height presets). let edge = Rect::from_min_max(pos2(head.min.x, head.max.y - 4.0), pos2(head.max.x, head.max.y + 2.0)); @@ -778,7 +784,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele .unwrap_or(p.y); ui.ctx() .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("reorder"))) - .line_segment([pos2(head.min.x, line_y), pos2(head.max.x + 600.0, line_y)], Stroke::new(3.0, Tokens::DARK.accent)); + .line_segment([pos2(head.min.x, line_y), pos2(head.max.x + 600.0, line_y)], Stroke::new(3.0, Tokens::current().accent)); } if nresp.drag_stopped() && let Some(p) = ui.ctx().pointer_latest_pos() @@ -833,7 +839,15 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele TrackKind::Video => "VIDEO", }; if !kind_label.is_empty() { - ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim); + let kr = ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim); + if track.kind == TrackKind::Instrument { + // The instrument picker (and the instrument's editor) behind the INST label. + let name = track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("none", |p| p.name); + let resp = ui + .interact(kr.expand(3.0), ui.id().with(("instrument", id.0)), Sense::click()) + .on_hover_text(format!("Instrument: {name} (click to change or open its editor)")); + egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id)); + } } if h < 30.0 { return; @@ -919,7 +933,7 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele /// One optional Edit-window column for a track (I/O, inserts, sends, comments). fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); ui.painter().rect_filled(r, 0.0, t.panel_bg); ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border)); let line_h = 15.0; @@ -1062,7 +1076,7 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { } fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let view = Range::new(sample_at(s, tl, tl.min.x).max(0), sample_at(s, tl, tl.max.x)); let sr = s.sample_rate; // Bar lines always; grid lines when enabled and dense enough. @@ -1301,7 +1315,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let (lo, hi, _) = p.range(); let (lo, hi) = if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) { (-60.0f32, 12.0f32) @@ -1372,6 +1386,20 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { let id = ui.id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); + // The clip menu is attached before the early return below: once the menu is open the pointer + // is over the popup, the lane has no hover position, and the rest of this function bails out. + // The clip is remembered at the right-click so the menu keeps its target while it is used. + let menu_clip_id = id.with("menu_clip"); + if resp.secondary_clicked() { + let clip = resp.interact_pointer_pos().and_then(|p| { + let s = app.engine.session(); + clip_at(track, sample_at(s, tl, p.x).max(0)).map(|c| c.id) + }); + ui.ctx().data_mut(|d| d.insert_temp(menu_clip_id, clip)); + } + // The clip may have been removed or moved since the right-click; only use it if it still exists. + let menu_clip = ui.ctx().data(|d| d.get_temp::>(menu_clip_id)).flatten().filter(|c| track.clips().iter().any(|x| x.id == *c)); + resp.context_menu(|ui| clip_context_menu(app, ui, menu_clip)); // Audio files dropped from the Clip List. if let Some(src) = resp.dnd_release_payload::() && let Some(p) = ui.ctx().pointer_latest_pos() @@ -1381,7 +1409,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let _ = app.run("clip.place_source", json!({"source": src.0, "track": track.id.0, "at": at})); } if resp.dnd_hover_payload::().is_some() { - ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::DARK.accent), StrokeKind::Inside); + ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside); } let s = app.engine.session().clone(); let tool = s.edit.tool; @@ -1613,7 +1641,6 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, { let _ = app.run("edit.select", json!({"clips": [c.id.0]})); } - resp.context_menu(|ui| clip_context_menu(app, ui, hit.as_ref().map(|c| c.id))); } fn automation_id(p: &AutoParam) -> String { @@ -1679,9 +1706,46 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { } } +/// Next follow state after observing one frame: `(hold, follow_to)`. +/// +/// The view is ours to move only while nothing else moved it: when the +/// session scroll differs from both the last seen value and our own last +/// follow target, something else (a manual pan, scrollbar, universe jump, +/// or programmatic scroll) took over, so the follow pauses (`hold`) and no +/// jump is issued. Stopping, or scrolling back to the playhead, clears the +/// hold. Only `page`/`continuous`/`center` ever issue follow jumps while +/// playing; `none`/`after_playback` leave the view alone. +fn follow_update( + playing: bool, + mode_follow: bool, + playhead_visible: bool, + cur: Samples, + last_scroll: Samples, + last_follow_to: Option, + hold: bool, + pos: Samples, +) -> (bool, Option) { + if !playing || playhead_visible { + return (false, None); + } + if cur != last_scroll && Some(cur) != last_follow_to { + return (mode_follow, None); + } + if hold || !mode_follow { + return (hold, None); + } + (false, Some(pos)) +} + /// Selection overlay, insertion point and playhead across rulers and tracks. +/// While playing, auto-scroll keeps the playhead in view for the follow +/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual +/// pan, scrollbar, universe jump, or programmatic scroll) pauses it, so the +/// timeline can be inspected anywhere during playback. Scrolling back to the +/// playhead, stopping/starting, or re-selecting a scrolling mode resumes the +/// follow. `none` and `after_playback` never scroll during playback. fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let mut scroll_to: Option = None; let s = app.engine.session(); let painter = ui.painter().with_clip_rect(area); @@ -1721,10 +1785,18 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { if app.is_playing() { let x = x_of(s, tl, app.position()); painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead)); - // Page scrolling. - if s.edit.scrolling != "none" && (x > tl.max.x || x < tl.min.x) { - scroll_to = Some(app.position()); - } + // Session reads first (owned copies), then the layout mutation: the + // two borrow disjoint fields. + let pos = app.position(); + let cur = s.edit.zoom.scroll; + let mode_follow = matches!(s.edit.scrolling.as_str(), "page" | "continuous" | "center"); + let visible = x >= tl.min.x && x <= tl.max.x; + let layout = &mut app.edit_layout; + let (hold, follow_to) = follow_update(true, mode_follow, visible, cur, layout.last_scroll, layout.last_follow_to, layout.follow_hold, pos); + layout.follow_hold = hold; + layout.last_scroll = cur; + layout.last_follow_to = follow_to; + scroll_to = follow_to; } // Ghost of a clip move. if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture { @@ -1763,3 +1835,47 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } } + +#[cfg(test)] +mod tests { + use super::follow_update; + + #[test] + fn follow_jumps_to_offscreen_playhead_while_playing() { + // Steady view, follow mode, playhead left the screen: jump to it. + assert_eq!(follow_update(true, true, false, 1_000, 1_000, None, false, 96_000), (false, Some(96_000))); + } + + #[test] + fn follow_leaves_onscreen_playhead_alone() { + assert_eq!(follow_update(true, true, true, 1_000, 1_000, None, false, 96_000), (false, None)); + } + + #[test] + fn follow_never_scrolls_when_stopped_nor_in_free_modes() { + assert_eq!(follow_update(false, true, false, 1_000, 1_000, None, false, 96_000), (false, None)); + // `none`/`after_playback` never follow, even with a steady view. + assert_eq!(follow_update(true, false, false, 1_000, 1_000, None, false, 96_000), (false, None)); + assert_eq!(follow_update(true, false, false, 9_000, 1_000, None, false, 96_000), (false, None)); + } + + #[test] + fn outside_view_move_pauses_the_follow() { + // The view moved and it was not our own jump: pause, no jump. + assert_eq!(follow_update(true, true, false, 9_000, 1_000, None, false, 96_000), (true, None)); + // A held view stays held while it sits still elsewhere. + assert_eq!(follow_update(true, true, false, 9_000, 9_000, None, true, 96_000), (true, None)); + } + + #[test] + fn own_follow_jump_is_not_mistaken_for_a_takeover() { + // The scroll equals the target we issued last frame: keep following. + assert_eq!(follow_update(true, true, false, 96_000, 1_000, Some(96_000), false, 97_000), (false, Some(97_000))); + } + + #[test] + fn hold_clears_when_playhead_returns_or_transport_stops() { + assert_eq!(follow_update(true, true, true, 9_000, 9_000, None, true, 96_000), (false, None)); + assert_eq!(follow_update(false, true, false, 9_000, 9_000, None, true, 96_000), (false, None)); + } +} diff --git a/crates/ui-egui/src/extra_windows.rs b/crates/ui-egui/src/extra_windows.rs index 7737a96..71e63fa 100644 --- a/crates/ui-egui/src/extra_windows.rs +++ b/crates/ui-egui/src/extra_windows.rs @@ -87,7 +87,7 @@ fn renderer(app: &mut SoundApp, ctx: &egui::Context) { if !open { return; } - let t = Tokens::DARK; + let t = Tokens::current(); let fmt = app.engine.session().main_format(); let peaks: Vec = app.player.as_ref().map(|p| p.meters().main.peaks).unwrap_or_default(); let objects: Vec<(String, f32, f32, f32)> = app @@ -149,11 +149,9 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { if !open { return; } - egui::Window::new(egui::RichText::new("UI Customization").font(bold(13.0))) - .open(&mut open) - .default_size(vec2(320.0, 180.0)) - .resizable(false) - .show(ctx, |ui| { + egui::Window::new(egui::RichText::new("Appearance").font(bold(13.0))).open(&mut open).default_size(vec2(320.0, 180.0)).resizable(false).show( + ctx, + |ui| { egui::Grid::new("ui_custom").num_columns(2).spacing(vec2(12.0, 8.0)).show(ui, |ui| { ui.label("Interface scale"); ui.add(egui::Slider::new(&mut app.extra.ui_scale, 0.75..=1.5).step_by(0.05).custom_formatter(|v, _| format!("{:.0} %", v * 100.0))); @@ -161,6 +159,19 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { ui.label("Reduce motion"); ui.checkbox(&mut app.extra.reduce_motion, ""); ui.end_row(); + ui.label("Theme"); + let mut chosen = None; + egui::ComboBox::from_id_salt("theme_mode").selected_text(app.ui.theme.label()).show_ui(ui, |ui| { + for mode in crate::theme::ThemeMode::ALL { + if ui.selectable_label(app.ui.theme == mode, mode.label()).clicked() { + chosen = Some(mode); + } + } + }); + if let Some(mode) = chosen { + let _ = app.run("ui.theme", json!({"mode": mode.id()})); + } + ui.end_row(); ui.label("Auto-update configuration"); ui.checkbox(&mut app.extra.auto_update_config, ""); ui.end_row(); @@ -168,7 +179,9 @@ fn ui_customization(app: &mut SoundApp, ctx: &egui::Context) { if ui.button("Reset").clicked() { app.extra.ui_scale = 1.0; app.extra.reduce_motion = false; + let _ = app.run("ui.theme", json!({"mode": crate::theme::ThemeMode::default().id()})); } - }); + }, + ); app.extra.show_ui_customization = open; } diff --git a/crates/ui-egui/src/fonts.rs b/crates/ui-egui/src/fonts.rs index 6905934..bcaed2d 100644 --- a/crates/ui-egui/src/fonts.rs +++ b/crates/ui-egui/src/fonts.rs @@ -26,11 +26,41 @@ const BOLD: &[(&str, u32)] = &[ ("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0), ]; +/// Asks fontconfig for the desktop's UI font. Only TrueType/OpenType results are used: egui cannot +/// read Type 1 or bitmap fonts, which `fc-match` may return on minimal systems. +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn load_from_fontconfig(bold: bool) -> Option { + let pattern = if bold { "sans-serif:style=Bold" } else { "sans-serif" }; + let output = std::process::Command::new("fc-match").args(["--format=%{file}\\n%{index}\\n", pattern]).output().ok()?; + if !output.status.success() { + return None; + } + let stdout = String::from_utf8(output.stdout).ok()?; + let mut lines = stdout.lines(); + let path = lines.next()?.trim(); + // The upper 16 bits carry a variable font's named instance; the face index is the lower 16. + let index = lines.next()?.trim().parse::().ok()? & 0xFFFF; + let bytes = std::fs::read(path).ok()?; + let sfnt = matches!(bytes.get(..4), Some(b"\0\x01\0\0" | b"OTTO" | b"true" | b"ttcf")); + if !sfnt { + return None; + } + let mut font = FontData::from_owned(bytes); + font.index = index; + Some(font) +} + #[cfg(not(target_arch = "wasm32"))] -fn load(cands: &[(&str, u32)]) -> Option { +fn load(cands: &[(&str, u32)], bold: bool) -> Option { if std::env::var_os("SOUNDCRAFT_NO_SYSTEM_FONTS").is_some() { return None; } + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + if let Some(font) = load_from_fontconfig(bold) { + return Some(font); + } + #[cfg(not(any(target_os = "linux", target_os = "freebsd")))] + let _ = bold; for (p, idx) in cands { if let Ok(bytes) = std::fs::read(p) { let mut fd = FontData::from_owned(bytes); @@ -42,7 +72,7 @@ fn load(cands: &[(&str, u32)]) -> Option { } #[cfg(target_arch = "wasm32")] -fn load(_: &[(&str, u32)]) -> Option { +fn load(_: &[(&str, u32)], _: bool) -> Option { None } @@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions { let mut fonts = FontDefinitions::default(); let base: Vec = fonts.families.get(&FontFamily::Proportional).cloned().unwrap_or_default(); let mut bold_family = base.clone(); - if let Some(reg) = load(REGULAR) { + if let Some(reg) = load(REGULAR, false) { fonts.font_data.insert("system-regular".into(), Arc::new(reg)); if let Some(f) = fonts.families.get_mut(&FontFamily::Proportional) { f.insert(0, "system-regular".into()); } } - if let Some(b) = load(BOLD) { + if let Some(b) = load(BOLD, true) { fonts.font_data.insert("system-bold".into(), Arc::new(b)); bold_family.insert(0, "system-bold".into()); } else if fonts.font_data.contains_key("system-regular") { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 18982a0..13078f0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -94,6 +94,8 @@ pub struct UiState { pub video_burn_in: bool, pub workspace_dir: String, pub configurations: Vec<(String, Value)>, + /// Appearance (`ui.theme`): dark by default, light or follow the system on request. + pub theme: theme::ThemeMode, } impl Default for UiState { @@ -142,6 +144,7 @@ impl Default for UiState { video_burn_in: false, workspace_dir: String::new(), configurations: Vec::new(), + theme: theme::ThemeMode::Dark, } } } @@ -197,6 +200,8 @@ pub struct SoundApp { recorder_failed: bool, pub ui: UiState, pub services: Services, + /// Set only by a desktop host after installing an OS menu bar. + pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, /// Folder for user plugin presets (native apps set it). @@ -218,6 +223,8 @@ pub struct SoundApp { last_rev: u64, fonts_ready: bool, fonts_installed: bool, + /// The palette last applied to the context (light?), so visuals are set only on a change. + theme_applied: Option, /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, pub quit_requested: bool, @@ -232,6 +239,16 @@ pub struct SoundApp { pub video: video_track::VideoPool, } +/// Whether another automation-write pass is due: the transport moved at least +/// `step` since `last` in either direction. `last == i64::MIN` marks "never +/// written", so the first pass after (re)start is always due. Saturating +/// arithmetic keeps restarts and loop wraps overflow-free: the previous +/// `(pos - last).abs()` panicked in debug builds on the first playing frame, +/// when `last` is still `i64::MIN`. +fn automation_step_due(pos: Samples, last: Samples, step: Samples) -> bool { + last == i64::MIN || pos.saturating_sub(last) >= step || last.saturating_sub(pos) >= step +} + impl SoundApp { pub fn new(engine: Engine, player: Option, services: Services) -> Self { SoundApp { @@ -243,6 +260,7 @@ impl SoundApp { recorder_failed: false, ui: UiState::default(), services, + native_menu_bar: false, autosave_dir: None, preset_dir: None, last_autosave: 0.0, @@ -262,6 +280,7 @@ impl SoundApp { last_rev: 0, fonts_ready: false, fonts_installed: false, + theme_applied: None, sim: None, quit_requested: false, arrange_request: false, @@ -292,6 +311,10 @@ impl SoundApp { if id == "app.quit" { self.quit_requested = true; } + if id == "options.scrolling" { + // Re-selecting a scrolling mode resumes the playhead follow. + self.edit_layout.follow_hold = false; + } Ok(v) } Err(e) => { @@ -320,6 +343,12 @@ impl SoundApp { } self.engine.transport.playing = true; self.engine.transport.position = from; + // A fresh start resumes the playhead follow: clear a manual hold and + // re-sync the follow tracker so the current view is not mistaken for + // an outside move on the first frame. + self.edit_layout.follow_hold = false; + self.edit_layout.last_scroll = self.engine.session().edit.zoom.scroll; + self.edit_layout.last_follow_to = None; } fn stop_play(&mut self) { @@ -332,11 +361,33 @@ impl SoundApp { self.sim = None; self.engine.transport.playing = false; self.engine.transport.recording = false; - let s = self.engine.session(); - if s.edit.insertion_follows_playback { + let stop_at = self.engine.transport.position; + // Snapshot the session flags first: `session_mut` below needs a + // mutable borrow, so no session borrow may span it. + let (follow_insertion, after_playback) = { + let s = self.engine.session(); + (s.edit.insertion_follows_playback, s.edit.scrolling == "after_playback") + }; + if follow_insertion { let pos = self.engine.transport.position; self.engine.session_mut().edit.selection = Range::point(pos); } + if after_playback { + // After Playback never scrolls while playing, so bring the stopped + // position into view now (only if it is off-screen); every other + // mode keeps its view. + let at = stop_at.max(0); + let (scroll, spp) = { + let z = &self.engine.session().edit.zoom; + (z.scroll, z.samples_per_px.max(0.01)) + }; + let width = f64::from(self.edit_layout.timeline[2] - self.edit_layout.timeline[0]).max(0.0); + let end = scroll.saturating_add((width * spp) as Samples); + if at < scroll || at >= end { + let _ = self.engine.execute("view.scroll", &json!({ "to": at })); + } + } + self.edit_layout.follow_hold = false; } fn start_recording(&mut self) { @@ -531,7 +582,7 @@ impl SoundApp { } let pos = self.position(); let step = self.engine.session().sample_rate.samples(0.02); - if (pos - self.last_write_at).abs() < step { + if !automation_step_due(pos, self.last_write_at, step) { return; } self.last_write_at = pos; @@ -681,11 +732,15 @@ impl SoundApp { self.fonts_ready = true; } else { fonts::install(ctx); - theme::apply(ctx); self.fonts_installed = true; ctx.request_repaint(); } } + let light = theme::wants_light(ctx, self.ui.theme); + if self.theme_applied != Some(light) || theme::is_light() != light { + theme::apply(ctx, light); + self.theme_applied = Some(light); + } let now = ctx.input(|i| i.time); let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25); self.last_frame = Some(now); @@ -767,7 +822,9 @@ impl SoundApp { ctx.memory_mut(|m| m.reset_areas()); } shortcuts::handle(self, &ctx); - menus::menu_bar(self, ui); + if !self.native_menu_bar { + menus::menu_bar(self, ui); + } match self.ui.window { MainWindow::Edit => edit_window::show(self, ui), MainWindow::Mix => mix_window::show(self, ui), @@ -824,3 +881,27 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { } d.gr = if gr > d.gr { gr } else { d.gr * fall }; } + +#[cfg(test)] +mod tests { + use super::automation_step_due; + + #[test] + fn automation_first_pass_after_start_is_due_without_overflow() { + // `last_write_at` starts (and resets) at `i64::MIN`: the previous + // `(pos - last).abs()` panicked here in debug builds. + assert!(automation_step_due(0, i64::MIN, 960)); + assert!(automation_step_due(48_000, i64::MIN, 960)); + } + + #[test] + fn automation_write_throttles_to_step() { + assert!(!automation_step_due(1_000, 900, 960)); + assert!(automation_step_due(2_000, 900, 960)); + } + + #[test] + fn automation_write_fires_after_loop_wrap() { + assert!(automation_step_due(0, 1_000_000, 960)); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 949d29f..ad1a322 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -67,6 +67,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("ui.new_tracks_dialog", "New Tracks…", "", None), ("ui.bounce_dialog", "Bounce Mix…", "", None), ("ui.set", "Set UI State", "", None), + ("ui.theme", "Appearance", "", None), ]; /// Extra catalog mappings handled by the UI layer. @@ -147,43 +148,50 @@ const DIALOG_COMMANDS: &[&str] = &[ "edit.strip_silence", ]; -struct MenuNode { - label: String, - path: String, - children: Vec, +/// Shared menu hierarchy for egui and native desktop hosts. +pub struct MenuNode { + pub label: String, + pub path: String, + pub children: Vec, } -fn tree() -> Vec { +fn insert_path(roots: &mut Vec, line: &str) { + let parts: Vec<&str> = line.split(" > ").collect(); + let mut level = roots; + let mut path = String::new(); + for (i, p) in parts.iter().enumerate() { + if i > 0 { + path.push_str(" > "); + } + path.push_str(p); + let idx = match level.iter().position(|n| n.label == *p) { + Some(i) => i, + None => { + level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() }); + level.len() - 1 + } + }; + let Some(node) = level.get_mut(idx) else { break }; + level = &mut node.children; + } +} + +pub fn tree() -> Vec { let mut roots: Vec = Vec::new(); for line in catalog::catalog() { - let parts: Vec<&str> = line.split(" > ").collect(); - let mut level = &mut roots; - let mut path = String::new(); - for (i, p) in parts.iter().enumerate() { - if i > 0 { - path.push_str(" > "); - } - path.push_str(p); - let idx = match level.iter().position(|n| n.label == *p) { - Some(i) => i, - None => { - level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() }); - level.len() - 1 - } - }; - let Some(node) = level.get_mut(idx) else { break }; - level = &mut node.children; - } + insert_path(&mut roots, line); + } + for (path, _) in MENU_WINDOWS { + insert_path(&mut roots, path); } roots } pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { - let t = crate::theme::Tokens::DARK; - egui::Panel::top("menu_bar") - .exact_size(24.0) - .frame(egui::Frame::NONE.fill(egui::Color32::from_rgb(22, 22, 23)).inner_margin(egui::Margin::symmetric(8, 2))) - .show(ui, |ui| { + let t = crate::theme::Tokens::current(); + egui::Panel::top("menu_bar").exact_size(24.0).frame(egui::Frame::NONE.fill(t.menu_bar).inner_margin(egui::Margin::symmetric(8, 2))).show( + ui, + |ui| { egui::MenuBar::new().ui(ui, |ui| { ui.menu_button("SoundCraft", |ui| { if ui.button("About SoundCraft").clicked() { @@ -219,7 +227,8 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(title).color(t.text_dim)); }); }); - }); + }, + ); } fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) { @@ -231,13 +240,8 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str }); return; } - let id = catalog::implemented_by(&n.path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == n.path).map(|(_, w)| w.to_string())); - let enabled = match id.as_deref() { - Some(i) if i.starts_with("window.") || i.starts_with("view.mix_section") || i == "audiosuite.process" => true, - Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), - None => false, - }; - let checked = checked_state(app, &n.path, id.as_deref()); + let id = command_for_path(&n.path, extra); + let (enabled, checked) = item_state(app, &n.path, id.as_deref()); let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() }; let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or(""); let btn = egui::Button::new(label).shortcut_text(shortcut); @@ -249,6 +253,21 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str } } +/// Resolve the command behind a menu leaf, including UI dialog overrides. +pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option { + catalog::implemented_by(path, extra).or_else(|| MENU_WINDOWS.iter().find(|(p, _)| *p == path).map(|(_, w)| w.to_string())) +} + +/// 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) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), + None => false, + }; + (enabled, checked_state(app, path, id)) +} + fn checked_state(app: &SoundApp, path: &str, id: Option<&str>) -> bool { let e = &app.engine.session().edit; match id { @@ -338,6 +357,7 @@ fn params_for(path: &str, id: &str) -> Value { /// Menu items that open a SoundCraft window when clicked (programmatic calls still run the command). const MENU_WINDOWS: &[(&str, &str)] = &[ + ("Setup > Appearance", "window.ui_customization"), ("Setup > Hardware...", "window.playback_engine"), ("Setup > Playback Engine...", "window.playback_engine"), ("Setup > I/O...", "window.io_setup"), @@ -502,6 +522,19 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option { + let Some(mode) = p.get("mode").and_then(Value::as_str) else { + return Some(Ok(json!({"mode": app.ui.theme.id()}))); + }; + let Some(mode) = crate::theme::ThemeMode::parse(mode) else { + return Some(Err(format!("unknown theme `{mode}` (system, light or dark)"))); + }; + if mode == crate::theme::ThemeMode::System { + crate::theme::refresh_system(); + } + app.ui.theme = mode; + json!({"mode": mode.id()}) + } "ui.set" => match serde_json::to_value(&app.ui) { Ok(mut cur) => { if let (Some(obj), Some(patch)) = (cur.as_object_mut(), p.as_object()) { diff --git a/crates/ui-egui/src/midi_editor.rs b/crates/ui-egui/src/midi_editor.rs index 92a607a..3716c98 100644 --- a/crates/ui-egui/src/midi_editor.rs +++ b/crates/ui-egui/src/midi_editor.rs @@ -56,7 +56,7 @@ fn is_black(p: i32) -> bool { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.panel_bg); let header = Rect::from_min_size(full.min, vec2(full.width(), 22.0)); diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index f7a9aac..20591fe 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -15,7 +15,7 @@ pub fn strip_width(narrow: bool) -> f32 { } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); if app.ui.show_tracks_list { egui::Panel::left("mix_tracks_list") .exact_size(168.0) @@ -84,7 +84,7 @@ fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec String { @@ -97,14 +97,14 @@ fn route_name(app: &SoundApp, r: &Route) -> String { } fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) { - let t = Tokens::DARK; + let t = Tokens::current(); let Some(track) = app.engine.session().track(id).cloned() else { return }; let narrow = app.ui.narrow_mix; let w = strip_width(narrow); let h = ui.available_height().max(560.0); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); let selected = app.engine.session().edit.selected_tracks.contains(&id); - ui.painter().rect_filled(r, 0.0, if selected { Color32::from_rgb(54, 58, 64) } else { t.strip_bg }); + ui.painter().rect_filled(r, 0.0, if selected { t.strip_selected } else { t.strip_bg }); let mut y = r.min.y + 4.0; let inner_w = w - 8.0; let x0 = r.min.x + 4.0; @@ -140,8 +140,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 16.0), vec2(inner_w - 4.0, 15.0)); let slot = track.mixer.inserts.iter().position(|i| i.as_ref().is_some_and(|i| plugin_info(&i.plugin).is_some_and(|p| p.is_instrument))); match slot { + _ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, &track, sr), Some(k) => insert_slot(app, ui, &track, k, sr), - None if matches!(track.kind, TrackKind::Instrument | TrackKind::Midi) => { + None if track.kind == TrackKind::Midi => { ui.painter().text(sr.center(), Align2::CENTER_CENTER, "MIDI in: all", regular(9.0), t.text_dim); } None => {} @@ -401,21 +402,47 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh ui.painter().line_segment([pos2(r.max.x, r.min.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); } +/// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window, +/// right-click (or click, when none is set) picks another instrument. +fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { + let t = Tokens::current(); + let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT); + 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 }; + 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)), + 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); + let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin)); + if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) { + app.ui.plugin_windows.push((id, slot)); + } + let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() }; + let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) }; + popup.show(|ui| instrument_menu(app, ui, id)); +} + fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let id = track.id; let ins = track.mixer.inserts.get(slot).cloned().flatten(); let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click()); let fill = match &ins { - Some(i) if i.bypass => Color32::from_rgb(70, 56, 30), - Some(_) => Color32::from_rgb(52, 62, 80), + Some(i) if i.bypass => t.insert_bypass, + Some(_) => t.insert_on, None => 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, ); let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name); @@ -457,44 +484,114 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc } }); } - // Third-party CLAP plugins (scanned once, cached by soundcraft-clap-host). + if let Some(plugin) = hosted_menus(ui, false) + && let Err(e) = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) + { + app.ui.status = e; + } +} + +/// The instrument picker of an instrument track: its instrument's editor, then the built-in, +/// CLAP, VST3 and Audio Units instruments. +pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { + let slot = soundcraft_mix::INSTRUMENT_SLOT; + let current = app.engine.session().track(id).and_then(|t| t.instrument.clone()); + if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) { + ui.label(egui::RichText::new(info.name).strong()); + if let Some(p) = &app.player + && p.has_editor(id, slot) + { + if p.editor_open(id, slot) { + if ui.button("Close Plugin Editor").clicked() { + p.close_editor(id, slot); + } + } else if ui.button("Open Plugin Editor").clicked() + && let Err(e) = p.open_editor(id, slot) + { + app.ui.status = format!("{}: {e}", info.name); + } + } + ui.separator(); + } + let mut picked = None; + ui.menu_button("Built-in", |ui| { + for p in soundcraft_dsp::plugins().iter().filter(|p| p.is_instrument) { + if ui.button(p.name).clicked() { + picked = Some(p.id.to_string()); + } + } + }); + if let Some(p) = hosted_menus(ui, true) { + picked = Some(p); + } + if let Some(plugin) = picked + && let Err(e) = app.run("mix.instrument", json!({"track": id.0, "plugin": plugin})) + { + app.ui.status = e; + } +} + +/// A hosted plugin as the plugin menus list it. +struct MenuPlugin { + id: String, + name: String, + vendor: String, +} + +/// The CLAP, VST3 and (on macOS) Audio Units submenus, of effects or of instruments; returns the +/// id picked. Each format is scanned the first time its submenu opens, then cached by its host. +fn hosted_menus(ui: &mut Ui, instruments: bool) -> Option { + let mut picked = None; ui.menu_button("CLAP", |ui| { - let found: Vec<_> = soundcraft_clap_host::scan().into_iter().filter(|d| !d.is_instrument).collect(); - if found.is_empty() { - ui.label("No CLAP plugins found"); - } - for d in found { - if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() { - let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id})); - } + let list = soundcraft_clap_host::scan().into_iter().filter(|d| d.is_instrument == instruments); + if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No CLAP plugins found") { + picked = Some(p); } }); - // Third-party VST3 plugins (scanned once, cached by soundcraft-vst3-host). ui.menu_button("VST3", |ui| { - let found: Vec<_> = soundcraft_vst3_host::scan().into_iter().filter(|d| !d.is_instrument).collect(); - if found.is_empty() { - ui.label("No VST3 plugins found"); - } - for d in found { - if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() { - let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id})); - } + let list = soundcraft_vst3_host::scan().into_iter().filter(|d| d.is_instrument == instruments); + if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No VST3 plugins found") { + picked = Some(p); } }); - // Audio Units (macOS; scanned once, cached by soundcraft-au-host). if cfg!(target_os = "macos") { ui.menu_button("Audio Units", |ui| { - let found: Vec<_> = soundcraft_au_host::scan().into_iter().filter(|d| !d.is_instrument).collect(); - if found.is_empty() { - ui.label("No Audio Units found"); - } - for d in found { - if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() { - let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id})); - } + let list = soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments); + if let Some(p) = vendor_menus(ui, list.map(|d| MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }).collect(), "No Audio Units found") + { + picked = Some(p); } }); } + picked +} + +/// One submenu per vendor (as Pro Tools lists plug-ins by manufacturer), each list scrolling +/// within the screen so that every plugin stays reachable however many are installed. +fn vendor_menus(ui: &mut Ui, mut list: Vec, none: &str) -> Option { + if list.is_empty() { + ui.label(none); + return None; + } + list.sort_by_cached_key(|p| (p.vendor.to_lowercase(), p.name.to_lowercase())); + let mut vendors: Vec<&str> = list.iter().map(|p| p.vendor.as_str()).collect(); + vendors.dedup(); + let max_h = (ui.ctx().content_rect().height() - 80.0).max(160.0); + let mut picked = None; + egui::ScrollArea::vertical().id_salt("vendors").max_height(max_h).show(ui, |ui| { + for v in vendors { + ui.menu_button(if v.is_empty() { "Unknown vendor" } else { v }, |ui| { + egui::ScrollArea::vertical().id_salt("plugins").max_height(max_h).show(ui, |ui| { + for p in list.iter().filter(|p| p.vendor == v) { + if ui.button(&p.name).clicked() { + picked = Some(p.id.clone()); + } + } + }); + }); + } + }); + picked } /// A built-in plugin's description, else a hosted CLAP (`clap:`), VST3 (`vst3:`) or @@ -507,12 +604,12 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> { } fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { - let t = Tokens::DARK; + let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag()); - let fill = if snd.is_some() { Color32::from_rgb(46, 66, 56) } else { t.slot_bg }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, Color32::from_rgb(16, 16, 16)), StrokeKind::Inside); + let fill = if snd.is_some() { t.send_on } else { t.slot_bg }; + ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside); match &snd { Some(s) => { let name = route_name(app, &s.target); diff --git a/crates/ui-egui/src/ops_windows.rs b/crates/ui-egui/src/ops_windows.rs index 9917a0d..d298ab3 100644 --- a/crates/ui-egui/src/ops_windows.rs +++ b/crates/ui-egui/src/ops_windows.rs @@ -66,10 +66,10 @@ fn result_line(ui: &mut egui::Ui, r: &Result) { match r { Ok(v) => { let s = v.to_string(); - ui.label(egui::RichText::new(if s.len() > 160 { format!("{}…", &s[..160]) } else { s }).small().color(Tokens::DARK.counter_text)); + ui.label(egui::RichText::new(if s.len() > 160 { format!("{}…", &s[..160]) } else { s }).small().color(Tokens::current().counter_text)); } Err(e) => { - ui.label(egui::RichText::new(e).small().color(Tokens::DARK.rec)); + ui.label(egui::RichText::new(e).small().color(Tokens::current().rec)); } } } diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs index d1cff69..6291aea 100644 --- a/crates/ui-egui/src/palette.rs +++ b/crates/ui-egui/src/palette.rs @@ -62,7 +62,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if !app.ui.show_search { return; } - let t = Tokens::DARK; + let t = Tokens::current(); let mut run: Option = None; let mut close = ctx.input(|i| i.key_pressed(Key::Escape)); egui::Window::new("Search") diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 1bdb184..33c4324 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -7,7 +7,7 @@ use serde_json::json; use soundcraft_time::format_position; fn panel_header(ui: &mut Ui, title: &str) { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::hover()); ui.painter().rect_filled(r, 0.0, t.panel_bg2); ui.painter().text(pos2(r.min.x + 8.0, r.center().y), Align2::LEFT_CENTER, title, bold(11.5), t.header_text); @@ -15,7 +15,7 @@ fn panel_header(ui: &mut Ui, title: &str) { } pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let total = ui.available_height(); panel_header(ui, "TRACKS"); let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = { @@ -26,16 +26,11 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { for (id, name, shown, color, sel) in tracks { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); if sel { - ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96)); + ui.painter().rect_filled(r, 0.0, t.row_selected); } let dot = Rect::from_center_size(pos2(r.min.x + 10.0, r.center().y), vec2(8.0, 8.0)); let dresp = ui.interact(dot.expand(3.0), ui.id().with(("vis", id)), Sense::click()); - ui.painter().circle( - dot.center(), - 3.5, - if shown { Color32::from_rgb(200, 200, 200) } else { Color32::TRANSPARENT }, - Stroke::new(1.0, t.text_dim), - ); + ui.painter().circle(dot.center(), 3.5, if shown { t.visible_dot } else { Color32::TRANSPARENT }, Stroke::new(1.0, t.text_dim)); if dresp.clicked() { let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown})); } @@ -103,7 +98,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { } pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); panel_header(ui, "CLIPS"); let items: Vec<(Option, String, bool, [u8; 3])> = { let s = app.engine.session(); @@ -150,7 +145,7 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { } } if !whole && id.is_some_and(|i| selected.contains(&i)) { - ui.painter().rect_filled(r, 0.0, Color32::from_rgb(52, 70, 96)); + ui.painter().rect_filled(r, 0.0, t.row_selected); } ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color)); ui.painter().with_clip_rect(r).text( @@ -186,7 +181,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { if !open { return; } - let t = Tokens::DARK; + let t = Tokens::current(); egui::Window::new("Transport").open(&mut open).resizable(false).default_pos(pos2(400.0, 500.0)).show(ctx, |ui| { let flags = app.engine.session().edit.flags.clone(); let expanded = flags.contains("view.transport.expanded"); @@ -275,8 +270,9 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { let s = app.engine.session(); let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); let r = ui.available_rect_before_wrap(); - ui.painter().rect_filled(r, 4.0, Color32::BLACK); - ui.painter().text(r.center(), Align2::CENTER_CENTER, txt, mono((r.height() * 0.6).clamp(20.0, 120.0)), Tokens::DARK.counter_text); + let t = Tokens::current(); + ui.painter().rect_filled(r, 4.0, t.big_counter_bg); + ui.painter().text(r.center(), Align2::CENTER_CENTER, txt, mono((r.height() * 0.6).clamp(20.0, 120.0)), t.counter_text); }); app.ui.show_big_counter = open; } @@ -362,7 +358,7 @@ fn undo_history(app: &mut SoundApp, ctx: &egui::Context) { } } if let Some(r) = app.engine.redo_label() { - ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::DARK.text_dim)); + ui.label(egui::RichText::new(format!("(redo) {r}")).italics().color(Tokens::current().text_dim)); } }); }); @@ -373,26 +369,33 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { let wins = app.ui.plugin_windows.clone(); let mut keep = Vec::new(); for (tid, slot) in wins { - let Some((tname, ins)) = - app.engine.session().track(tid).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten().map(|i| (t.name.clone(), i))) - else { + // The instrument slot (an instrument track's instrument) or an insert slot. + let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT; + let Some((tname, ins)) = app.engine.session().track(tid).and_then(|t| { + let ins = if instrument { t.instrument.clone() } else { t.mixer.inserts.get(slot).cloned().flatten() }; + ins.map(|i| (t.name.clone(), i)) + }) else { continue; }; let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue }; + let place = if instrument { "instrument".to_string() } else { char::from(b'a'.saturating_add(slot as u8)).to_string() }; let mut open = true; - egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name)) + egui::Window::new(format!("{tname} · {place} · {}", info.name)) .id(egui::Id::new(("plugin", tid.0, slot))) .open(&mut open) .default_width(340.0) .default_pos(egui::pos2(ctx.content_rect().width() - 380.0, 120.0)) .show(ctx, |ui| { ui.horizontal(|ui| { - ui.label(egui::RichText::new(&ins.preset).color(Tokens::DARK.text_dim)); - let label = if ins.bypass { "BYPASSED" } else { "Bypass" }; - if ui.button(label).clicked() { - let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot})); + // Bypass and presets act on insert slots. + if !instrument { + ui.label(egui::RichText::new(&ins.preset).color(Tokens::current().text_dim)); + let label = if ins.bypass { "BYPASSED" } else { "Bypass" }; + if ui.button(label).clicked() { + let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot})); + } + ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins)); } - ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins)); if soundcraft_mix::is_third_party(&ins.plugin) { let open = app.player.as_ref().is_some_and(|p| p.editor_open(tid, slot)); let available = app.player.as_ref().is_some_and(|p| p.has_editor(tid, slot)); @@ -411,6 +414,12 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } } }); + if instrument { + // The parameter commands address insert slots: an instrument is played and + // edited in its own editor. + ui.label(egui::RichText::new("Load and edit sounds in the plugin's own editor.").color(Tokens::current().text_dim)); + return; + } if info.id == "eq_7band" || info.id == "eq_1band" { eq_curve(ui, info.id, &ins); } @@ -462,7 +471,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { } fn eq_curve(ui: &mut Ui, id: &str, ins: &soundcraft_model::Insert) { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width().max(300.0), 110.0), Sense::hover()); ui.painter().rect_filled(r, 4.0, Color32::from_rgb(14, 18, 22)); let n = 160; diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index cc35c90..15141c8 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -41,8 +41,17 @@ pub fn parse(s: &str) -> Option<(Modifiers, Key)> { key.map(|k| (m, k)) } -fn mods_match(want: Modifiers, got: Modifiers) -> bool { - want.command == got.command && want.shift == got.shift && want.alt == got.alt && (want.ctrl == got.ctrl || got.mac_cmd) +/// Whether the held modifiers `got` fire a shortcut that asks for `want`. +fn mods_match(want: Modifiers, got: Modifiers, mac: bool) -> bool { + // Off macOS egui reports Ctrl as both `ctrl` and `command`: Ctrl is the shortcut's Cmd, and a shortcut's + // Ctrl (the Mac Control key) has no key of its own there, so Ctrl+X is Cut, not Cmd+Ctrl+X. + let ctrl = if mac { want.ctrl == got.ctrl } else { !want.ctrl }; + want.command == got.command && want.shift == got.shift && want.alt == got.alt && ctrl +} + +/// Cmd+Ctrl+S opens Search. Off macOS Ctrl+S is Save (Cmd+S), so Search is reached from the Window menu. +fn search_chord(m: Modifiers, mac: bool) -> bool { + mac && m.command && m.ctrl } pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { @@ -58,12 +67,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { }) .collect() }); + // Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees). + let mac = ctx.os() == egui::os::OperatingSystem::Mac; for (key, mods) in events { - if fixed(app, key, mods) { + if fixed(app, key, mods, mac) { continue; } let hit = - soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods))); + soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac))); if let Some(c) = hit { let id = c.id; if crate::menus::invoke_shortcut_dialog(app, id) { @@ -75,7 +86,7 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { } /// Keys with context-dependent meaning. Returns true when handled. -fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { +fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let plain = !m.command && !m.alt && !m.ctrl && !m.shift; match key { Key::Space if !m.command && !m.alt => { @@ -87,7 +98,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers) -> bool { let _ = app.run("transport.record", json!({})); true } - Key::S if m.command && m.ctrl => { + Key::S if search_chord(m, mac) => { let _ = app.run("window.search", json!({})); true } @@ -227,3 +238,38 @@ fn tab_clip(app: &mut SoundApp, back: bool) { let _ = app.run("transport.locate", json!({"at": n})); } } + +#[cfg(test)] +mod tests { + use super::{mods_match, parse, search_chord}; + use egui::Modifiers; + + /// Ctrl held on Windows or Linux, as egui reports it. + const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + /// Cmd held on macOS. + const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + + fn want(s: &str) -> Modifiers { + parse(s).map(|(m, _)| m).unwrap() + } + + #[test] + fn ctrl_is_cmd_off_macos() { + assert!(mods_match(want("Cmd+S"), PC_CTRL, false)); + assert!(mods_match(want("Cmd+Shift+N"), Modifiers { shift: true, ..PC_CTRL }, false)); + assert!(mods_match(want("Cmd+X"), PC_CTRL, false)); + assert!(!mods_match(want("Cmd+Ctrl+X"), PC_CTRL, false)); + assert!(!mods_match(want("Cmd+S"), Modifiers::NONE, false)); + assert!(!search_chord(PC_CTRL, false)); + } + + #[test] + fn cmd_and_ctrl_stay_apart_on_macos() { + assert!(mods_match(want("Cmd+S"), MAC_CMD, true)); + assert!(!mods_match(want("Cmd+S"), Modifiers::CTRL, true)); + assert!(mods_match(want("Cmd+Ctrl+X"), Modifiers { ctrl: true, ..MAC_CMD }, true)); + assert!(!mods_match(want("Cmd+X"), Modifiers { ctrl: true, ..MAC_CMD }, true), "Cmd+Ctrl+X is not Cut"); + assert!(!search_chord(MAC_CMD, true)); + assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true)); + } +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 8cbd5f9..113e152 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -1,6 +1,52 @@ //! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours. use egui::{Color32, FontFamily, FontId}; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; + +/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only). +static LIGHT_THEME: AtomicBool = AtomicBool::new(false); + +/// The operating system's appearance, asked once off the UI thread. +static SYSTEM: AtomicU8 = AtomicU8::new(SYSTEM_UNKNOWN); +const SYSTEM_UNKNOWN: u8 = 0; +const SYSTEM_ASKING: u8 = 1; +const SYSTEM_DARK: u8 = 2; +const SYSTEM_LIGHT: u8 = 3; + +/// 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")] +pub enum ThemeMode { + System, + Light, + #[default] + Dark, +} + +impl ThemeMode { + pub const ALL: [Self; 3] = [Self::Dark, Self::Light, Self::System]; + + pub const fn label(self) -> &'static str { + match self { + Self::System => "System", + Self::Light => "Light", + Self::Dark => "Dark", + } + } + + /// The id used in `ui.theme {"mode": …}` and in the saved UI prefs. + pub const fn id(self) -> &'static str { + match self { + Self::System => "system", + Self::Light => "light", + Self::Dark => "dark", + } + } + + pub fn parse(s: &str) -> Option { + Self::ALL.into_iter().find(|m| m.id().eq_ignore_ascii_case(s.trim())) + } +} #[derive(Clone, Copy, Debug)] pub struct Tokens { @@ -58,6 +104,58 @@ pub struct Tokens { pub strip_section: Color32, pub slot_bg: Color32, pub automation_line: Color32, + /// Menu bar background. + pub menu_bar: Color32, + /// Outline of toolbar groups. + pub group_border: Color32, + /// Edit-mode cell, not selected. + pub mode_off: Color32, + /// Counter-panel toggle (Grid, Count Off), off. + pub counter_off: Color32, + /// Outline of the counter panels. + pub counter_border: Color32, + /// Divider inside the counter panels. + pub counter_rule: Color32, + /// Smart-tool bracket, off. + pub tool_bracket_off: Color32, + /// Selected row in the Tracks, Groups and Clips lists. + pub row_selected: Color32, + /// Track-visible dot in the Tracks list. + pub visible_dot: Color32, + /// Big Counter background. + pub big_counter_bg: Color32, + /// Edit-window track header, selected. + pub header_selected: Color32, + /// Mix-window strip, selected. + pub strip_selected: Color32, + /// Marker names on the marker ruler. + pub marker_text: Color32, + /// Alternate-playlist lanes. + pub playlist_lane: Color32, + /// Universe overview background. + pub universe_bg: Color32, + /// Universe visible-area outline. + pub universe_view: Color32, + /// Meter background. + pub meter_bg: Color32, + /// Meter clip indicator, not clipped. + pub meter_clip_off: Color32, + /// Pan knob body. + pub knob_bg: Color32, + /// Pan knob outline. + pub knob_ring: Color32, + /// Pan knob pointer. + pub knob_pointer: Color32, + /// Mix insert slot holding a plugin. + pub insert_on: Color32, + /// Mix insert slot, bypassed. + pub insert_bypass: Color32, + /// Mix send slot in use. + pub send_on: Color32, + /// Mix insert/send slot outline. + pub slot_border: Color32, + /// Mix-window section captions (INSERTS A-E, I/O…). + pub section_label: Color32, } impl Tokens { @@ -116,19 +214,185 @@ impl Tokens { strip_section: Color32::from_rgb(36, 36, 38), slot_bg: Color32::from_rgb(28, 28, 29), automation_line: Color32::from_rgb(240, 240, 240), + menu_bar: Color32::from_rgb(22, 22, 23), + group_border: Color32::from_rgb(8, 8, 8), + mode_off: Color32::from_rgb(20, 36, 22), + counter_off: Color32::from_rgb(30, 50, 32), + counter_border: Color32::from_rgb(50, 50, 50), + counter_rule: Color32::from_rgb(40, 40, 40), + tool_bracket_off: Color32::from_rgb(64, 64, 66), + row_selected: Color32::from_rgb(52, 70, 96), + visible_dot: Color32::from_rgb(200, 200, 200), + big_counter_bg: Color32::from_rgb(0, 0, 0), + header_selected: Color32::from_rgb(52, 58, 66), + strip_selected: Color32::from_rgb(54, 58, 64), + marker_text: Color32::from_rgb(236, 236, 236), + playlist_lane: Color32::from_rgb(30, 30, 31), + universe_bg: Color32::from_rgb(18, 18, 19), + universe_view: Color32::from_rgb(255, 255, 255), + meter_bg: Color32::from_rgb(8, 8, 8), + meter_clip_off: Color32::from_rgb(60, 20, 20), + knob_bg: Color32::from_rgb(26, 26, 28), + knob_ring: Color32::from_rgb(90, 90, 94), + knob_pointer: Color32::from_rgb(230, 230, 230), + insert_on: Color32::from_rgb(52, 62, 80), + insert_bypass: Color32::from_rgb(70, 56, 30), + send_on: Color32::from_rgb(46, 66, 56), + slot_border: Color32::from_rgb(16, 16, 16), + section_label: Color32::from_rgb(200, 200, 200), }; - pub fn get(_ctx: &egui::Context) -> Tokens { - Tokens::DARK + pub const LIGHT: Tokens = Tokens { + window_bg: Color32::from_rgb(238, 239, 242), + panel_bg: Color32::from_rgb(248, 249, 251), + panel_bg2: Color32::from_rgb(255, 255, 255), + toolbar_bg: Color32::from_rgb(230, 232, 236), + toolbar_group: Color32::from_rgb(242, 243, 246), + border: Color32::from_rgb(190, 193, 200), + border_light: Color32::from_rgb(210, 213, 220), + text: Color32::from_rgb(35, 36, 40), + text_dim: Color32::from_rgb(95, 98, 106), + text_dark: Color32::from_rgb(20, 21, 24), + header_text: Color32::from_rgb(45, 47, 53), + counter_bg: Color32::from_rgb(224, 226, 231), + counter_text: Color32::from_rgb(22, 116, 45), + counter_label: Color32::from_rgb(65, 68, 75), + accent: Color32::from_rgb(62, 116, 180), + accent_dark: Color32::from_rgb(43, 88, 145), + mode_on: Color32::from_rgb(75, 158, 88), + mode_on_text: Color32::from_rgb(255, 255, 255), + mode_off_text: Color32::from_rgb(48, 122, 62), + button: Color32::from_rgb(224, 226, 230), + button_hi: Color32::from_rgb(210, 213, 219), + button_border: Color32::from_rgb(175, 179, 187), + name_field: Color32::from_rgb(255, 255, 255), + name_field_sel: Color32::from_rgb(236, 242, 252), + ruler_bg: Color32::from_rgb(226, 228, 233), + ruler_label_bg: Color32::from_rgb(226, 228, 233), + ruler_text: Color32::from_rgb(70, 73, 80), + ruler_tick: Color32::from_rgb(130, 134, 142), + tempo_ruler: Color32::from_rgb(104, 170, 124), + meter_ruler: Color32::from_rgb(92, 158, 184), + marker_ruler: Color32::from_rgb(210, 213, 219), + playlist_bg: Color32::from_rgb(246, 247, 249), + playlist_alt: Color32::from_rgb(238, 240, 244), + grid_line: Color32::from_rgb(210, 213, 219), + bar_line: Color32::from_rgb(185, 189, 197), + selection: Color32::from_rgba_premultiplied(100, 145, 205, 90), + playhead: Color32::from_rgb(210, 48, 42), + insertion: Color32::from_rgb(35, 36, 40), + solo: Color32::from_rgb(178, 145, 20), + mute: Color32::from_rgb(194, 106, 22), + rec: Color32::from_rgb(190, 42, 38), + input: Color32::from_rgb(45, 126, 64), + auto_read: Color32::from_rgb(52, 142, 68), + auto_write: Color32::from_rgb(190, 50, 45), + auto_touch: Color32::from_rgb(180, 122, 24), + auto_latch: Color32::from_rgb(118, 80, 170), + meter_green: Color32::from_rgb(36, 160, 58), + meter_yellow: Color32::from_rgb(190, 162, 20), + meter_red: Color32::from_rgb(195, 42, 35), + fader_track: Color32::from_rgb(198, 201, 207), + strip_bg: Color32::from_rgb(235, 237, 241), + strip_section: Color32::from_rgb(244, 245, 247), + slot_bg: Color32::from_rgb(248, 249, 251), + automation_line: Color32::from_rgb(40, 42, 48), + menu_bar: Color32::from_rgb(222, 224, 229), + group_border: Color32::from_rgb(190, 193, 200), + mode_off: Color32::from_rgb(236, 240, 237), + counter_off: Color32::from_rgb(214, 230, 217), + counter_border: Color32::from_rgb(190, 193, 200), + counter_rule: Color32::from_rgb(204, 207, 213), + tool_bracket_off: Color32::from_rgb(200, 203, 210), + row_selected: Color32::from_rgb(198, 216, 240), + visible_dot: Color32::from_rgb(110, 113, 120), + big_counter_bg: Color32::from_rgb(224, 226, 231), + header_selected: Color32::from_rgb(206, 219, 238), + strip_selected: Color32::from_rgb(206, 219, 238), + marker_text: Color32::from_rgb(35, 36, 40), + playlist_lane: Color32::from_rgb(232, 234, 238), + universe_bg: Color32::from_rgb(228, 230, 234), + universe_view: Color32::from_rgb(40, 42, 48), + meter_bg: Color32::from_rgb(196, 199, 206), + meter_clip_off: Color32::from_rgb(232, 200, 198), + knob_bg: Color32::from_rgb(252, 252, 253), + knob_ring: Color32::from_rgb(150, 154, 162), + knob_pointer: Color32::from_rgb(40, 42, 48), + insert_on: Color32::from_rgb(206, 218, 236), + insert_bypass: Color32::from_rgb(240, 222, 186), + send_on: Color32::from_rgb(204, 230, 214), + slot_border: Color32::from_rgb(175, 179, 187), + section_label: Color32::from_rgb(95, 98, 106), + }; + + pub fn current() -> Tokens { + if is_light() { Self::LIGHT } else { Self::DARK } } } +/// Whether the Light palette is the active one. +pub fn is_light() -> bool { + LIGHT_THEME.load(Ordering::Relaxed) +} + +/// Whether `mode` resolves to the Light palette right now. Never blocks. +pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { + match mode { + ThemeMode::Light => true, + ThemeMode::Dark => false, + ThemeMode::System => system_is_light(ctx), + } +} + +/// Forget the cached system appearance so the next System lookup asks again. +pub fn refresh_system() { + let _ = SYSTEM.compare_exchange(SYSTEM_DARK, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); + let _ = SYSTEM.compare_exchange(SYSTEM_LIGHT, SYSTEM_UNKNOWN, Ordering::Relaxed, Ordering::Relaxed); +} + +/// The OS appearance. The query (a D-Bus call on Linux) runs once on a background thread; until it +/// answers this reports dark, and `ctx` is repainted when the answer arrives. +#[cfg(not(target_arch = "wasm32"))] +fn system_is_light(ctx: &egui::Context) -> bool { + if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { + let ctx = ctx.clone(); + let spawned = std::thread::Builder::new().name("soundcraft-theme".into()).spawn(move || { + let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); + SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); + ctx.request_repaint(); + }); + if spawned.is_err() { + SYSTEM.store(SYSTEM_DARK, Ordering::Relaxed); + } + } + SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT +} + +/// On the web the query is a synchronous `matchMedia` lookup (no threads there). +#[cfg(target_arch = "wasm32")] +fn system_is_light(_ctx: &egui::Context) -> bool { + if SYSTEM.compare_exchange(SYSTEM_UNKNOWN, SYSTEM_ASKING, Ordering::Relaxed, Ordering::Relaxed).is_ok() { + let light = matches!(dark_light::detect(), Ok(dark_light::Mode::Light)); + SYSTEM.store(if light { SYSTEM_LIGHT } else { SYSTEM_DARK }, Ordering::Relaxed); + } + SYSTEM.load(Ordering::Relaxed) == SYSTEM_LIGHT +} + /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; let k = if selected { 0.95 } else { 0.62 }; let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8); + if is_light() { + // A paler body and a waveform in a deep shade of the clip's own colour. + let tint = |c: u8, k: f32| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; + let k = if selected { 0.2 } else { 0.45 }; + let body = Color32::from_rgb(tint(r, k), tint(g, k), tint(b, k)); + let d = if selected { 0.22 } else { 0.32 }; + let wave = Color32::from_rgb((f32::from(r) * d) as u8, (f32::from(g) * d) as u8, (f32::from(b) * d) as u8); + return (body, bar, wave); + } let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) }; (body, bar, wave) } @@ -149,10 +413,11 @@ pub fn mono(size: f32) -> FontId { FontId::new(size, FontFamily::Monospace) } -/// Apply visuals to the context. -pub fn apply(ctx: &egui::Context) { - let t = Tokens::DARK; - let mut v = egui::Visuals::dark(); +/// Make `light` (or dark) the active palette and apply its visuals. Call only when it changes. +pub fn apply(ctx: &egui::Context, light: bool) { + LIGHT_THEME.store(light, Ordering::Relaxed); + let t = Tokens::current(); + let mut v = if light { egui::Visuals::light() } else { egui::Visuals::dark() }; v.panel_fill = t.panel_bg; v.window_fill = t.panel_bg2; v.extreme_bg_color = t.slot_bg; @@ -172,3 +437,43 @@ pub fn apply(ctx: &egui::Context) { s.interaction.tooltip_delay = 0.4; }); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Services, SoundApp, UiState}; + use serde_json::json; + + #[test] + fn dark_is_the_default() { + assert_eq!(ThemeMode::default(), ThemeMode::Dark); + assert_eq!(UiState::default().theme, ThemeMode::Dark); + // Prefs saved before the setting existed load as Dark. + let old: UiState = serde_json::from_value(json!({"show_tracks_list": true})).unwrap_or_default(); + assert_eq!(old.theme, ThemeMode::Dark); + } + + #[test] + fn modes_round_trip_through_prefs() { + for mode in ThemeMode::ALL { + assert_eq!(ThemeMode::parse(mode.id()), Some(mode)); + let ui = UiState { theme: mode, ..UiState::default() }; + let back: UiState = serde_json::from_value(serde_json::to_value(&ui).unwrap_or_default()).unwrap_or_default(); + assert_eq!(back.theme, mode); + } + assert_eq!(ThemeMode::parse(" Light "), Some(ThemeMode::Light)); + assert_eq!(ThemeMode::parse("sepia"), None); + } + + #[test] + fn theme_command_sets_and_reports_the_mode() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + assert_eq!(app.run("ui.theme", json!({})), Ok(json!({"mode": "dark"}))); + assert_eq!(app.run("ui.theme", json!({"mode": "light"})), Ok(json!({"mode": "light"}))); + assert_eq!(app.ui.theme, ThemeMode::Light); + assert!(app.run("ui.theme", json!({"mode": "sepia"})).is_err()); + assert_eq!(app.ui.theme, ThemeMode::Light); + assert_eq!(app.run("ui.theme", json!({"mode": "system"})), Ok(json!({"mode": "system"}))); + assert!(crate::menus::UI_COMMANDS.iter().any(|c| c.0 == "ui.theme")); + } +} diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 6d0cad6..e47df13 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -11,15 +11,15 @@ use soundcraft_time::{TimeFormat, format_length, format_position}; pub const HEIGHT: f32 = 78.0; fn group(ui: &mut Ui, w: f32, h: f32, add: impl FnOnce(&mut Ui, Rect)) { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); - ui.painter().rect(r, CornerRadius::same(4), t.toolbar_group, Stroke::new(1.0, Color32::from_rgb(8, 8, 8)), StrokeKind::Inside); + ui.painter().rect(r, CornerRadius::same(4), t.toolbar_group, Stroke::new(1.0, t.group_border), StrokeKind::Inside); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(5.0)).layout(egui::Layout::left_to_right(egui::Align::Min))); add(&mut child, r); } pub fn show(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.toolbar_bg); ui.add_space(4.0); @@ -38,7 +38,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { } fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let cur = app.engine.session().edit.edit_mode; group(ui, 112.0, 52.0, |ui, r| { let inner = r.shrink(6.0); @@ -54,7 +54,7 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0); let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click()); let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative); - ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { Color32::from_rgb(20, 36, 22) }); + ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { t.mode_off }); let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label }; ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text }); if resp.clicked() { @@ -114,7 +114,7 @@ fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { } fn tools(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let cur = app.engine.session().edit.tool; group(ui, 268.0 + 38.0, 52.0, |ui, r| { ui.vertical(|ui| { @@ -142,7 +142,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { let bx1 = bx0 + 38.0 * 3.0 - 2.0; let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0)); let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click()); - ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { Color32::from_rgb(64, 64, 66) }); + ui.painter().rect_filled(bracket, 1.0, if cur == Tool::Smart { t.accent } else { t.tool_bracket_off }); if resp.on_hover_text("Smart Tool (F6+F7 / F7+F8)").clicked() { let _ = app.run("edit.tool", json!({"tool": "smart"})); } @@ -177,14 +177,14 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { } fn counters(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let (main_fmt, sub_fmt, sel) = { let s = app.engine.session(); (s.edit.main_counter, s.edit.sub_counter.unwrap_or(TimeFormat::Samples), s.edit.selection) }; let pos = app.position(); let (r, _) = ui.allocate_exact_size(vec2(318.0, 64.0), Sense::hover()); - ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside); + ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let main_r = Rect::from_min_max(r.min, pos2(r.min.x + 180.0, r.min.y + 44.0)); let txt = fmt_pos(app, pos, main_fmt); ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text); @@ -211,8 +211,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) { } // Sub counter row. let y = r.max.y - 11.0; - ui.painter() - .line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, Color32::from_rgb(40, 40, 40))); + ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, t.counter_rule)); ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label); ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text); let st = if app.engine.is_dirty() { "modified" } else { "saved" }; @@ -221,15 +220,15 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) { } fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let (grid, nudge, lines) = { let e = &app.engine.session().edit; (e.grid, e.nudge, e.grid_lines) }; let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover()); - ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside); + ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0)); - ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { Color32::from_rgb(30, 50, 32) }); + ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { t.counter_off }); ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text }); if ui.interact(gl, ui.id().with("grid_lines"), Sense::click()).on_hover_text("Show grid lines").clicked() { let _ = app.run("view.grid_lines", json!({})); @@ -272,7 +271,7 @@ fn value_menu(app: &mut SoundApp, resp: &egui::Response, cmd: &str) { } fn transport(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let playing = app.is_playing(); let recording = app.engine.transport.recording; let looped = app.engine.session().edit.loop_playback; @@ -351,7 +350,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { } fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { - let t = Tokens::DARK; + let t = Tokens::current(); let (bpm, meter, countoff, click, bars) = { let s = app.engine.session(); let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate); @@ -359,9 +358,9 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { (s.tempo.tempo_at_tick(tick), format!("{}/{}", m.numerator, m.denominator), s.edit.countoff, s.edit.click, s.edit.countoff_bars) }; let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover()); - ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, Color32::from_rgb(50, 50, 50)), StrokeKind::Inside); + ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside); let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0)); - ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { Color32::from_rgb(30, 50, 32) }); + ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { t.counter_off }); ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text }); if ui.interact(co, ui.id().with("countoff"), Sense::click()).clicked() { let _ = app.run("options.countoff", json!({})); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 4566a10..5ed4584 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -6,7 +6,7 @@ use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeK /// 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::DARK; + let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); let fill = if on { t.accent @@ -24,7 +24,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st /// Square text toggle used for S / M / I / record etc. pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); let fill = if on { on_color @@ -41,7 +41,7 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color /// Record-enable button (red dot). pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); let fill = if on { t.rec @@ -57,7 +57,7 @@ pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Respons /// 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 { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); ui.painter().rect( r, @@ -76,7 +76,7 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col /// Rotary knob for pan / send pan (-1..1). Drag vertically; double-click resets. pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response { - let t = Tokens::DARK; + let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); if resp.dragged() { let d = resp.drag_delta(); @@ -87,7 +87,7 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response } let c = r.center(); let rad = size * 0.42; - ui.painter().circle(c, rad, Color32::from_rgb(26, 26, 28), Stroke::new(1.0, Color32::from_rgb(90, 90, 94))); + ui.painter().circle(c, rad, t.knob_bg, Stroke::new(1.0, t.knob_ring)); let n = 24; let start = std::f32::consts::PI * 0.75; let sweep = std::f32::consts::PI * 1.5; @@ -104,7 +104,7 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response let (a0, a1) = if at < mid { (at, mid) } else { (mid, at) }; ui.painter().add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text))); let tip_pos = c + vec2(at.cos(), at.sin()) * rad * 0.85; - ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, Color32::from_rgb(230, 230, 230))); + ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, t.knob_pointer)); resp.on_hover_text(tip) } @@ -120,8 +120,8 @@ pub fn pan_text(v: f32) -> String { /// Vertical peak meter with green/yellow/red zones; values are linear peaks. pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { - let t = Tokens::DARK; - ui.painter().rect_filled(r, 0.0, Color32::from_rgb(8, 8, 8)); + let t = Tokens::current(); + ui.painter().rect_filled(r, 0.0, t.meter_bg); let pos = |v: f32| -> f32 { let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() }; meter_pos(db) @@ -151,7 +151,7 @@ pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) { ); } let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5)); - ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { Color32::from_rgb(60, 20, 20) }); + ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off }); } /// dB → 0..1 meter position (logarithmic-ish scale like hardware meters). @@ -174,7 +174,7 @@ pub fn meter_pos(db: f32) -> f32 { /// A fader: returns the new dB value when dragged. `db` in -144..12. pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { - let t = Tokens::DARK; + let t = Tokens::current(); let resp = ui.interact(r, id, Sense::click_and_drag()); let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - 14.0)); ui.painter().rect_filled(slot, 2.0, t.fader_track); @@ -237,7 +237,7 @@ pub enum PannerEdit { /// Drag moves the puck; double-click returns it to front centre; Alt-drag (or the slider below) /// sets divergence; height layouts get a second slider for elevation. Returns the edit, if any. pub fn surround_panner(ui: &mut Ui, size: f32, speakers: &[PannerSpeaker], puck: (f32, f32), divergence: f32, z: Option) -> Option { - let t = Tokens::DARK; + let t = Tokens::current(); let mut edit = None; let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click_and_drag()); let half = size * 0.5 - 6.0; @@ -335,12 +335,12 @@ pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bo let br = Rect::from_min_size(pos2(x, mr.min.y), vec2(w, mr.height())); meter(ui, br, *lv, *lv, clip); if let Some(l) = labels.get(i) { - let galley = ui.painter().layout_no_wrap(l.clone(), regular(7.0), Tokens::DARK.text_dim); + let galley = ui.painter().layout_no_wrap(l.clone(), regular(7.0), Tokens::current().text_dim); let gw = galley.size().x; let gh = galley.size().y; // Rotated 90° anticlockwise, reading bottom-to-top, centred under the bar. let pos = pos2(br.center().x - gh * 0.5, mr.max.y + 2.0 + gw.min(label_h - 2.0)); - ui.painter().add(egui::epaint::TextShape::new(pos, galley, Tokens::DARK.text_dim).with_angle(-std::f32::consts::FRAC_PI_2)); + ui.painter().add(egui::epaint::TextShape::new(pos, galley, Tokens::current().text_dim).with_angle(-std::f32::consts::FRAC_PI_2)); } } } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 48a3eb8..332a46f 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -125,7 +125,9 @@ fn task_manager(app: &mut SoundApp, ctx: &egui::Context) { win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| { ui.label("No background tasks are running."); ui.label( - egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.").small().color(Tokens::DARK.text_dim), + egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.") + .small() + .color(Tokens::current().text_dim), ); }); app.ui.show_task_manager = open; @@ -327,6 +329,7 @@ fn configurations(app: &mut SoundApp, ctx: &egui::Context) { && 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; } diff --git a/crates/ui-egui/tests/clip_menu.rs b/crates/ui-egui/tests/clip_menu.rs new file mode 100644 index 0000000..71684a9 --- /dev/null +++ b/crates/ui-egui/tests/clip_menu.rs @@ -0,0 +1,66 @@ +//! Regression: the clip context menu must stay open while the pointer moves onto it, so its +//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane +//! returned early (no hover position) before the menu was drawn. + +use egui::{Event, Modifiers, PointerButton, pos2, vec2}; +use egui_kittest::Harness; +use soundcraft_ui_egui::{Services, SoundApp}; + +/// One UI frame, with queued synthetic pointer input applied first (as the control channel does). +fn step(h: &mut Harness<'_, SoundApp>) { + let mut raw = std::mem::take(h.input_mut()); + h.state_mut().raw_input_hook(&mut raw); + *h.input_mut() = raw; + h.step(); +} + +fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) { + let pos = pos2(x, y); + let s = &mut h.state_mut().synthetic; + s.push(Event::PointerMoved(pos)); + s.push(Event::PointerButton { pos, button, pressed: true, modifiers: Modifiers::NONE }); + s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE }); +} + +fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize { + let s = h.state().engine.session(); + s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len()) +} + +#[test] +fn clip_menu_stays_open_while_moving_onto_delete() { + let engine = soundcraft_engine::demo::demo_engine(); + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + step(&mut h); + } + let before = kick_clip_count(&h); + assert!(before > 0, "the demo session should have a recorded Kick clip"); + + // Right-click the Kick clip, then move the pointer into the menu and down to Delete. + press(&mut h, 880.0, 240.0, PointerButton::Secondary); + for _ in 0..10 { + step(&mut h); + } + // Move (no click) into the menu, as a real pointer would, so the lane loses its hover. + for (x, y) in [(900.0, 300.0), (907.0, 442.0)] { + h.state_mut().synthetic.push(Event::PointerMoved(pos2(x, y))); + for _ in 0..10 { + step(&mut h); + } + } + press(&mut h, 907.0, 442.0, PointerButton::Primary); + for _ in 0..15 { + step(&mut h); + } + + assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip"); +} diff --git a/crates/vst3-host/Cargo.toml b/crates/vst3-host/Cargo.toml index 19d7ef2..450d454 100644 --- a/crates/vst3-host/Cargo.toml +++ b/crates/vst3-host/Cargo.toml @@ -27,6 +27,22 @@ objc2-app-kit = { version = "0.3", default-features = false, features = ["std", name = "editor_main_thread" harness = false +[[test]] +name = "module_exit" +harness = false + +[[test]] +name = "module_exit_live_instance" +harness = false + +[[test]] +name = "module_exit_worker_thread" +harness = false + +[[test]] +name = "module_exit_shutdown" +harness = false + # An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate # opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere # and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`. diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index c7c60c4..f27ea39 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -15,11 +15,12 @@ #![allow(clippy::unnecessary_cast)] use crate::{Vst3Error, scan}; -use std::collections::HashMap; -use std::ffi::{c_char, c_void}; +use std::ffi::{c_char, c_int, c_void}; +use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, Ordering}; -use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::sync::atomic::{AtomicBool, AtomicI32, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError, TryLockError, mpsc}; +use std::time::Duration; use vst3::Steinberg::Vst::{self as sv}; use vst3::Steinberg::Vst::{ IAudioProcessorTrait, IComponentHandlerTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IEventListTrait, @@ -96,19 +97,106 @@ pub(crate) struct RawClass { /// A loaded VST3 binary and its plugin factory. Loaded binaries are cached for the life of the /// process (see [`Bundle::load`]): plugin code must never be unmapped while an instance may run, -/// and VST3 modules are not required to support being re-entered after their exit function. +/// and VST3 modules are not required to support being re-entered after their exit function, which +/// therefore runs once, at process exit (see [`exit_modules`]). pub(crate) struct Bundle { factory: ComPtr, - // Dropped last: the library must outlive the factory pointer. - _lib: libloading::Library, + /// The module exit matching the entry that ran at load (`bundleExit`, `ExitDll`, `ModuleExit`). + exit: Option, + /// The thread that ran the module entry, and so must run the exit. + entered_on: EntryThread, + /// Set when plugin objects were leaked on purpose (see [`EditorLink::kill`]): the module exit + /// must not run while any of its objects are alive. + leaked: AtomicBool, + // Never closed: code stays mapped for the process lifetime, even after the module exit. + _lib: ManuallyDrop, } -fn loaded() -> &'static Mutex>> { - static L: OnceLock>>> = OnceLock::new(); - L.get_or_init(|| Mutex::new(HashMap::new())) +/// Loaded binaries, oldest first (module exits run newest first, the reverse of loading). +fn loaded() -> &'static Mutex)>> { + static L: OnceLock)>>> = OnceLock::new(); + L.get_or_init(|| Mutex::new(Vec::new())) +} + +/// Set once the module exits have run: loading another module after that is refused. +static MODULES_EXITED: AtomicBool = AtomicBool::new(false); + +/// Where a module's entry ran. Its exit must run on the same thread: Qt-based modules tie their +/// application object to it (Native Instruments' Massive X crashes in `bundleExit` on another +/// thread). Modules wanted on the main thread are entered there; any other thread's requests go +/// to the module thread, because the asking thread may be gone by the time the process exits. +#[derive(Clone, Copy, PartialEq, Eq)] +enum EntryThread { + Main, + Modules, +} + +/// Whether this is the main thread. Not for exit handlers, where `std::thread::current` panics +/// (its thread-local data is gone by then): compare [`os_thread`] with [`MAIN_OS_THREAD`] there. +fn on_main_thread() -> bool { + std::thread::current().name() == Some("main") +} + +/// The OS id of the main thread, recorded when it loads a module (0 until then). +static MAIN_OS_THREAD: AtomicUsize = AtomicUsize::new(0); + +/// The OS id of the calling thread, which (unlike `std::thread::current`) exit handlers can use. +fn os_thread() -> usize { + #[cfg(unix)] + { + unsafe extern "C" { + fn pthread_self() -> usize; + } + // SAFETY: `pthread_self` takes no arguments and cannot fail; `pthread_t` is an integer or + // a pointer of pointer size on the Unix systems SoundCraft builds for. + unsafe { pthread_self() } + } + #[cfg(windows)] + { + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentThreadId() -> u32; + } + // SAFETY: `GetCurrentThreadId` takes no arguments and cannot fail. + unsafe { GetCurrentThreadId() as usize } + } +} + +type Job = Box; + +/// The module thread (started on first use): it runs jobs until the process exits. +fn module_thread() -> Option<&'static mpsc::Sender> { + static TX: OnceLock>> = OnceLock::new(); + TX.get_or_init(|| { + let (tx, rx) = mpsc::channel::(); + let spawned = std::thread::Builder::new().name("vst3-modules".into()).spawn(move || { + while let Ok(job) = rx.recv() { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(job)); + } + }); + spawned.ok().map(|_| tx) + }) + .as_ref() +} + +/// Runs `f` on the module thread and waits for its result (at most `timeout`, when given). +fn on_module_thread(f: impl FnOnce() -> R + Send + 'static, timeout: Option) -> Option { + let (rtx, rrx) = mpsc::sync_channel(1); + module_thread()? + .send(Box::new(move || { + let _ = rtx.send(f()); + })) + .ok()?; + match timeout { + Some(t) => rrx.recv_timeout(t).ok(), + None => rrx.recv().ok(), + } } type GetFactory = unsafe extern "system" fn() -> *mut sb::IPluginFactory; +/// `bundleExit` (macOS), `ExitDll` (Windows) or `ModuleExit` (Linux/BSD). `extern "system"` is the +/// C ABI everywhere except 32-bit Windows, where it is the `PLUGIN_API` (stdcall) `ExitDll` uses. +type ModuleExit = unsafe extern "system" fn() -> bool; #[cfg(target_os = "macos")] mod cf { @@ -123,7 +211,8 @@ mod cf { /// Opens the shared library and runs the platform module entry (`bundleEntry` with a /// `CFBundleRef` on macOS, `InitDll` on Windows, `ModuleEntry` with the `dlopen` handle on Linux). -fn open_library(bundle: &Path, exe: &Path) -> Result { +/// Also returns the matching module exit, when the entry ran and the module exports one. +fn open_library(bundle: &Path, exe: &Path) -> Result<(libloading::Library, Option), String> { #[cfg(all(unix, not(target_os = "macos")))] { use libloading::os::unix::{Library as UnixLib, RTLD_LOCAL, RTLD_NOW}; @@ -136,20 +225,25 @@ fn open_library(bundle: &Path, exe: &Path) -> Result(b"ModuleExit\0") }.ok().map(|s| *s); } let _ = bundle; - Ok(lib.into()) + Ok((lib.into(), exit)) } #[cfg(not(all(unix, not(target_os = "macos"))))] { // SAFETY: loading a shared library runs its static initialisers. That is inherent to // hosting plugins; we only load files found in VST3 plugin folders or named explicitly. let lib = unsafe { libloading::Library::new(exe) }.map_err(|e| e.to_string())?; + let mut exit: Option = None; #[cfg(windows)] { let _ = bundle; @@ -160,6 +254,9 @@ fn open_library(bundle: &Path, exe: &Path) -> Result(b"ExitDll\0") }.ok().map(|s| *s); } } #[cfg(target_os = "macos")] @@ -198,39 +295,143 @@ fn open_library(bundle: &Path, exe: &Path) -> Result(b"bundleExit\0") } + .or_else(|_| { + // SAFETY: as above (older SDKs export the capitalised name). + unsafe { lib.get::(b"BundleExit\0") } + }) + .ok() + .map(|s| *s); } } - Ok(lib) + Ok((lib, exit)) + } +} + +/// Runs the module exits at process exit, newest module first, each on the thread that ran its +/// entry. VST3 modules expect their exit before their static destructors run, and some crash in +/// those destructors without it (seen with Steinberg's HALion Sonic, after a plain scan). A module +/// with a live instance or leaked objects keeps running and is not exited, and so is one entered +/// on the main thread when the process exits from another thread. Runs once; later loads are +/// refused. +pub(crate) fn exit_modules() { + if MODULES_EXITED.swap(true, Ordering::AcqRel) { + return; + } + let mut cache = match loaded().try_lock() { + Ok(g) => g, + Err(TryLockError::Poisoned(p)) => p.into_inner(), + // Another thread is loading a module right now: leave every module as it is. + Err(TryLockError::WouldBlock) => return, + }; + let main = MAIN_OS_THREAD.load(Ordering::Acquire) == os_thread(); + let (mut kept, mut here, mut there) = (Vec::new(), Vec::new(), Vec::new()); + while let Some((path, b)) = cache.pop() { + if b.leaked.load(Ordering::Acquire) || (b.entered_on == EntryThread::Main && !main) { + kept.push((path, b)); + continue; + } + match Arc::try_unwrap(b) { + Ok(bundle) if bundle.entered_on == EntryThread::Main => here.push(bundle), + Ok(bundle) => there.push(bundle), + Err(b) => kept.push((path, b)), + } + } + kept.reverse(); + *cache = kept; + drop(cache); + here.into_iter().for_each(Bundle::exit); + if !there.is_empty() { + // Bounded: a module thread stuck inside a plugin must not hang the process exit. + let _ = on_module_thread(move || there.into_iter().for_each(Bundle::exit), Some(Duration::from_secs(5))); + } +} + +/// Registers [`exit_modules`] to run at process exit. Exit handlers run in reverse order of +/// registration, C++ static destructors included, so registering again after each module load +/// puts the exits ahead of the destructors of every module loaded so far. +fn register_exit_hook() { + unsafe extern "C" { + fn atexit(f: extern "C" fn()) -> c_int; + } + extern "C" fn hook() { + // Nothing may unwind out of an exit handler. + let _ = std::panic::catch_unwind(exit_modules); + } + // SAFETY: `atexit` is the C library function with this signature; `hook` is a plain function + // of this binary, so it lives as long as the process. + if unsafe { atexit(hook) } != 0 { + log::warn!("vst3: cannot register the module exit handler"); } } impl Bundle { /// Loads (once per path, then cached for the life of the process) a `.vst3`. pub fn load(path: &Path) -> Result, Vst3Error> { - let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner); - if let Some(b) = map.get(path) { + let mut cache = loaded().lock().unwrap_or_else(PoisonError::into_inner); + if let Some((_, b)) = cache.iter().find(|(p, _)| p == path) { return Ok(b.clone()); } - let b = Arc::new(Bundle::open(path)?); - map.insert(path.to_path_buf(), b.clone()); + let err = |m: &str| Vst3Error::Load(path.display().to_string(), m.to_string()); + if MODULES_EXITED.load(Ordering::Acquire) { + return Err(err("plugin hosting has shut down")); + } + let b = if on_main_thread() { + MAIN_OS_THREAD.store(os_thread(), Ordering::Release); + Bundle::open(path, EntryThread::Main)? + } else { + let p = path.to_path_buf(); + on_module_thread(move || Bundle::open(&p, EntryThread::Modules), None).ok_or_else(|| err("the module thread is not running"))?? + }; + let b = Arc::new(b); + cache.push((path.to_path_buf(), b.clone())); + // After every new module, so that the exits run before its static destructors. + register_exit_hook(); Ok(b) } - fn open(path: &Path) -> Result { + fn open(path: &Path, entered_on: EntryThread) -> Result { let err = |m: String| Vst3Error::Load(path.display().to_string(), m); let exe = scan::bundle_binary(path).ok_or_else(|| err("no loadable binary".into()))?; - let lib = open_library(path, &exe).map_err(err)?; + let (lib, exit) = open_library(path, &exe).map_err(err)?; + // The module entry ran: balance it before giving up on the module. + let fail = |m: String| { + if let Some(f) = exit { + // SAFETY: balanced with the entry that ran in `open_library`; no plugin object exists. + unsafe { f() }; + } + err(m) + }; // SAFETY: `GetPluginFactory` is the VST3-mandated export with this signature. let get = match unsafe { lib.get::(b"GetPluginFactory\0") } { Ok(sym) => *sym, - Err(e) => return Err(err(format!("not a VST3 plugin: {e}"))), + Err(e) => return Err(fail(format!("not a VST3 plugin: {e}"))), }; // SAFETY: the module is loaded and its entry function (if any) has run. let raw = unsafe { get() }; // SAFETY: a non-null result is a factory pointer with one reference owned by the caller // (VST3 contract), which the `ComPtr` takes over. - let factory = unsafe { ComPtr::from_raw(raw) }.ok_or_else(|| err("GetPluginFactory returned null".into()))?; - Ok(Bundle { factory, _lib: lib }) + let factory = unsafe { ComPtr::from_raw(raw) }.ok_or_else(|| fail("GetPluginFactory returned null".into()))?; + Ok(Bundle { factory, exit, entered_on, leaked: AtomicBool::new(false), _lib: ManuallyDrop::new(lib) }) + } + + /// Releases the factory, then runs the module exit, as the VST3 SDK's own host does. + fn exit(self) { + let Bundle { factory, exit, .. } = self; + drop(factory); + if let Some(f) = exit { + // SAFETY: balanced with the entry that ran when the module was loaded; called once + // (`self` is consumed), after every instance is gone (each holds an `Arc` to the + // bundle) and the factory released. The code stays mapped (`_lib` is never closed). + unsafe { f() }; + } + } + + /// Plugin objects of this module were leaked on purpose: never run its module exit. + pub fn mark_leaked(&self) { + self.leaked.store(true, Ordering::Release); } /// The vendor from the factory info (fallback for classes without one). @@ -330,7 +531,7 @@ impl Bundle { editor: Arc::new(EditorLink::default()), component, host, - _bundle: self.clone(), + bundle: self.clone(), }; let processor = inst.component.cast::().ok_or_else(|| err("no IAudioProcessor"))?; inst.processor = Some(processor); @@ -894,7 +1095,7 @@ pub(crate) struct Instance { // and host objects are released, and the bundle (which keeps the code mapped) goes last. component: ComPtr, host: ComWrapper, - _bundle: Arc, + bundle: Arc, } // SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always @@ -1260,7 +1461,9 @@ impl Instance { impl Drop for Instance { fn drop(&mut self) { // Close any editor and cut its handles off before the controller goes away. - self.editor.kill(); + if self.editor.kill() { + self.bundle.mark_leaked(); + } self.shutdown(); if let Some((a, b)) = self.connections.take() { // SAFETY: the pair was connected in `instantiate`; disconnect before terminating. @@ -1515,8 +1718,10 @@ impl EditorLink { } /// Called by the instance before it terminates: closes the editor and cuts the handles off. - fn kill(&self) { + /// Returns whether plugin objects had to be leaked. + fn kill(&self) -> bool { let mut g = self.lock(); + let mut leaked = false; if g.view.is_some() || g.window.is_some() { if window::on_main_thread() { EditorLink::teardown(&mut g); @@ -1525,6 +1730,7 @@ impl EditorLink { log::warn!("vst3: editor still open while its plugin is destroyed off the main thread"); if let Some(v) = g.view.take() { std::mem::forget(v); + leaked = true; } // `HostWindow` has no destructor: dropping it leaks its retain on purpose. g.window = None; @@ -1534,6 +1740,7 @@ impl EditorLink { } } g.controller = None; + leaked } } diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 8c2972b..0cfd3ab 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -135,6 +135,9 @@ mod api { pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { registry::plugin_info(id) } + pub fn shutdown() { + ffi::exit_modules(); + } } #[cfg(target_arch = "wasm32")] @@ -162,6 +165,7 @@ mod api { pub fn plugin_info(_id: &str) -> Option<&'static PluginInfo> { None } + pub fn shutdown() {} } /// Every plugin in the standard VST3 folders, `VST3_PATH` and folders added with @@ -187,6 +191,15 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// Shuts plugin hosting down: runs the module exit of every loaded binary that has no live +/// instance left, each on the thread that loaded it; later loads fail. Call it on the main +/// thread once the plugin instances are gone, before the process exits. Without it the exits +/// run from an exit handler, by which time some plugins have already torn down state they need +/// (HALion Sonic crashes in its module exit after it has been used). +pub fn shutdown() { + api::shutdown() +} + /// The audio module classes in one `.vst3` bundle or file. pub fn load_bundle(path: &Path) -> Result, Vst3Error> { api::load_bundle(path) diff --git a/crates/vst3-host/tests/editor_main_thread.rs b/crates/vst3-host/tests/editor_main_thread.rs index ab5d28c..b6fbaa5 100644 --- a/crates/vst3-host/tests/editor_main_thread.rs +++ b/crates/vst3-host/tests/editor_main_thread.rs @@ -9,7 +9,12 @@ fn main() { let dir = common::fixture_dir(); let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"); - assert_eq!(p.editor_size(), Ok((320, 240))); + // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". + if cfg!(target_os = "macos") { + assert_eq!(p.editor_size(), Ok((320, 240))); + } else { + assert!(p.editor_size().is_err()); + } let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle"); assert!(!ed.is_open()); assert_eq!(ed.idle().len(), 1, "the fixture's setup edit"); diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index dd7c382..3ba07c6 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -11,12 +11,15 @@ //! its edit controller). No audio input, one stereo output, one event input; every output //! sample is the velocity of the held note (sample-accurate), 0 when none. //! +//! The module checks the host's module lifecycle the way real plugins depend on it (see "module +//! lifecycle" below) and aborts the process when it is broken. +//! //! Test code: panics are fine here, the host must survive anything anyway. #![allow(non_snake_case, clippy::missing_safety_doc)] -use std::ffi::{c_char, c_void}; -use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::ffi::{c_char, c_int, c_void}; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, AtomicUsize, Ordering}; use vst3::Steinberg::Vst::*; use vst3::Steinberg::*; use vst3::{Class, ComRef, ComWrapper, uid}; @@ -64,6 +67,7 @@ struct GainProcessor { /// Normalized gain (plain = 2 × normalized). gain: AtomicU64, arrangement: AtomicU64, + _live: Live, } impl Class for GainProcessor { @@ -209,10 +213,12 @@ impl IAudioProcessorTrait for GainProcessor { struct GainController { gain: AtomicU64, freq: AtomicU64, + _live: Live, } struct View { attached: std::sync::atomic::AtomicBool, + _live: Live, } impl Class for View { @@ -396,7 +402,7 @@ impl IEditControllerTrait for GainController { if name.is_null() || unsafe { std::ffi::CStr::from_ptr(name) }.to_bytes() != b"editor" { return std::ptr::null_mut(); } - match ComWrapper::new(View { attached: std::sync::atomic::AtomicBool::new(false) }).to_com_ptr::() { + match ComWrapper::new(View { attached: std::sync::atomic::AtomicBool::new(false), _live: Live::new(&OBJECTS) }).to_com_ptr::() { Some(p) => p.into_raw(), None => std::ptr::null_mut(), } @@ -407,6 +413,7 @@ impl IEditControllerTrait for GainController { struct Synth { level: AtomicU32, + _live: Live, } impl Class for Synth { @@ -582,7 +589,9 @@ impl IEditControllerTrait for Synth { // ---- factory ------------------------------------------------------------------------------- -struct Factory; +struct Factory { + _live: Live, +} impl Class for Factory { type Interfaces = (IPluginFactory2,); @@ -616,13 +625,24 @@ impl IPluginFactoryTrait for Factory { kResultOk } unsafe fn createInstance(&self, cid: FIDString, iid: FIDString, obj: *mut *mut c_void) -> tresult { + set_up_late_state(); let cid = unsafe { *(cid as *const TUID) }; let unknown = if cid == GAIN_CID { - ComWrapper::new(GainProcessor { gain: AtomicU64::new(0.25f64.to_bits()), arrangement: AtomicU64::new(SpeakerArr::kStereo) }).to_com_ptr::() + ComWrapper::new(GainProcessor { + gain: AtomicU64::new(0.25f64.to_bits()), + arrangement: AtomicU64::new(SpeakerArr::kStereo), + _live: Live::new(&OBJECTS), + }) + .to_com_ptr::() } else if cid == GAIN_CTRL_CID { - ComWrapper::new(GainController { gain: AtomicU64::new(0.5f64.to_bits()), freq: AtomicU64::new(0.5f64.to_bits()) }).to_com_ptr::() + ComWrapper::new(GainController { + gain: AtomicU64::new(0.5f64.to_bits()), + freq: AtomicU64::new(0.5f64.to_bits()), + _live: Live::new(&OBJECTS), + }) + .to_com_ptr::() } else if cid == SYNTH_CID { - ComWrapper::new(Synth { level: AtomicU32::new(0) }).to_com_ptr::() + ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::() } else { None }; @@ -653,45 +673,160 @@ impl IPluginFactory2Trait for Factory { } } +// ---- module lifecycle ---------------------------------------------------------------------- +// +// The contract real plugins rely on: the host runs the module exit once the factory and every +// plugin object are released, on the thread that ran the module entry, and before the process +// tears down the module's statics. Breaking it ends the process with status 3, much as real +// plugins crash (HALion Sonic segfaults in its static destructors when the module exit never ran, +// and in its module exit when that runs from an exit handler after it was used; Massive X in its +// module exit when that runs on another thread than the entry). + +/// Module entries minus module exits (they nest, as in the VST3 SDK). +static ENTERED: AtomicI64 = AtomicI64::new(0); +/// The OS thread that ran the outermost module entry. +static ENTRY_THREAD: AtomicUsize = AtomicUsize::new(0); +/// Factories handed out and not yet released. +static FACTORIES: AtomicI64 = AtomicI64::new(0); +/// Plugin objects alive: components, controllers, views. +static OBJECTS: AtomicI64 = AtomicI64::new(0); +static TEARDOWN_REGISTERED: AtomicBool = AtomicBool::new(false); +/// With `VST3_FIXTURE_LATE_STATE` set, the first plugin made sets up state that the process exit +/// tears down, like a static a real plugin initialises lazily; the module exit needs it. +static LATE_STATE_REGISTERED: AtomicBool = AtomicBool::new(false); +static LATE_STATE_GONE: AtomicBool = AtomicBool::new(false); + +unsafe extern "C" { + fn atexit(f: extern "C" fn()) -> c_int; + fn _exit(status: c_int) -> !; +} + +/// Counts itself in one of the counters above for as long as it exists. +struct Live(&'static AtomicI64); + +impl Live { + fn new(counter: &'static AtomicI64) -> Live { + counter.fetch_add(1, Ordering::SeqCst); + Live(counter) + } +} + +impl Drop for Live { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Ends the process at once with status 3 (no crash report: one test expects this). +fn violation(what: &str) -> ! { + eprintln!("vst3-fixture: host contract violation: {what}"); + unsafe { _exit(3) } +} + +/// The OS id of the calling thread (`std::thread::current` is gone in exit handlers). +fn os_thread() -> usize { + #[cfg(unix)] + { + unsafe extern "C" { + fn pthread_self() -> usize; + } + unsafe { pthread_self() } + } + #[cfg(windows)] + { + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetCurrentThreadId() -> u32; + } + unsafe { GetCurrentThreadId() as usize } + } +} + +fn module_entry() -> bool { + if ENTERED.fetch_add(1, Ordering::SeqCst) == 0 { + ENTRY_THREAD.store(os_thread(), Ordering::SeqCst); + } + if !TEARDOWN_REGISTERED.swap(true, Ordering::SeqCst) { + unsafe { atexit(static_teardown) }; + } + true +} + +/// Called whenever the factory makes a plugin object. +fn set_up_late_state() { + if std::env::var_os("VST3_FIXTURE_LATE_STATE").is_some() && !LATE_STATE_REGISTERED.swap(true, Ordering::SeqCst) { + unsafe { atexit(late_state_teardown) }; + } +} + +extern "C" fn late_state_teardown() { + LATE_STATE_GONE.store(true, Ordering::SeqCst); +} + +fn module_exit() -> bool { + let left = ENTERED.fetch_sub(1, Ordering::SeqCst) - 1; + if left < 0 { + violation("module exit without a module entry"); + } + let (factories, objects) = (FACTORIES.load(Ordering::SeqCst), OBJECTS.load(Ordering::SeqCst)); + if left == 0 && (factories != 0 || objects != 0) { + violation(&format!("module exit with {factories} factories and {objects} plugin objects alive")); + } + if left == 0 && os_thread() != ENTRY_THREAD.load(Ordering::SeqCst) { + violation("module exit on another thread than the module entry"); + } + if LATE_STATE_GONE.load(Ordering::SeqCst) { + violation("module exit after the process exit tore down state the module set up while in use"); + } + true +} + +/// Stands in for the C++ static destructors a real module registers while it loads. +extern "C" fn static_teardown() { + if ENTERED.load(Ordering::SeqCst) > 0 && OBJECTS.load(Ordering::SeqCst) == 0 { + violation("static teardown of a module that is not in use but was never exited"); + } +} + #[cfg(target_os = "windows")] #[unsafe(no_mangle)] extern "system" fn InitDll() -> bool { - true + module_entry() } #[cfg(target_os = "windows")] #[unsafe(no_mangle)] extern "system" fn ExitDll() -> bool { - true + module_exit() } #[cfg(target_os = "macos")] #[unsafe(no_mangle)] extern "C" fn bundleEntry(bundle: *mut c_void) -> bool { - !bundle.is_null() + !bundle.is_null() && module_entry() } #[cfg(target_os = "macos")] #[unsafe(no_mangle)] extern "C" fn bundleExit() -> bool { - true + module_exit() } #[cfg(all(unix, not(target_os = "macos")))] #[unsafe(no_mangle)] extern "C" fn ModuleEntry(handle: *mut c_void) -> bool { - !handle.is_null() + !handle.is_null() && module_entry() } #[cfg(all(unix, not(target_os = "macos")))] #[unsafe(no_mangle)] extern "C" fn ModuleExit() -> bool { - true + module_exit() } #[unsafe(no_mangle)] extern "system" fn GetPluginFactory() -> *mut IPluginFactory { - match ComWrapper::new(Factory).to_com_ptr::() { + match ComWrapper::new(Factory { _live: Live::new(&FACTORIES) }).to_com_ptr::() { Some(p) => p.into_raw(), None => std::ptr::null_mut(), } diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index 23970d2..e3603b7 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -212,7 +212,12 @@ fn state_saves_component_and_controller_and_restores_them() { fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap(); - assert_eq!(p.editor_size(), Ok((320, 240))); + // Editor windows are hosted on macOS only; elsewhere the probe reports "unsupported". + if cfg!(target_os = "macos") { + assert_eq!(p.editor_size(), Ok((320, 240))); + } else { + assert!(p.editor_size().is_err()); + } let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle"); // The fixture "edits" Gain to 0.9 (normalized) when it gets the component handler. assert_eq!(ed.idle(), vec![("7".to_string(), 1.8)]); diff --git a/crates/vst3-host/tests/module_exit.rs b/crates/vst3-host/tests/module_exit.rs new file mode 100644 index 0000000..2377dd8 --- /dev/null +++ b/crates/vst3-host/tests/module_exit.rs @@ -0,0 +1,14 @@ +//! At process exit the host runs the module exit of every module it loaded, after releasing the +//! factory and before the module's statics are torn down (`harness = false`: the process exit is +//! what is under test). The fixture aborts the process if that order breaks, much as real plugins +//! crash: HALion Sonic segfaulted in its static destructors after a plain plugin scan. + +mod common; + +fn main() { + let dir = common::fixture_dir(); + let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; + let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"); + drop(p); + println!("module_exit: ok"); +} diff --git a/crates/vst3-host/tests/module_exit_live_instance.rs b/crates/vst3-host/tests/module_exit_live_instance.rs new file mode 100644 index 0000000..737c3de --- /dev/null +++ b/crates/vst3-host/tests/module_exit_live_instance.rs @@ -0,0 +1,14 @@ +//! A module with a plugin instance still alive at process exit keeps running: its module exit +//! would pull the module out from under that instance (`harness = false`, see `module_exit.rs`). +//! The fixture aborts the process if its module exit runs while any of its objects are alive. + +mod common; + +fn main() { + let dir = common::fixture_dir(); + let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; + let p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"); + // Still alive at exit, like a plugin held by a mixer that is never torn down. + std::mem::forget(p); + println!("module_exit_live_instance: ok"); +} diff --git a/crates/vst3-host/tests/module_exit_shutdown.rs b/crates/vst3-host/tests/module_exit_shutdown.rs new file mode 100644 index 0000000..cc337c9 --- /dev/null +++ b/crates/vst3-host/tests/module_exit_shutdown.rs @@ -0,0 +1,44 @@ +//! A plugin that sets up state while in use (HALion Sonic does) needs its module exit before the +//! process exit tears that state down: `shutdown()` runs the exits up front, while an exit +//! handler comes too late (`harness = false`, see `module_exit.rs`). Each case runs in a child +//! process with the fixture's late state on (`VST3_FIXTURE_LATE_STATE`); the fixture ends the +//! process with status 3 when its module exit comes after the teardown. + +mod common; + +use soundcraft_dsp::Plugin; +use std::process::{Command, Output}; + +/// Makes, uses and releases a plugin, then shuts plugin hosting down unless `exit_handler_only`. +fn child(exit_handler_only: bool) { + let dir = common::fixture_dir(); + let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; + let mut p = soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate"); + p.prepare(48_000.0, 64, 2); + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + drop(p); + if !exit_handler_only { + soundcraft_vst3_host::shutdown(); + assert!(soundcraft_vst3_host::instantiate_plugin(&gain).is_err(), "loads fail after shutdown"); + } +} + +fn run(case: &str) -> Output { + Command::new(std::env::current_exe().expect("test binary")).arg(case).env("VST3_FIXTURE_LATE_STATE", "1").output().expect("run the child") +} + +fn main() { + match std::env::args().nth(1).as_deref() { + Some("shutdown") => return child(false), + Some("exit-handler") => return child(true), + _ => {} + } + let up_front = run("shutdown"); + assert!(up_front.status.success(), "shutdown before exit: {:?}\n{}", up_front.status, String::from_utf8_lossy(&up_front.stderr)); + let too_late = run("exit-handler"); + let err = String::from_utf8_lossy(&too_late.stderr); + assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}"); + assert!(err.contains("module exit after the process exit tore down state"), "{err}"); + println!("module_exit_shutdown: ok"); +} diff --git a/crates/vst3-host/tests/module_exit_worker_thread.rs b/crates/vst3-host/tests/module_exit_worker_thread.rs new file mode 100644 index 0000000..4a4de01 --- /dev/null +++ b/crates/vst3-host/tests/module_exit_worker_thread.rs @@ -0,0 +1,17 @@ +//! A module first loaded from a short-lived thread is entered and, at process exit, exited on +//! one thread that outlives it (`harness = false`, see `module_exit.rs`): the asking thread is +//! gone by then, and modules tie state to the thread of their entry (Massive X crashed in a +//! module exit run on another thread). The fixture aborts the process if the threads differ. + +mod common; + +fn main() { + std::thread::spawn(|| { + let dir = common::fixture_dir(); + let gain = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]).into_iter().find(|d| d.name == "Fixture Gain").expect("fixture").id; + drop(soundcraft_vst3_host::instantiate_plugin(&gain).expect("instantiate")); + }) + .join() + .expect("worker thread"); + println!("module_exit_worker_thread: ok"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 527711b..3432699 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -41,6 +41,12 @@ process in parallel; plugin delay compensation aligns every path. The same engin bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns it on the audio thread and receives new session snapshots through a channel). +The cpal callbacks mark their thread (`soundcraft_playback::mark_audio_thread`). Code that may run +there can ask `on_audio_thread()` before doing anything that blocks; the desktop app's logger does, +so a `log::` record from the audio thread (a stream error, a full synth event queue, a hosted +plugin's failed `process`, a CLAP plugin's own log call) is kept without waiting and written later +by the UI thread (see README › Logs). + ## Agent control The desktop app's control channel (JSON lines over TCP) exposes engine commands, inspection, diff --git a/docs/mcp.md b/docs/mcp.md index ad197bb..2949871 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,7 +24,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo | `execute` | Run one command with JSON params. | | `batch` | Run several commands in order (`keep_going` optional). | | `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). | -| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). | +| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). | | `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. | | `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. | | `parity` | Feature-parity report. |