Merge origin/main into feat/inspect-source-loaded
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
e2bfdd5b73
|
|
@ -9,3 +9,4 @@ plan/
|
|||
/target-*/
|
||||
/craft-fonts/
|
||||
plans/
|
||||
.DS_Store
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
37
README.md
37
README.md
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¶ms) {
|
||||
Ok(v) => outln!("{id}: {v}"),
|
||||
|
|
@ -270,7 +303,13 @@ fn app(args: &[String]) -> ExitCode {
|
|||
.map(|(_, a)| a)
|
||||
.collect();
|
||||
let Some(method) = positional.first() else { return fail("app [--port P] METHOD [JSON]") };
|
||||
let params = positional.get(1).and_then(|p| serde_json::from_str::<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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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…</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¬e);
|
||||
}
|
||||
}
|
||||
|
||||
/// There will be no log file: forget the lines kept for it and keep no more.
|
||||
pub fn no_file(&mut self) {
|
||||
self.file = None;
|
||||
self.pending = Vec::new();
|
||||
self.dropped = 0;
|
||||
self.capped = true;
|
||||
}
|
||||
|
||||
fn append(&mut self, line: &str) {
|
||||
if self.capped {
|
||||
return;
|
||||
}
|
||||
let Some(file) = self.file.as_mut() else { return };
|
||||
let len = u64::try_from(line.len()).unwrap_or(u64::MAX);
|
||||
let text = if self.written.saturating_add(len) > self.max_bytes {
|
||||
self.capped = true;
|
||||
format!("log file reached {} bytes; later records go to standard error only\n", self.max_bytes)
|
||||
} else {
|
||||
self.written = self.written.saturating_add(len);
|
||||
line.to_owned()
|
||||
};
|
||||
// A full disk or a vanished file must not take the app down: drop the file sink (and
|
||||
// stop buffering, since no file will come).
|
||||
if file.write_all(text.as_bytes()).is_err() {
|
||||
self.file = None;
|
||||
self.capped = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text formatted into a fixed buffer: no allocation, cut at a character boundary when full.
|
||||
struct Bounded<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(×tamp(h.at), h.level, AUDIO_THREAD, h.target.as_str(), &message));
|
||||
}
|
||||
let more = records.saturating_sub(usize::from(held.is_some()));
|
||||
if more > 0 {
|
||||
let thread = std::thread::current();
|
||||
let note = format!("{more} more log records from the realtime audio thread were not written (only the first is kept)");
|
||||
lines.push_str(&format_line(×tamp(SystemTime::now()), log::Level::Warn, thread.name().unwrap_or("?"), module_path!(), ¬e));
|
||||
}
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// The installed logger: a [`Filter`], standard error and a [`Sink`].
|
||||
pub struct AppLogger {
|
||||
filter: Filter,
|
||||
stderr: bool,
|
||||
sink: Mutex<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(×tamp(SystemTime::now()), record.level(), thread.name().unwrap_or("?"), record.target(), &record.args().to_string());
|
||||
self.emit(&line);
|
||||
}
|
||||
|
||||
fn flush(&self) {}
|
||||
}
|
||||
|
||||
/// Install the logger (filter from `RUST_LOG`, else [`DEFAULT_FILTER`]); `None` when another
|
||||
/// logger was installed first. Call [`AppLogger::attach_dir`] or [`AppLogger::no_file`] once the
|
||||
/// settings directory is known.
|
||||
pub fn install() -> Option<&'static AppLogger> {
|
||||
static LOGGER: OnceLock<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)>);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}`")]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({}));
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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]`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
Loading…
Reference in New Issue