Merge origin/main into feat/inspect-source-loaded

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-09 20:37:45 -04:00
commit e2bfdd5b73
58 changed files with 3496 additions and 316 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ plan/
/target-*/
/craft-fonts/
plans/
.DS_Store

81
Cargo.lock generated
View File

@ -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"

View File

@ -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"] }

View File

@ -158,6 +158,28 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
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 |

View File

@ -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 |

View File

@ -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::<Vec<_>>()
.join("\n")
}
fn main() -> ExitCode {
let args: Vec<String> = 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<String> = 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::<Vec<_>>().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<String> {
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<Engine, String> {
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::<f64>().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::<Value>(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::<Value>(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, &params) {
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::<Value>(p).ok()).unwrap_or(json!({}));
let params = match positional.get(1) {
Some(p) => match serde_json::from_str::<Value>(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"));
}
}

View File

@ -15,6 +15,27 @@
</head>
<body>
<canvas id="soundcraft_canvas"></canvas>
<script>
// cpal (wasm) builds the output stream at startup, before any user gesture, so the
// WebAudio AudioContext stays 'suspended': no audio is heard and the transport clock
// (driven by the audio callback) never advances. Capture the AudioContext cpal creates
// and resume it on the first user gesture. Runs before the wasm module is instantiated.
(function () {
var Orig = window.AudioContext || window.webkitAudioContext;
if (!Orig) return;
var ctxs = [];
function Wrapped(opts) { var c = new Orig(opts); ctxs.push(c); return c; }
Wrapped.prototype = Orig.prototype;
window.AudioContext = Wrapped;
if (window.webkitAudioContext) window.webkitAudioContext = Wrapped;
function resumeAll() {
ctxs.forEach(function (c) { if (c.state === 'suspended') { c.resume().catch(function () {}); } });
}
['pointerdown', 'mousedown', 'touchstart', 'keydown'].forEach(function (ev) {
window.addEventListener(ev, resumeAll, { capture: true });
});
})();
</script>
<div id="soundcraft_loading">Loading SoundCraft&hellip;</div>
</body>
</html>

View File

@ -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 }

View File

@ -13,12 +13,12 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
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<ControlRequest> {
}
});
if let Err(e) = spawned {
eprintln!("soundcraft: control thread failed: {e}");
log::error!("control thread failed: {e}");
}
rx
}

View File

@ -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<Backends>) {
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>) -> 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);
}
}
}

View File

@ -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:
//! `<settings dir>/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.<KEEP>.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<LevelFilter> {
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` → … → `.<KEEP>.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: `<timestamp> <LEVEL> [<thread>] <target>: <message>\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<File>,
pending: Vec<String>,
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(&note);
}
}
/// 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<const N: usize> {
buf: [u8; N],
len: usize,
cut: bool,
}
impl<const N: usize> Bounded<N> {
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<const N: usize> std::fmt::Write for Bounded<N> {
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<REALTIME_TARGET_BYTES>,
message: Bounded<REALTIME_MESSAGE_BYTES>,
}
/// 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<Option<Held>>,
}
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(&timestamp(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(&timestamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), &note));
}
lines
}
}
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
pub struct AppLogger {
filter: Filter,
stderr: bool,
sink: Mutex<Sink>,
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<PathBuf, String> {
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(&timestamp(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<AppLogger> = 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<T: Send>(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);
}
}

View File

@ -5,20 +5,40 @@
//! `--control <port>` (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 `<settings dir>/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<native_menu::NativeMenu>);
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<String> =
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<std::path::PathBuf> {
let base = if cfg!(target_os = "macos") {
/// The per-user settings directory: `ui.json`, `Autosave/`, `Presets/` and `logs/` live here.
fn config_dir() -> Option<std::path::PathBuf> {
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<std::path::PathBuf> {
.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<std::path::PathBuf> {
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<UiState> {
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<UiState> {
}
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<egui::IconData> {
}
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<String> = 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<u16> = 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,
)))
}),
)
}

View File

@ -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<String>,
path: String,
enabled: bool,
checked: bool,
}
pub struct NativeMenu {
menu: Menu,
entries: Vec<Entry>,
events: Receiver<MenuEvent>,
/// When `sync` last walked the entries; see `SYNC_EVERY`.
last_sync: Option<std::time::Instant>,
}
/// `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<Self> {
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<Submenu> {
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<String>) -> 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::<fn(MenuEvent)>);
}
}

View File

@ -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

View File

@ -257,6 +257,19 @@ pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
}
}
/// 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<FileFormat> {
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<Vec<u8>> {
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)

View File

@ -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}");
}
}

View File

@ -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::*;

View File

@ -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

View File

@ -25,9 +25,13 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`) or VST3 (`vst3:<class id>`) plugin's.
/// A built-in plugin's description, else a hosted CLAP (`clap:<id>`), VST3 (`vst3:<class id>`) or
/// Audio Units (`au:<type>:<subtype>:<manufacturer>`) 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");
}
}

View File

@ -359,3 +359,21 @@ fn step_marker(e: &mut Engine, dir: i64) -> Result<Value> {
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);
}
}

View File

@ -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<CommandSpec> {
@ -11,8 +11,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
"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<Value> {
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<Value> {
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<Option<TrackId>> {
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<bool> {
match str_param(p, "fold_down").map(str::trim) {
@ -233,11 +248,10 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value>
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<f32> = (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);
}
}

View File

@ -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<CommandSpec> {
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<Value> {
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<String> {
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);
}
}

View File

@ -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
})

View File

@ -110,10 +110,6 @@ fn drums(sr: f64, frames: usize) -> (Vec<f32>, Vec<f32>, Vec<f32>) {
(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<f32> {
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<f32>, Vec<f32>) {
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;

View File

@ -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}`")]

View File

@ -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<bool> = 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<T: Send>(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<f32>]) {
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<f32>], 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<f32>], _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<Box<dyn Plugin>> {
.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<bool> {
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<Vec<f32>> {
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();

View File

@ -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,
) {

View File

@ -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<f32> = 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<f32>,
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<std::collections::VecDeque<f32>>,
state: Mutex<RingState>,
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<InputRing> {
pub fn new(channels: usize, rate: u32) -> Arc<InputRing> {
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<f32>], 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<f32>], 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<f32> = (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<f32> = (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<f32> = (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));
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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<Samples>,
}
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<String> = 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::<Option<ClipId>>(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::<crate::DragSource>()
&& 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::<crate::DragSource>().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<ClipId>) {
}
}
/// 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<Samples>,
hold: bool,
pos: Samples,
) -> (bool, Option<Samples>) {
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<Samples> = 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));
}
}

View File

@ -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<f32> = 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;
}

View File

@ -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<FontData> {
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::<u32>().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<FontData> {
fn load(cands: &[(&str, u32)], bold: bool) -> Option<FontData> {
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<FontData> {
}
#[cfg(target_arch = "wasm32")]
fn load(_: &[(&str, u32)]) -> Option<FontData> {
fn load(_: &[(&str, u32)], _: bool) -> Option<FontData> {
None
}
@ -50,13 +80,13 @@ pub fn definitions() -> FontDefinitions {
let mut fonts = FontDefinitions::default();
let base: Vec<String> = 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") {

View File

@ -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<std::path::PathBuf>,
/// 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<bool>,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
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<Player>, 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));
}
}

View File

@ -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<MenuNode>,
/// Shared menu hierarchy for egui and native desktop hosts.
pub struct MenuNode {
pub label: String,
pub path: String,
pub children: Vec<MenuNode>,
}
fn tree() -> Vec<MenuNode> {
fn insert_path(roots: &mut Vec<MenuNode>, 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<MenuNode> {
let mut roots: Vec<MenuNode> = 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<String> {
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<Result<
let _ = app.dialogs.open_for_command(&app.engine, "file.bounce_mix");
json!({})
}
"ui.theme" => {
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()) {

View File

@ -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));

View File

@ -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<f32
}
fn section_label(ui: &Ui, r: Rect, text: &str) {
ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Color32::from_rgb(200, 200, 200));
ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Tokens::current().section_label);
}
fn route_name(app: &SoundApp, r: &Route) -> 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<String> {
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<MenuPlugin>, none: &str) -> Option<String> {
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:<id>`), VST3 (`vst3:<class id>`) 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);

View File

@ -66,10 +66,10 @@ fn result_line(ui: &mut egui::Ui, r: &Result<Value, String>) {
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));
}
}
}

View File

@ -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<String> = None;
let mut close = ctx.input(|i| i.key_pressed(Key::Escape));
egui::Window::new("Search")

View File

@ -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<u64>, 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;

View File

@ -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));
}
}

View File

@ -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> {
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"));
}
}

View File

@ -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!({}));

View File

@ -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<f32> {
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<f32>) -> Option<PannerEdit> {
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));
}
}
}

View File

@ -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::<crate::UiState>(v.clone())
{
st.configurations = app.ui.configurations.clone();
st.theme = app.ui.theme;
st.show_configurations = true;
app.ui = st;
}

View File

@ -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");
}

View File

@ -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]`.

View File

@ -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<sb::IPluginFactory>,
// 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<ModuleExit>,
/// 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<libloading::Library>,
}
fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
static L: OnceLock<Mutex<HashMap<PathBuf, Arc<Bundle>>>> = 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<Vec<(PathBuf, Arc<Bundle>)>> {
static L: OnceLock<Mutex<Vec<(PathBuf, Arc<Bundle>)>>> = 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<dyn FnOnce() + Send>;
/// The module thread (started on first use): it runs jobs until the process exits.
fn module_thread() -> Option<&'static mpsc::Sender<Job>> {
static TX: OnceLock<Option<mpsc::Sender<Job>>> = OnceLock::new();
TX.get_or_init(|| {
let (tx, rx) = mpsc::channel::<Job>();
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<R: Send + 'static>(f: impl FnOnce() -> R + Send + 'static, timeout: Option<Duration>) -> Option<R> {
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<libloading::Library, String> {
/// 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<ModuleExit>), 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<libloading::Library, String
let raw = lib.into_raw();
// SAFETY: `raw` is the handle we just took out of `lib`; it is rewrapped right away.
let lib = unsafe { UnixLib::from_raw(raw) };
let mut exit = None;
if let Some(f) = entry {
// SAFETY: called once, with the module's own `dlopen` handle, as the spec requires.
if !unsafe { f(raw) } {
return Err("ModuleEntry failed".into());
}
// SAFETY: `ModuleExit` is the VST3-mandated Linux exit with this signature; the
// pointer stays valid because loaded modules are never unmapped.
exit = unsafe { lib.get::<ModuleExit>(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<ModuleExit> = None;
#[cfg(windows)]
{
let _ = bundle;
@ -160,6 +254,9 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<libloading::Library, String
if !unsafe { f() } {
return Err("InitDll failed".into());
}
// SAFETY: `ExitDll` is the VST3-mandated Windows exit with this signature; the
// pointer stays valid because loaded modules are never unmapped.
exit = unsafe { lib.get::<ModuleExit>(b"ExitDll\0") }.ok().map(|s| *s);
}
}
#[cfg(target_os = "macos")]
@ -198,39 +295,143 @@ fn open_library(bundle: &Path, exe: &Path) -> Result<libloading::Library, String
if !unsafe { f(cfbundle) } {
return Err("bundleEntry failed".into());
}
// SAFETY: `bundleExit`/`BundleExit` is the VST3-mandated macOS exit with this
// signature; the pointer stays valid because loaded modules are never unmapped.
exit = unsafe { lib.get::<ModuleExit>(b"bundleExit\0") }
.or_else(|_| {
// SAFETY: as above (older SDKs export the capitalised name).
unsafe { lib.get::<ModuleExit>(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<Arc<Bundle>, 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<Bundle, Vst3Error> {
fn open(path: &Path, entered_on: EntryThread) -> Result<Bundle, Vst3Error> {
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::<GetFactory>(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::<sv::IAudioProcessor>().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<sv::IComponent>,
host: ComWrapper<HostContext>,
_bundle: Arc<Bundle>,
bundle: Arc<Bundle>,
}
// 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
}
}

View File

@ -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<Vst3Descriptor> {
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<Vec<Vst3Descriptor>, Vst3Error> {
api::load_bundle(path)

View File

@ -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");

View File

@ -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::<IPlugView>() {
match ComWrapper::new(View { attached: std::sync::atomic::AtomicBool::new(false), _live: Live::new(&OBJECTS) }).to_com_ptr::<IPlugView>() {
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::<FUnknown>()
ComWrapper::new(GainProcessor {
gain: AtomicU64::new(0.25f64.to_bits()),
arrangement: AtomicU64::new(SpeakerArr::kStereo),
_live: Live::new(&OBJECTS),
})
.to_com_ptr::<FUnknown>()
} 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::<FUnknown>()
ComWrapper::new(GainController {
gain: AtomicU64::new(0.5f64.to_bits()),
freq: AtomicU64::new(0.5f64.to_bits()),
_live: Live::new(&OBJECTS),
})
.to_com_ptr::<FUnknown>()
} else if cid == SYNTH_CID {
ComWrapper::new(Synth { level: AtomicU32::new(0) }).to_com_ptr::<FUnknown>()
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} 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::<IPluginFactory>() {
match ComWrapper::new(Factory { _live: Live::new(&FACTORIES) }).to_com_ptr::<IPluginFactory>() {
Some(p) => p.into_raw(),
None => std::ptr::null_mut(),
}

View File

@ -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)]);

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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");
}

View File

@ -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,

View File

@ -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. |