diff --git a/Cargo.lock b/Cargo.lock index 153861a..3228925 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3424,7 +3424,7 @@ dependencies = [ [[package]] name = "soundcraft" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -3442,7 +3442,7 @@ dependencies = [ [[package]] name = "soundcraft-au-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "serde", @@ -3452,7 +3452,7 @@ dependencies = [ [[package]] name = "soundcraft-audio-io" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3464,7 +3464,7 @@ dependencies = [ [[package]] name = "soundcraft-automation" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "serde_json", @@ -3474,7 +3474,7 @@ dependencies = [ [[package]] name = "soundcraft-clap-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "clap-sys", "libloading", @@ -3486,7 +3486,7 @@ dependencies = [ [[package]] name = "soundcraft-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", "soundcraft-audio-io", @@ -3497,7 +3497,7 @@ dependencies = [ [[package]] name = "soundcraft-dsp" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3508,7 +3508,7 @@ dependencies = [ [[package]] name = "soundcraft-engine" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "proptest", @@ -3530,7 +3530,7 @@ dependencies = [ [[package]] name = "soundcraft-midi" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3540,7 +3540,7 @@ dependencies = [ [[package]] name = "soundcraft-mix" -version = "0.6.0" +version = "0.6.1" dependencies = [ "log", "rayon", @@ -3556,7 +3556,7 @@ dependencies = [ [[package]] name = "soundcraft-model" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3569,7 +3569,7 @@ dependencies = [ [[package]] name = "soundcraft-playback" -version = "0.6.0" +version = "0.6.1" dependencies = [ "cpal", "log", @@ -3582,7 +3582,7 @@ dependencies = [ [[package]] name = "soundcraft-time" -version = "0.6.0" +version = "0.6.1" dependencies = [ "proptest", "serde", @@ -3592,7 +3592,7 @@ dependencies = [ [[package]] name = "soundcraft-ui-egui" -version = "0.6.0" +version = "0.6.1" dependencies = [ "craft-ui", "egui", @@ -3618,7 +3618,7 @@ dependencies = [ [[package]] name = "soundcraft-video" -version = "0.6.0" +version = "0.6.1" dependencies = [ "image", "proptest", @@ -3629,7 +3629,7 @@ dependencies = [ [[package]] name = "soundcraft-vst3-host" -version = "0.6.0" +version = "0.6.1" dependencies = [ "libloading", "log", @@ -3644,7 +3644,7 @@ dependencies = [ [[package]] name = "soundcraft-web" -version = "0.6.0" +version = "0.6.1" dependencies = [ "eframe", "egui", @@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.6.0" +version = "0.6.1" dependencies = [ "serde_json", ] diff --git a/Cargo.toml b/Cargo.toml index 02b36ac..a09510b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.6.0" +version = "0.6.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" diff --git a/README.md b/README.md index 1d5f86a..d88fee8 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,9 @@ soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}' # Give Claude a DAW. claude mcp add soundcraft -- soundcraft-cli mcp --demo + +# Or keep the agent to some folders (reads and writes are separate roots). +soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR ``` See [`docs/control-protocol.md`](docs/control-protocol.md) and [`docs/mcp.md`](docs/mcp.md). @@ -252,7 +255,7 @@ very welcome, in the issues or on Discord. ## Downloads -**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. +**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) have earlier versions and their notes. `` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file. ### Windows diff --git a/ROADMAP.md b/ROADMAP.md index 25ae055..bab0d92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -122,6 +122,9 @@ mutated and truncated movies. Field reports from the first public week are track ## Progress log +- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256) +- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248) +- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249) - **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change. - **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/ @@ -143,6 +146,9 @@ mutated and truncated movies. Field reports from the first public week are track | Date | Change | Summary | |---|---|---| +| 2026-10-12 | trivial | Progress log: convert option validation (#256) | +| 2026-10-12 | trivial | Progress log: timecode overflow (#248) | +| 2026-10-12 | trivial | Progress log: bar offset saturates (#249) | | 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h | | 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers | | 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added | diff --git a/apps/soundcraft-cli/src/automation_args.rs b/apps/soundcraft-cli/src/automation_args.rs new file mode 100644 index 0000000..f874226 --- /dev/null +++ b/apps/soundcraft-cli/src/automation_args.rs @@ -0,0 +1,90 @@ +//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed +//! flag must not silently remove a root and leave the server unconfined. + +use std::path::PathBuf; + +pub struct AutomationArgs { + pub read_root: Option, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> Self { + Self { read_root, write_root, read_given: false, write_given: false } + } + + /// Returns whether this argument is a root flag. A second occurrence of a flag is an error, + /// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed. + pub fn consume(&mut self, arg: &str, args: &mut impl Iterator) -> Result { + let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value))); + let (root, given) = match flag { + "--automation-read-root" => (&mut self.read_root, &mut self.read_given), + "--automation-write-root" => (&mut self.write_root, &mut self.write_given), + _ => return Ok(false), + }; + if *given { + return Err(format!("{flag} given twice")); + } + let value = match inline { + Some(value) => value.to_owned(), + None => { + let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?; + if value.starts_with('-') { + return Err(format!("{flag} needs a folder; use {flag}= for a folder starting with '-'")); + } + value + } + }; + if value.is_empty() { + return Err(format!("{flag} needs a non-empty folder")); + } + *root = Some(value.into()); + *given = true; + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roots_are_taken_once_and_other_arguments_are_left() { + let mut roots = AutomationArgs::new(None, None); + let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter(); + assert!(roots.consume("--automation-read-root", &mut args).unwrap()); + assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); + assert!(!roots.consume("--demo", &mut args).unwrap()); + assert_eq!(roots.read_root, Some("a folder".into())); + assert_eq!(roots.write_root, Some("out".into())); + assert_eq!(args.next().as_deref(), Some("song.scraft")); + assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); + assert_eq!(roots.read_root, Some("a folder".into())); + } + + #[test] + fn invalid_operands_leave_roots_unchanged() { + for flag in ["--automation-read-root", "--automation-write-root"] { + for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { + let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into())); + let mut args = operand.map(str::to_owned).into_iter(); + assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); + assert_eq!(roots.read_root, Some("read".into())); + assert_eq!(roots.write_root, Some("write".into())); + } + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err()); + } + } + + #[test] + fn explicit_dash_folder_and_relative_dash_folder_are_valid() { + let mut roots = AutomationArgs::new(None, None); + assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap()); + assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap()); + assert_eq!(roots.read_root, Some("-folder".into())); + assert_eq!(roots.write_root, Some("./-folder".into())); + } +} diff --git a/apps/soundcraft-cli/src/main.rs b/apps/soundcraft-cli/src/main.rs index bb7628a..9a51cc5 100644 --- a/apps/soundcraft-cli/src/main.rs +++ b/apps/soundcraft-cli/src/main.rs @@ -8,15 +8,23 @@ //! soundcraft-cli commands [FILTER] list commands //! soundcraft-cli describe ID one command's details //! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel -//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio +//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR] +//! MCP server on stdio. The two roots confine every file the +//! agent's commands read (open, import, presets) and write +//! (save, bounce, export) to those folders, links followed; +//! a root left out grants none of its access. They run the +//! in-process server, so they conflict with --connect. //! soundcraft-cli parity [--write PATH] parity report (markdown) //! soundcraft-cli plugins list built-in plugins //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod automation_args; + use serde_json::{Value, json}; use soundcraft_automation::{Backend, Headless, Remote, mcp::Server}; use soundcraft_engine::Engine; +use soundcraft_engine::file_access::{self, AutomationRoots}; use std::io::Write; use std::process::ExitCode; @@ -114,6 +122,14 @@ fn info(args: &[String]) -> ExitCode { fn convert(args: &[String]) -> ExitCode { let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") }; + let rate = match parse_rate(arg_value(args, "--rate").as_deref()) { + Ok(r) => r, + Err(e) => return fail(e), + }; + let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) { + Ok(b) => b, + Err(e) => return fail(e), + }; let bytes = match std::fs::read(input) { Ok(b) => b, Err(e) => return fail(format!("{input}: {e}")), @@ -123,7 +139,7 @@ fn convert(args: &[String]) -> ExitCode { Ok(x) => x, Err(e) => return fail(format!("{input}: {e}")), }; - if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::().ok()) { + if let Some(rate) = rate { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate); buf.sample_rate = rate; } @@ -134,11 +150,6 @@ fn convert(args: &[String]) -> ExitCode { 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, - Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32, - _ => soundcraft_audio_io::BitDepth::Int24, - }; let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None }; match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) { Ok(()) => { @@ -149,6 +160,25 @@ fn convert(args: &[String]) -> ExitCode { } } +fn parse_rate(v: Option<&str>) -> Result, String> { + match v { + None => Ok(None), + Some(r) => match r.parse::() { + Ok(hz) if hz > 0 => Ok(Some(hz)), + _ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")), + }, + } +} + +fn parse_bit_depth(v: Option<&str>) -> Result { + match v { + None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24), + Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16), + Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32), + Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")), + } +} + /// One stderr line per media file a session refers to but could not load (the exit code is unchanged). fn missing_warnings(missing: &[String]) -> Vec { missing.iter().map(|m| format!("warning: missing media: {m}")).collect() @@ -356,15 +386,62 @@ fn app(args: &[String]) -> ExitCode { } } +/// What `mcp` was asked for. +struct McpArgs { + connect: Option, + demo: bool, + roots: Option>, +} + +/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the +/// server running unconfined. +fn parse_mcp_args(args: &[String]) -> Result { + let mut connect = None; + let mut demo = false; + let mut roots_args = automation_args::AutomationArgs::new(None, None); + let mut it = args.iter().cloned(); + while let Some(a) = it.next() { + if roots_args.consume(&a, &mut it)? { + continue; + } + match a.as_str() { + "--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?), + "--demo" => demo = true, + other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")), + _ => {} + } + } + let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?; + if roots.is_some() && connect.is_some() { + return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \ +so drop --connect (or use --connect without the roots)" + .into()); + } + Ok(McpArgs { connect, demo, roots }) +} + fn mcp(args: &[String]) -> ExitCode { + let McpArgs { connect, demo, roots } = match parse_mcp_args(args) { + Ok(a) => a, + Err(err) => return fail(err), + }; let stdin = std::io::stdin(); let stdout = std::io::stdout(); - let r = if let Some(port) = arg_value(args, "--connect") { + let r = if let Some(port) = connect { let mut s = Server::new(Remote::new(&port)); s.serve(stdin.lock(), stdout.lock()) } else { - let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; - let mut s = Server::new(Headless::new(e)); + // `--demo` is the operator's own choice, made before the roots come into force. + let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() }; + let mut h = Headless::new(e); + if let Some(roots) = roots { + // Every thread of this process, not only the server's: nothing reaches past the roots. + file_access::confine_process(roots.clone()); + let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string()); + eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root())); + h = h.with_automation_roots(Some(roots)); + } + let mut s = Server::new(h); s.serve(stdin.lock(), stdout.lock()) }; match r { @@ -422,6 +499,18 @@ mod tests { } } + #[test] + fn convert_rejects_invalid_rate_and_bit_depth() { + assert_eq!(parse_rate(None), Ok(None)); + assert_eq!(parse_rate(Some("44100")), Ok(Some(44_100))); + assert!(parse_rate(Some("4410x")).is_err()); + assert!(parse_rate(Some("0")).is_err()); + assert!(matches!(parse_bit_depth(None), Ok(soundcraft_audio_io::BitDepth::Int24))); + assert!(matches!(parse_bit_depth(Some("16")), Ok(soundcraft_audio_io::BitDepth::Int16))); + assert!(matches!(parse_bit_depth(Some("32f")), Ok(soundcraft_audio_io::BitDepth::Float32))); + assert!(parse_bit_depth(Some("8")).is_err()); + } + #[test] fn missing_media_gets_one_warning_each() { assert!(missing_warnings(&[]).is_empty()); @@ -446,6 +535,7 @@ mod tests { 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("--automation-read-root DIR") && help.contains("--automation-write-root DIR")); assert!(!help.contains("```")); assert!(!help.contains("#![") && !help.contains("use serde_json")); } diff --git a/apps/soundcraft-cli/tests/cli.rs b/apps/soundcraft-cli/tests/cli.rs new file mode 100644 index 0000000..ef2fb88 --- /dev/null +++ b/apps/soundcraft-cli/tests/cli.rs @@ -0,0 +1,140 @@ +//! `soundcraft-cli mcp` with the automation root flags, run as the real binary. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::io::Write; +use std::path::PathBuf; +use std::process::{Command, Output, Stdio}; + +use serde_json::Value; + +fn bin() -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")); + cmd.env("SOUNDCRAFT_NO_PREFS", "1"); + cmd +} + +fn run(args: &[&str]) -> Output { + bin().args(args).output().unwrap() +} + +fn tmpdir(tag: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("sc-cli-roots-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp `; returns whether it exited +/// cleanly, its stderr, and the stdout lines after `initialize`, one per call. +fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec) { + let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap(); + { + let stdin = child.stdin.as_mut().unwrap(); + writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); + for (i, (name, arguments)) in calls.iter().enumerate() { + let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}}); + writeln!(stdin, "{call}").unwrap(); + } + } + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect(); + (out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies) +} + +fn text(reply: &Value) -> &str { + reply["result"]["content"][0]["text"].as_str().unwrap() +} + +/// A short WAV made by the binary itself. +fn make_wav(path: &std::path::Path) { + let o = run(&["run", "--demo", "--bounce", path.to_str().unwrap(), "--start", "0", "--end", "0.1"]); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); +} + +#[test] +fn mcp_automation_roots_confine_the_server() { + let base = tmpdir("roots"); + let (work, elsewhere) = (base.join("work"), base.join("elsewhere")); + std::fs::create_dir_all(&work).unwrap(); + std::fs::create_dir_all(&elsewhere).unwrap(); + make_wav(&work.join("in.wav")); + make_wav(&elsewhere.join("secret.wav")); + let work_s = work.to_str().unwrap(); + let secret = elsewhere.join("secret.wav").to_str().unwrap().to_string(); + + // Both spellings of the flags; inside works, outside is refused with a readable message. + let read_flag = format!("--automation-read-root={work_s}"); + let calls = [ + ("import_audio", serde_json::json!({"path": work.join("in.wav").to_str().unwrap()})), + ("import_audio", serde_json::json!({"path": secret})), + ("save_session", serde_json::json!({"path": work.join("saved.scraft").to_str().unwrap()})), + ("save_session", serde_json::json!({"path": elsewhere.join("saved.scraft").to_str().unwrap()})), + ("bounce_mix", serde_json::json!({"path": elsewhere.join("mix.wav").to_str().unwrap()})), + ]; + let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls); + assert!(success, "{stderr}"); + assert!(stderr.contains("files confined"), "{stderr}"); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + assert_eq!(replies[1]["result"]["isError"], true); + assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1])); + assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]); + assert!(work.join("saved.scraft").exists()); + assert_eq!(replies[3]["result"]["isError"], true); + assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3])); + assert_eq!(replies[4]["result"]["isError"], true); + assert!(text(&replies[4]).contains("outside the write root"), "{}", text(&replies[4])); + assert!(!elsewhere.join("saved.scraft").exists() && !elsewhere.join("mix.wav").exists()); + + // A root left out grants none of its access. + let (_, _, replies) = mcp_session(&[&read_flag], &calls[2..3]); + assert_eq!(replies[0]["result"]["isError"], true); + assert!(text(&replies[0]).contains("write authority is absent"), "{}", text(&replies[0])); + let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[0..1]); + assert_eq!(replies[0]["result"]["isError"], true); + assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0])); + + // No flags: nothing is confined. + let (success, _, replies) = mcp_session(&[], &calls[1..2]); + assert!(success); + assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn mcp_automation_root_flags_are_checked_up_front() { + let base = tmpdir("roots-args"); + let dir = base.to_str().unwrap(); + let missing = base.join("missing"); + let fails = |args: &[&str], needle: &str| { + let mut full = vec!["mcp"]; + full.extend_from_slice(args); + let o = run(&full); + assert!(!o.status.success(), "{args:?}"); + let err = String::from_utf8_lossy(&o.stderr).into_owned(); + assert!(err.contains(needle), "{args:?}: {err}"); + }; + // The flags confine the in-process server, so they cannot be combined with --connect. + fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); + fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); + fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); + fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); + fails(&["--automation-read-root"], "needs a folder"); + fails(&["--automation-write-root", "--demo"], "needs a folder"); + fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root"); + fails(&["--automation-write-root="], "needs a non-empty folder"); + // A misspelled root flag must not start an unconfined server. + fails(&["--automation-read-roots", dir], "unknown mcp option"); + // A file is not a folder. + std::fs::write(base.join("f.txt"), "x").unwrap(); + fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root"); + let _ = std::fs::remove_dir_all(base); +} + +#[test] +fn usage_lists_the_root_flags() { + let o = run(&["--help"]); + let help = String::from_utf8_lossy(&o.stdout).into_owned(); + assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}"); +} diff --git a/apps/soundcraft-web/src/web.rs b/apps/soundcraft-web/src/web.rs index 02f1e13..90477cc 100644 --- a/apps/soundcraft-web/src/web.rs +++ b/apps/soundcraft-web/src/web.rs @@ -67,7 +67,8 @@ enum Purpose { } const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION]; -const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"]; +const AUDIO_EXTS: &[&str] = + &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"]; const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"]; impl Purpose { diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index 28723b7..1931d2c 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -162,7 +162,7 @@ fn services() -> Services { "file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]), _ => d.add_filter( "Audio", - &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"], + &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"], ), }; d.pick_file().map(|p| p.to_string_lossy().into_owned()) diff --git a/contributors/people.toml b/contributors/people.toml index bc8bd31..befac75 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -40,3 +40,10 @@ sync_github_name = true real_name = "Vikram Bhaskaran" display_name = "Vikram Bhaskaran" sync_github_name = false + +[people.webdevtodayjason] +display_name = "Jason Brashear" + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false diff --git a/crates/audio-io/src/caf.rs b/crates/audio-io/src/caf.rs new file mode 100644 index 0000000..fe4dda1 --- /dev/null +++ b/crates/audio-io/src/caf.rs @@ -0,0 +1,87 @@ +//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie, +//! a packet table or a packet claims, and a failed allocation aborts the process. + +use crate::pcm::{be_u16, be_u32, be_u64}; +use crate::{AudioError, Result}; + +/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes +/// than the file holds. +pub(crate) fn check(b: &[u8], at: usize) -> Result<()> { + if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) } +} + +fn malformed(what: &str) -> AudioError { + AudioError::Malformed(format!("CAF {what} is larger than the file")) +} + +/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by +/// the declared size, and after a `data` chunk of unknown size it reads on into the audio. +fn chunks(b: &[u8], mut pos: usize) -> Result<()> { + // Whether the packet table lists each packet's size and frame count (variable per packet). + let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false); + while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) { + let (body, size) = (pos + 12, size as i64); + let used = match kind { + b"desc" if size == 32 => { + lists_sizes = be_u32(b, body + 16) == Some(0); + lists_frames = be_u32(b, body + 20) == Some(0); + described = true; + Some(32) + } + b"data" if size == -1 => Some(4), + b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20), + b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?, + b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")), + b"desc" | b"pakt" | b"chan" => None, + _ => u64::try_from(size).ok(), + }; + match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) { + Some(next) if next < b.len() => pos = next, + _ => return Ok(()), + } + } + Ok(()) +} + +/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or +/// None where symphonia stops. It reserves room for every packet before reading any, so a table +/// it would refuse after that, or that runs past the file, is refused first. +fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result> { + let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0); + let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) }; + let mut at = body + 24; + if packets > b.len().saturating_sub(at) as u64 { + return Err(malformed("packet table")); + } + if !lists_sizes && !lists_frames { + return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) }; + } + for _ in 0..packets { + if lists_sizes { + let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) }; + if size > b.len() as u64 { + return Err(malformed("packet")); + } + at += len; + } + if lists_frames { + let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) }; + at += len; + } + } + Ok(Some((at - body) as u64)) +} + +/// A CAF variable-length integer at `at` and its length in bytes. +fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> { + let mut v = 0u64; + for i in 0..9 { + let x = *b.get(at + i)?; + v |= u64::from(x & 0x7f); + if x & 0x80 == 0 { + return Some((v, i + 1)); + } + v <<= 7; + } + None +} diff --git a/crates/audio-io/src/flac.rs b/crates/audio-io/src/flac.rs index 2f5550b..88b333e 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -1,8 +1,8 @@ //! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per //! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with -//! partitioned Rice-coded residuals. +//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`]. -use crate::pcm::{Quantizer, validate_buffer}; +use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer}; use crate::{AudioBuffer, AudioError, BitDepth, Result}; const BLOCK_SIZE: usize = 4096; @@ -354,6 +354,54 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result Ok(out) } +/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its +/// metadata block holds: symphonia allocates each field's length before reading it. +pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> { + let mut pos = at + 4; + while let Some(head) = be_u32(b, pos) { + let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize); + let end = (body + len).min(b.len()); + let overruns = match (head >> 24) & 0x7f { + 4 => comments_overrun(b, body, end), + // Picture: type, MIME type, description, four size fields, data. + 6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]), + _ => false, + }; + if overruns { + return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into())); + } + if head >> 31 == 1 { + break; + } + pos = body + len; + } + Ok(()) +} + +/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds. +/// The vendor string is skipped unread. +fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool { + let Some(vendor) = le_u32(b, body) else { return false }; + let at = (body + 4).saturating_add(vendor as usize); + let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; + overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize)) +} + +/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are +/// the bytes skipped before each length, which `read` reads. +fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option, gaps: impl IntoIterator) -> bool { + for gap in gaps { + at = at.saturating_add(gap); + let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false }; + at += 4; + if n as usize > end - at { + return true; + } + at += n as usize; + } + false +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 87241c6..20f6213 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -14,7 +14,9 @@ mod aiff; mod buffer; +mod caf; mod flac; +mod mp4; mod pcm; pub mod peaks; mod symph; @@ -187,7 +189,7 @@ fn detect_by_ext(ext: &str) -> FileFormat { "flac" => FileFormat::Flac, "mp3" | "mp2" | "mp1" => FileFormat::Mp3, "ogg" | "oga" => FileFormat::Ogg, - "aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac, + "aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac, "caf" => FileFormat::Caf, _ => FileFormat::Other, } @@ -205,16 +207,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result { FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? { wav::WavParse::Native(l) => Ok(l.info), wav::WavParse::Foreign { bwf } => { - let mut info = symph::probe(bytes, Some("wav"), format)?; + let mut info = symph::probe(bytes, format)?; info.bwf = bwf; Ok(info) } }, FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? { aiff::AiffParse::Native(l) => Ok(l.info), - aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format), + aiff::AiffParse::Foreign => symph::probe(bytes, format), }, - _ => symph::probe(bytes, ext_hint, format), + _ => symph::probe(bytes, format), } } @@ -230,7 +232,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB Ok((info, buf)) } wav::WavParse::Foreign { bwf } => { - let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?; + let (mut info, buf) = symph::decode(bytes, format)?; info.bwf = bwf; Ok((info, buf)) } @@ -242,9 +244,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB info.frames = buf.frames() as u64; Ok((info, buf)) } - aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format), + aiff::AiffParse::Foreign => symph::decode(bytes, format), }, - _ => symph::decode(bytes, ext_hint, format), + _ => symph::decode(bytes, format), } } diff --git a/crates/audio-io/src/mp4.rs b/crates/audio-io/src/mp4.rs new file mode 100644 index 0000000..06e68dd --- /dev/null +++ b/crates/audio-io/src/mp4.rs @@ -0,0 +1,106 @@ +//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample +//! table or a sample claims, and a failed allocation aborts the process. + +use crate::pcm::{be_u32, be_u64}; +use crate::{AudioError, Result}; + +#[derive(Clone, Copy)] +enum Level { + Top, + Moov, + Trak, + Mdia, + Minf, + Stbl, + Udta, + Meta, + Ilst, + Tag, +} + +/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a +/// sample table or a sample claims more bytes than the file holds. +pub(crate) fn check(b: &[u8], at: usize) -> Result<()> { + atoms(b, at, b.len(), Level::Top).map(|_| ()) +} + +fn malformed(what: &str) -> AudioError { + AudioError::Malformed(format!("MP4 {what} is larger than the file")) +} + +/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where +/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`. +fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result { + // A header that starts inside the container is read whole, even past its end. + while pos < end { + let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) }; + let head = if size == 1 { 16 } else { 8 }; + // Size 0: no data, and the next atom starts at the container's end. + let len = match size { + 0 => head, + 1 => be_u64(b, pos + 8).unwrap_or(0), + n => u64::from(n), + }; + if len < head { + return Ok(false); + } + let body = pos + head as usize; + let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n)); + let next = if size == 0 { Some(end) } else { data_end }; + let inner = data_end.map_or(b.len(), |n| n.min(b.len())); + let more = match (level, kind) { + (Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?, + (Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?, + (Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?, + (Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?, + (Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?, + (Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?, + (Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?, + (Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?, + (Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?, + (Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?, + (Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?, + (Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?, + (Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?, + (Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => { + return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind)))); + } + _ => true, + }; + match next { + Some(n) if more && n <= end => pos = n, + _ => return Ok(false), + } + } + Ok(true) +} + +/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for +/// all of them first. False if they run past the table's atom (`end`), where symphonia stops. +fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result { + let Some(n) = be_u32(b, count) else { return Ok(false) }; + let (first, bytes) = (count + 4, u64::from(n) * entry); + if bytes > b.len().saturating_sub(first) as u64 { + return Err(malformed("sample table")); + } + Ok(first as u64 + bytes <= end as u64) +} + +/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size. +/// Sizes past the atom are never used, as symphonia stops before reading a packet. +fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result { + let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) }; + let file = b.len() as u64; + if u64::from(size) > file { + return Err(malformed("sample")); + } + if size != 0 { + return Ok(true); + } + let fits = table(b, body + 8, 4, end)?; + let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>(); + if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) { + return Err(malformed("sample")); + } + Ok(fits) +} diff --git a/crates/audio-io/src/pcm.rs b/crates/audio-io/src/pcm.rs index a1fe894..a998fec 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -36,6 +36,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option { Some(u32::from_be_bytes(s)) } +/// Big-endian `u64` at `pos`. +pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option { + let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?; + Some(u64::from_be_bytes(s)) +} + /// Four-byte tag at `pos`. pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> { b.get(pos..pos.checked_add(4)?)?.try_into().ok() diff --git a/crates/audio-io/src/symph.rs b/crates/audio-io/src/symph.rs index 8fa1916..6169111 100644 --- a/crates/audio-io/src/symph.rs +++ b/crates/audio-io/src/symph.rs @@ -12,13 +12,17 @@ use symphonia::core::codecs::{ }; use symphonia::core::errors::Error as SymError; use symphonia::core::formats::{FormatOptions, FormatReader}; -use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions}; +use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes}; use symphonia::core::meta::MetadataOptions; -use symphonia::core::probe::Hint; +use symphonia::core::probe::Instantiate; -use crate::pcm::MAX_DECODE_SAMPLES; +use crate::pcm::{MAX_DECODE_SAMPLES, be_u32}; use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; +/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC +/// takes its frame length from the file (encoders use 4096). +const MAX_PACKET_FRAMES: u64 = 1 << 20; + fn map_err(e: SymError) -> AudioError { match e { SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()), @@ -32,15 +36,36 @@ fn guarded(f: impl FnOnce() -> Result) -> Result { catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into()))) } -fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result> { - let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); - let mut hint = Hint::new(); - if let Some(ext) = ext_hint { - hint.with_extension(ext.trim_start_matches('.')); - } +/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it +/// finds before its reader allocates whatever the file claims. +fn open(bytes: &[u8]) -> Result> { + let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; - let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?; - Ok(probed.format) + loop { + let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?; + let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); + let head = bytes.get(at..).unwrap_or_default(); + match (found, head.get(..10)) { + (Instantiate::Format(reader), _) => { + match head.get(..4) { + Some(b"caff") => crate::caf::check(bytes, at)?, + Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?, + Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?, + Some(b"RIFF") => crate::wav::check_info(bytes, at)?, + _ => {} + } + return reader(mss, &fmt_opts).map_err(map_err); + } + // Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it. + (Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => { + let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f)); + mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?; + } + (Instantiate::Metadata(tag), _) => { + tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?; + } + } + } } fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat { @@ -115,8 +140,8 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi } /// Decode the whole first audio track. When `count_only`, samples are counted but not stored. -fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { - let mut reader = open(bytes, ext_hint)?; +fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { + let mut reader = open(bytes)?; let (track_id, mut info) = info_of(reader.as_ref(), detected)?; if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { let total = n.saturating_mul(u64::from(info.channels.max(1))); @@ -130,6 +155,10 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b .find(|t| t.id == track_id) .map(|t| t.codec_params.clone()) .ok_or_else(|| AudioError::Malformed("track vanished".into()))?; + let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0)); + if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { + return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); + } let mut decoder = symphonia::default::get_codecs().make(¶ms, &DecoderOptions::default()).map_err(map_err)?; let mut planar: Vec> = Vec::new(); @@ -206,18 +235,18 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b Ok((info, AudioBuffer { sample_rate, channels: planar })) } -pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result { +pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result { guarded(|| { - let reader = open(bytes, ext_hint)?; + let reader = open(bytes)?; let (_, info) = info_of(reader.as_ref(), detected)?; if info.frames > 0 && info.channels > 0 { return Ok(info); } // Length not in the header (e.g. MP3 without a Xing frame): count by decoding. - run(bytes, ext_hint, detected, true).map(|(i, _)| i) + run(bytes, detected, true).map(|(i, _)| i) }) } -pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { - guarded(|| run(bytes, ext_hint, detected, false)) +pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> { + guarded(|| run(bytes, detected, false)) } diff --git a/crates/audio-io/src/wav.rs b/crates/audio-io/src/wav.rs index df56010..d6ce7cd 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -83,6 +83,57 @@ fn parse_bext(c: &[u8]) -> Option { }) } +/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry +/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are +/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length. +pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> { + if tag(b, at + 8) != Some(*b"WAVE") { + return Ok(()); + } + let mut pos = at + 12; + while let Some(id) = tag(b, pos) { + let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break }; + let (body, pad) = (pos + 8, len & 1); + let used = match &id { + b"data" => break, + b"fmt " => fmt_used(b, body, len), + b"LIST" if len < 4 => break, + b"LIST" => { + let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 }; + // The list pads itself, then the chunk is padded again. + 4 + entries.max(len - 4) + pad + } + _ => len, + }; + pos = body.saturating_add(used).saturating_add(pad); + } + Ok(()) +} + +/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size. +fn fmt_used(b: &[u8], body: usize, len: usize) -> usize { + match le_u16(b, body) { + Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)), + Some(0xfffe) => 40, + _ => len, + } +} + +/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of +/// them, with the pad it reads after an odd entry. +fn info_entries(b: &[u8], start: usize, len: usize) -> Result { + let mut used = 0; + while used + 8 <= len { + let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break }; + if n > b.len().saturating_sub(start + used + 8) { + return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into())); + } + used += 8 + n; + used += used & 1; + } + Ok(used) +} + /// True when the bytes start like a RIFF/RF64/BW64 WAVE file. pub(crate) fn is_wav(b: &[u8]) -> bool { matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE") diff --git a/crates/audio-io/tests/malformed.rs b/crates/audio-io/tests/malformed.rs index 1cec656..ed0d6e3 100644 --- a/crates/audio-io/tests/malformed.rs +++ b/crates/audio-io/tests/malformed.rs @@ -153,6 +153,194 @@ fn absurd_chunk_sizes() { assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0); } +fn caf(chunks: &[(&[u8; 4], i64, Vec)]) -> Vec { + let mut out = b"caff\0\x01\0\0".to_vec(); + for (kind, size, body) in chunks { + out.extend_from_slice(*kind); + out.extend_from_slice(&size.to_be_bytes()); + out.extend_from_slice(body); + } + out +} + +fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec) { + let mut d = 48_000f64.to_be_bytes().to_vec(); + d.extend_from_slice(format); + for v in [0, bytes_per_packet, frames_per_packet, channels, bits] { + d.extend_from_slice(&v.to_be_bytes()); + } + (b"desc", 32, d) +} + +fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec { + [&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat() +} + +#[test] +fn caf_sizes_beyond_the_file_are_errors_not_aborts() { + let aac = || caf_desc(b"aac ", 0, 1024, 2, 0); + let huge = 1i64 << 50; + let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]); + let files = [ + cookie.clone(), + [vec![0; 100], cookie].concat(), + caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]), + caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]), + caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]), + caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]), + ]; + for f in files { + assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } + let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]); + assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets"))); +} + +#[test] +fn well_formed_caf_still_reads() { + let pcm: Vec = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect(); + let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat(); + let data = [vec![0; 4], pcm].concat(); + let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]); + let (info, buf) = decode(&lpcm, None).unwrap(); + assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480)); + let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]); + let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]); + assert_eq!(probe(&aac, None).unwrap().frames, 3072); +} + +fn atom(kind: &[u8; 4], body: &[u8]) -> Vec { + [&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat() +} + +/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms. +fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec { + let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0]; + atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat()) +} + +/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom. +fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec) -> Vec) -> Vec { + let full = |kind: &[u8; 4], fields: &[u32]| { + let body: Vec = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect(); + swap(kind, atom(kind, &body)) + }; + let moov = |offset: u32| { + let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat()); + let stbl = + [stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])]; + let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat(); + let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)]; + let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat(); + atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat()) + }; + let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom"); + let offset = (ftyp.len() + moov(0).len() + 8) as u32; + let pcm: Vec = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect(); + [ftyp, moov(offset), atom(b"mdat", &pcm)].concat() +} + +#[test] +fn mp4_sizes_beyond_the_file_are_errors_not_aborts() { + let pcm = sample_entry(b"sowt", 0, &[], &[]); + let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap(); + assert_eq!(buf.frames(), 480); + let huge = |kind: &[u8; 4], a: Vec| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat(); + // The first field after version and flags: stts's entry count, stsz's sample size. + let first_field_max = |mut a: Vec| { + a[12..16].copy_from_slice(&u32::MAX.to_be_bytes()); + a + }; + let sizes: Vec = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect(); + let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat()); + // A header that starts inside its container is read whole, past the container's end. + let mdia = atom(b"mdia", &1u32.to_be_bytes()); + let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat(); + let files = [ + mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }), + mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }), + [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(), + ]; + for f in files { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + +#[test] +fn atoms_running_past_their_parent_are_walked_once() { + // Each moov holds a trak that runs to the end of the file; symphonia stops after the first. + let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat()); + let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat(); + assert!(decode(&f, None).is_err()); +} + +#[test] +fn packets_longer_than_any_codec_uses_are_refused() { + let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::>(); + let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat(); + let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]); + let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat(); + let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames))); + let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap(); + assert_eq!(buf.frames(), 480); + for entry in [lpcm(1 << 21), alac(1 << 21)] { + assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets"))); + } +} + +#[test] +fn flac_metadata_fields_beyond_their_block_are_refused() { + let with_block = |kind: u8, body: &[u8]| { + let f = small(FileFormat::Flac, BitDepth::Int16, 1); + let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes(); + [&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat() + }; + let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat(); + assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300); + let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat(); + for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] { + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block"))); + } +} + +#[test] +fn an_id3_tag_before_the_audio_is_skipped_unread() { + let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat(); + let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat(); + let flac = small(FileFormat::Flac, BitDepth::Int16, 1); + assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300); +} + +#[test] +fn wav_info_entries_beyond_the_file_are_refused() { + let alaw = |len: u32| { + let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat(); + riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]) + }; + assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800); + let mut f = alaw(0x7fff_fff0); + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + let list = f.windows(4).position(|w| w == b"LIST").unwrap(); + f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); +} + +#[test] +fn wav_info_entries_are_found_where_symphonia_reads_them() { + let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); + let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat()); + // A-law's extra bytes are read past its chunk; an odd list is padded twice. + let files = + [riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()]; + for mut f in files { + f[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); + } +} + #[test] fn many_tiny_chunks_terminate() { let mut chunks: Vec<(&[u8; 4], Vec)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))]; @@ -240,6 +428,7 @@ fn detect_format_by_magic_and_hint() { assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav); assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff); assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac); + assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac); assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac); assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other); assert_eq!(detect_format(b"zz", None), FileFormat::Other); diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 84d4e23..17c3a5b 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -11,8 +11,10 @@ pub mod mcp; use serde_json::{Value, json}; use soundcraft_engine::Engine; +use soundcraft_engine::file_access::{self, AutomationRoots}; use std::io::{BufRead, BufReader, Write}; use std::net::TcpStream; +use std::sync::Arc; use std::time::Duration; /// Answers control-protocol methods. @@ -26,16 +28,22 @@ pub trait Backend { /// An in-process engine (no UI). pub struct Headless { pub engine: Engine, + roots: Option>, } impl Headless { pub fn new(engine: Engine) -> Self { - Headless { engine } + Headless { engine, roots: None } } -} -impl Backend for Headless { - fn call(&mut self, method: &str, params: Value) -> Result { + /// Confine every file the session's commands read and write to `roots` + /// (`soundcraft-cli mcp --automation-read-root … --automation-write-root …`). + pub fn with_automation_roots(mut self, roots: Option>) -> Self { + self.roots = roots; + self + } + + fn answer(&mut self, method: &str, params: Value) -> Result { match method { "engine.execute" | "command" => { let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?; @@ -56,6 +64,13 @@ impl Backend for Headless { other => Err(format!("unknown method `{other}`")), } } +} + +impl Backend for Headless { + fn call(&mut self, method: &str, params: Value) -> Result { + let roots = self.roots.clone(); + file_access::confine(roots.as_ref(), || self.answer(method, params)) + } fn has_ui(&self) -> bool { false } diff --git a/crates/automation/tests/automation_roots.rs b/crates/automation/tests/automation_roots.rs new file mode 100644 index 0000000..2c4fa16 --- /dev/null +++ b/crates/automation/tests/automation_roots.rs @@ -0,0 +1,352 @@ +//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads +//! stays inside the read root, every file it writes inside the write root. +// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use serde_json::{Value, json}; +use soundcraft_automation::{Headless, mcp::Server}; +use soundcraft_engine::Engine; +use soundcraft_engine::file_access::AutomationRoots; +use std::path::{Path, PathBuf}; + +/// A marker that must never come back from a file outside the read root. +const SECRET: &str = "TOP-SECRET-MARKER-5f3a"; + +struct Fixture { + base: PathBuf, + read: PathBuf, + write: PathBuf, + outside: PathBuf, +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } +} + +fn s(p: impl AsRef) -> String { + p.as_ref().to_string_lossy().into_owned() +} + +/// `in/` (read root), `out/` (write root) and `outside/`, each holding the same real files: +/// a session with audio, a WAV, a MIDI file, a track preset and a clip group file. `outside/` +/// also holds text files holding [`SECRET`] under the names a command might open. +fn fixture(name: &str) -> Fixture { + let base = std::env::temp_dir().join(format!("sc-roots-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + let (read, write, outside) = (base.join("in"), base.join("out"), base.join("outside")); + for d in [&read, &write, &outside] { + std::fs::create_dir_all(d).unwrap(); + } + // Real files, made by an unconfined engine. + let mut e = soundcraft_engine::demo::demo_engine(); + for dir in [&read, &outside] { + let run = |e: &mut Engine, id: &str, p: Value| e.execute(id, &p).unwrap_or_else(|err| panic!("{id}: {err}")); + run(&mut e, "file.bounce_mix", json!({"path": s(dir.join("tone.wav")), "start": 0, "end": 4800})); + run(&mut e, "file.export_midi", json!({"path": s(dir.join("keys.mid")), "tracks": ["Keys"]})); + run(&mut e, "edit.select", json!({"tracks": ["Bass"]})); + run(&mut e, "track.save_preset", json!({"path": s(dir.join("bass.json"))})); + run(&mut e, "session.save_as", json!({"path": s(dir.join("song.scraft")), "replace": true})); + let clips = e.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect::>(); + run(&mut e, "file.export_clip_groups", json!({"path": s(dir.join("group.scgrp")), "clips": clips})); + } + for f in ["secret.wav", "secret.mid", "secret.scraft", "secret.json", "secret.scgrp", "secret.mp4"] { + std::fs::write(outside.join(f), SECRET).unwrap(); + } + Fixture { base, read, write, outside } +} + +fn server(read: Option<&Path>, write: Option<&Path>) -> Server { + let roots = AutomationRoots::new(read, write).unwrap(); + Server::new(Headless::new(Engine::default()).with_automation_roots(roots)) +} + +/// One `execute` call: whether it failed and the text of the result. +fn run(s: &mut Server, command: &str, params: Value) -> (bool, String) { + let r = s + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "execute", "arguments": {"command": command, "params": params}}})) + .unwrap(); + let res = &r["result"]; + (res["isError"].as_bool().unwrap(), res["content"][0]["text"].as_str().unwrap().to_string()) +} + +fn ok(s: &mut Server, command: &str, params: Value) -> String { + let (failed, text) = run(s, command, params); + assert!(!failed, "{command} failed: {text}"); + text +} + +fn refused(s: &mut Server, command: &str, params: Value, needle: &str) { + let (failed, text) = run(s, command, params.clone()); + assert!(failed, "{command} {params} must be refused, got {text}"); + assert!(text.contains(needle), "{command}: {text}"); + assert!(!text.contains(SECRET), "{command} leaked file contents: {text}"); +} + +/// Every file under `dir`, to show nothing was written. +fn listing(dir: &Path) -> Vec { + let mut out = vec![]; + for entry in std::fs::read_dir(dir).unwrap().flatten() { + let p = entry.path(); + if p.is_dir() { + out.extend(listing(&p)); + } + out.push(s(&p)); + } + out.sort(); + out +} + +fn inspect(s: &mut Server) -> Value { + let r = s + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "inspect_session", "arguments": {"detail": "full"}}})) + .unwrap(); + serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap() +} + +/// Every command that reads a user-named file, with params for a file in `dir`. +fn reads(dir: &Path, name: &dyn Fn(&str) -> String) -> Vec<(&'static str, Value)> { + let p = |f: &str| s(dir.join(name(f))); + vec![ + ("session.open", json!({"path": p("song.scraft")})), + ("file.import_audio", json!({"path": p("tone.wav")})), + ("file.import_audio", json!({"paths": [p("tone.wav")]})), + ("file.import_midi", json!({"path": p("keys.mid")})), + ("file.import_video", json!({"path": p("movie.mp4")})), + ("file.import_clip_groups", json!({"path": p("group.scgrp")})), + ("file.import_session_data", json!({"path": p("song.scraft")})), + ("track.load_preset", json!({"path": p("bass.json"), "tracks": ["Bass"]})), + ] +} + +#[test] +fn reads_outside_the_read_root_are_refused_and_nothing_reaches_the_result() { + let f = fixture("reads"); + let mut srv = server(Some(&f.read), Some(&f.write)); + ok(&mut srv, "session.new", json!({"name": "Work"})); + ok(&mut srv, "track.new", json!({"name": "Bass", "count": 1})); + ok(&mut srv, "track.new", json!({"name": "Pads", "count": 1, "kind": "instrument"})); + let before = inspect(&mut srv); + // The secret files hold the marker under the names the commands read, so a read would show. + let secret_name = |f: &str| format!("secret.{}", f.rsplit('.').next().unwrap()); + for (command, params) in reads(&f.outside, &secret_name) { + refused(&mut srv, command, params, "outside the read root"); + } + refused(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.outside.join("secret.wav"))}), "outside the read root"); + // The open session is exactly as it was: nothing was imported or opened. + assert_eq!(inspect(&mut srv), before); + // The same commands work on files inside the root. + ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); + ok(&mut srv, "file.import_midi", json!({"path": s(f.read.join("keys.mid"))})); + ok(&mut srv, "file.import_clip_groups", json!({"path": s(f.read.join("group.scgrp"))})); + ok(&mut srv, "file.import_session_data", json!({"path": s(f.read.join("song.scraft")), "tracks": ["Kick"]})); + ok(&mut srv, "track.load_preset", json!({"path": s(f.read.join("bass.json")), "tracks": ["Bass"]})); + ok(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.read.join("tone.wav"))})); + // A missing movie inside the root is an ordinary error, not a refusal. + let (failed, text) = run(&mut srv, "file.import_video", json!({"path": s(f.read.join("movie.mp4"))})); + assert!(failed && !text.contains("read root"), "{text}"); + ok(&mut srv, "session.open", json!({"path": s(f.read.join("song.scraft"))})); + // The typed tools take the same path through the same checks. + let r = srv + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "open_session", "arguments": {"path": s(f.outside.join("secret.scraft"))}}})) + .unwrap(); + assert_eq!(r["result"]["isError"], true); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root")); +} + +/// A clip of the demo session. +fn kick_clip() -> u64 { + soundcraft_engine::demo::demo_engine().session().track_by_name("Kick").unwrap().clips()[0].id.0 +} + +/// Every command that writes a user-named file or folder, with params for `dir`. +fn writes(dir: &Path) -> Vec<(&'static str, Value)> { + let p = |f: &str| s(dir.join(f)); + let clip = kick_clip(); + vec![ + ("session.save", json!({"path": p("a.scraft")})), + ("session.save_as", json!({"path": p("b.scraft")})), + ("session.save_copy", json!({"path": p("c.scraft")})), + ("session.save_template", json!({"path": p("d.scraft")})), + ("file.bounce_mix", json!({"path": p("e.wav"), "start": 0, "end": 4800})), + ("file.bounce_stems", json!({"dir": p("stems"), "start": 0, "end": 4800})), + ("file.export_midi", json!({"path": p("f.mid")})), + ("file.export_session_text", json!({"path": p("g.txt")})), + ("file.export_clips", json!({"dir": p("clips"), "clips": [clip]})), + ("file.export_selected_tracks_as_session", json!({"path": p("h.scraft"), "tracks": ["Kick"]})), + ("file.export_sibelius", json!({"path": p("i.musicxml")})), + ("file.send_to_sibelius", json!({"path": p("j.musicxml")})), + ("file.print_score", json!({"path": p("k.svg")})), + ("file.export_clip_groups", json!({"path": p("l.scgrp"), "clips": [clip]})), + ("track.save_preset", json!({"path": p("m.json"), "tracks": ["Kick"]})), + ] +} + +fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server { + let roots = AutomationRoots::new(read, write).unwrap(); + Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots)) +} + +#[test] +fn every_writing_command_is_refused_outside_the_write_root_and_writes_nothing() { + let f = fixture("writes"); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + let before = listing(&f.base); + let outside_before = listing(&f.outside); + for (command, params) in writes(&f.outside) { + refused(&mut srv, command, params, "outside the write root"); + } + // Not even the temporary file a save goes through, nor a folder. + assert_eq!(listing(&f.base), before, "nothing may appear anywhere"); + // With no path, the score goes to the temp folder: outside the write root, so refused too. + refused(&mut srv, "file.send_to_sibelius", json!({}), "outside the write root"); + refused(&mut srv, "file.print_score", json!({}), "outside the write root"); + assert_eq!(listing(&f.base), before); + // Inside the write root every one of them works. + for (command, params) in writes(&f.write) { + let text = ok(&mut srv, command, params); + assert!(!text.is_empty(), "{command}"); + } + assert!(f.write.join("e.wav").is_file() && f.write.join("stems").is_dir() && f.write.join("l.scgrp").is_file()); + // A save puts the audio in `Audio Files/` beside it, inside the root as well. + assert!(f.write.join("Audio Files").is_dir()); + assert_eq!(listing(&f.outside), outside_before, "outside is as it was"); +} + +#[test] +fn saving_over_an_existing_file_outside_does_not_report_that_it_exists() { + let f = fixture("exists"); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + // Without `replace`, an existing target is reported as existing: not for a path outside the root. + refused(&mut srv, "session.save_as", json!({"path": s(f.outside.join("song.scraft"))}), "outside the write root"); +} + +#[test] +fn the_typed_bounce_and_save_tools_are_confined() { + let f = fixture("typed"); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + for (tool, args) in [ + ("bounce_mix", json!({"path": s(f.outside.join("x.wav")), "start": 0, "end": 4800})), + ("save_session", json!({"path": s(f.outside.join("x.scraft"))})), + ] { + let r = srv.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}})).unwrap(); + assert_eq!(r["result"]["isError"], true, "{tool}"); + assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the write root"), "{tool}"); + } + assert!(!f.outside.join("x.wav").exists() && !f.outside.join("x.scraft").exists()); + let r = srv + .handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "bounce_mix", "arguments": {"path": s(f.write.join("x.wav")), "start": 0, "end": 4800}}})) + .unwrap(); + assert_eq!(r["result"]["isError"], false); +} + +#[test] +fn one_root_grants_only_its_own_access() { + let f = fixture("one-root"); + // Read root only: reads work, nothing can be written anywhere. + let mut srv = demo_server(Some(&f.read), None); + ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); + let before = listing(&f.base); + for (command, params) in writes(&f.write) { + refused(&mut srv, command, params, "write authority is absent"); + } + for (command, params) in writes(&f.read) { + refused(&mut srv, command, params, "write authority is absent"); + } + assert_eq!(listing(&f.base), before); + // Write root only: writes work, nothing can be read anywhere. + let mut srv = demo_server(None, Some(&f.write)); + for (command, params) in writes(&f.write) { + ok(&mut srv, command, params); + } + let name = |f: &str| f.to_string(); + for (command, params) in reads(&f.read, &name) { + refused(&mut srv, command, params, "read authority is absent"); + } + refused(&mut srv, "file.import_audio", json!({"path": s(f.write.join("e.wav"))}), "read authority is absent"); +} + +#[test] +fn the_roots_may_be_the_same_folder_and_are_compared_by_whole_names() { + let f = fixture("same"); + let mut srv = demo_server(Some(&f.read), Some(&f.read)); + ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); + ok(&mut srv, "file.bounce_mix", json!({"path": s(f.read.join("again.wav")), "start": 0, "end": 4800})); + // `in2` shares the prefix of `in` and is a different folder. + let sibling = f.base.join("in2"); + std::fs::create_dir_all(&sibling).unwrap(); + std::fs::copy(f.read.join("tone.wav"), sibling.join("tone.wav")).unwrap(); + refused(&mut srv, "file.import_audio", json!({"path": s(sibling.join("tone.wav"))}), "outside the read root"); + refused(&mut srv, "file.bounce_mix", json!({"path": s(sibling.join("x.wav"))}), "outside the write root"); + // `..` out of the root is outside it. + let up = format!("{}/../outside/tone.wav", s(&f.read)); + refused(&mut srv, "file.import_audio", json!({"path": up}), "outside the read root"); +} + +#[cfg(unix)] +#[test] +fn links_out_of_the_roots_are_refused() { + use std::os::unix::fs::symlink; + let f = fixture("links"); + symlink(f.outside.join("tone.wav"), f.read.join("leak.wav")).unwrap(); + symlink(&f.outside, f.write.join("away")).unwrap(); + symlink(f.outside.join("not-yet.wav"), f.write.join("dangling.wav")).unwrap(); + let mut srv = demo_server(Some(&f.read), Some(&f.write)); + refused(&mut srv, "file.import_audio", json!({"path": s(f.read.join("leak.wav"))}), "outside the read root"); + let before = listing(&f.outside); + refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("away/x.wav"))}), "outside the write root"); + refused(&mut srv, "file.bounce_stems", json!({"dir": s(f.write.join("away/stems"))}), "outside the write root"); + refused(&mut srv, "session.save_as", json!({"path": s(f.write.join("away/x.scraft"))}), "outside the write root"); + refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("dangling.wav"))}), "a link that leads nowhere"); + assert_eq!(listing(&f.outside), before, "nothing was written through a link"); + assert!(!f.outside.join("not-yet.wav").exists()); + // A link to a folder inside the root stays inside it. + symlink(f.read.join("."), f.base.join("in-alias")).unwrap(); + let mut via_alias = demo_server(Some(&f.base.join("in-alias")), None); + ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))})); +} + +/// A session file is hostile input too: media it names outside the read root is not loaded, +/// and the refusal is listed with the other missing media. +#[test] +fn media_a_session_names_outside_the_read_root_is_not_loaded() { + let f = fixture("media"); + let session = f.read.join("song.scraft"); + let text = std::fs::read_to_string(&session).unwrap(); + let outside_wav = s(f.outside.join("tone.wav")); + let swapped = { + let v: Value = serde_json::from_str(&text).unwrap(); + let mut rel = vec![]; + for src in v["sources"].as_array().unwrap() { + rel.push(src["path"].as_str().unwrap().to_string()); + } + assert!(!rel.is_empty()); + rel.iter().fold(text.clone(), |t, r| t.replace(&format!("\"{r}\""), &format!("{}", json!(outside_wav)))) + }; + std::fs::write(&session, swapped).unwrap(); + let mut srv = server(Some(&f.read), None); + let opened = ok(&mut srv, "session.open", json!({"path": s(&session)})); + let opened: Value = serde_json::from_str(&opened).unwrap(); + let missing = opened["missing"].as_array().unwrap(); + assert!(!missing.is_empty() && missing.iter().all(|m| m.as_str().unwrap().contains("outside the read root")), "{opened}"); + let sources = inspect(&mut srv)["sources"].clone(); + assert!(sources.as_array().unwrap().iter().all(|x| x["loaded"] == false), "{sources}"); + // The same session opened without roots does load the file it names. + let mut free = server(None, None); + ok(&mut free, "session.open", json!({"path": s(&session)})); + assert!(inspect(&mut free)["sources"].as_array().unwrap().iter().all(|x| x["loaded"] == true)); +} + +#[test] +fn without_roots_nothing_is_confined() { + let f = fixture("free"); + let mut srv = server(None, None); + ok(&mut srv, "file.import_audio", json!({"path": s(f.outside.join("tone.wav"))})); + ok(&mut srv, "file.bounce_mix", json!({"path": s(f.outside.join("free.wav")), "start": 0, "end": 4800})); + assert!(f.outside.join("free.wav").is_file()); +} diff --git a/crates/dsp/src/meter.rs b/crates/dsp/src/meter.rs index bff76e7..3727f83 100644 --- a/crates/dsp/src/meter.rs +++ b/crates/dsp/src/meter.rs @@ -164,10 +164,12 @@ fn lufs(energy: f64) -> f32 { /// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate. fn k_weighting(sr: f32) -> [Coeffs; 2] { let sr = f64::from(sr); - let pi = std::f64::consts::PI; + // Bilinear prewarp. A corner at or past Nyquist wraps tan() negative (poles outside the unit + // circle) or aliases it, so it is held just below Nyquist, as `Coeffs::design` does. + let prewarp = |f0: f64| (std::f64::consts::PI * f0.min(sr * 0.49) / sr).tan(); // Stage 1: shelving. let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6); - let k = (pi * f0 / sr).tan(); + let k = prewarp(f0); let vh = 10f64.powf(g / 20.0); let vb = vh.powf(0.499_666_774_154_541_6); let a0 = 1.0 + k / q + k * k; @@ -180,7 +182,7 @@ fn k_weighting(sr: f32) -> [Coeffs; 2] { }; // Stage 2: high-pass. let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3); - let k = (pi * f0 / sr).tan(); + let k = prewarp(f0); let a0 = 1.0 + k / q + k * k; let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 }; [s1, s2] diff --git a/crates/dsp/src/offline.rs b/crates/dsp/src/offline.rs index b9a5eac..5ea6b45 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -341,7 +341,14 @@ pub fn pitch_shift(ch: &[Vec], semitones: f32, sample_rate: f32) -> Vec], sample_rate: f32, sensitivity: f32) -> /// Each region is padded by `pad_before`/`pad_after` (clamped to the buffer) and overlapping /// regions are merged. pub fn non_silent_ranges(ch: &[Vec], threshold_db: f32, min_len: usize, pad_before: usize, pad_after: usize) -> Vec<(usize, usize)> { - let len = max_len(ch); - let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db }); - let mut raw: Vec<(usize, usize)> = Vec::new(); - let mut start: Option = None; - let mut last_loud = 0usize; - for i in 0..len { - let loud = ch.iter().any(|c| c.get(i).is_some_and(|v| v.is_finite() && v.abs() > thr)); - if !loud { - continue; - } - match start { - Some(s) if i - last_loud > min_len.max(1) => { - raw.push((s, last_loud + 1)); - start = Some(i); + let mut scan = SilenceScan::new(threshold_db, min_len as u64); + scan.push(ch); + let at = |x: u64| usize::try_from(x).unwrap_or(usize::MAX); + scan.finish(pad_before as u64, pad_after as u64).into_iter().map(|(s, e)| (at(s), at(e))).collect() +} + +/// [`non_silent_ranges`] over audio that arrives a block at a time, so material of any length can +/// be scanned without holding all of it. +pub struct SilenceScan { + thr: f32, + min_len: u64, + len: u64, + start: Option, + last_loud: u64, + raw: Vec<(u64, u64)>, +} + +impl SilenceScan { + pub fn new(threshold_db: f32, min_len: u64) -> Self { + let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db }); + SilenceScan { thr, min_len, len: 0, start: None, last_loud: 0, raw: Vec::new() } + } + + /// Scan the next block (planar), which follows the audio scanned so far. + pub fn push(&mut self, ch: &[Vec]) { + let n = max_len(ch); + for k in 0..n { + if !ch.iter().any(|c| c.get(k).is_some_and(|v| v.is_finite() && v.abs() > self.thr)) { + continue; } - None => start = Some(i), - _ => {} + let i = self.len.saturating_add(k as u64); + match self.start { + Some(s) if i - self.last_loud > self.min_len.max(1) => { + self.raw.push((s, self.last_loud.saturating_add(1))); + self.start = Some(i); + } + None => self.start = Some(i), + _ => {} + } + self.last_loud = i; } - last_loud = i; + self.len = self.len.saturating_add(n as u64); } - if let Some(s) = start { - raw.push((s, last_loud + 1)); + + /// Pass over `n` samples known to be silent. + pub fn skip(&mut self, n: u64) { + self.len = self.len.saturating_add(n); } - let mut out: Vec<(usize, usize)> = Vec::with_capacity(raw.len()); - for (s, e) in raw { - let s = s.saturating_sub(pad_before); - let e = e.saturating_add(pad_after).min(len); - match out.last_mut() { - Some(last) if s <= last.1 => last.1 = last.1.max(e), - _ => out.push((s, e)), + + /// The regions found, padded and merged (see [`non_silent_ranges`]). + pub fn finish(mut self, pad_before: u64, pad_after: u64) -> Vec<(u64, u64)> { + if let Some(s) = self.start { + self.raw.push((s, self.last_loud.saturating_add(1))); } + let mut out: Vec<(u64, u64)> = Vec::with_capacity(self.raw.len()); + for (s, e) in self.raw { + let s = s.saturating_sub(pad_before); + let e = e.saturating_add(pad_after).min(self.len); + match out.last_mut() { + Some(last) if s <= last.1 => last.1 = last.1.max(e), + _ => out.push((s, e)), + } + } + out } - out } /// Runs a plugin over whole buffers in blocks, compensating its latency so the output lines up diff --git a/crates/dsp/src/pitch_detect.rs b/crates/dsp/src/pitch_detect.rs index 8271ff3..b1b2f0a 100644 --- a/crates/dsp/src/pitch_detect.rs +++ b/crates/dsp/src/pitch_detect.rs @@ -17,7 +17,7 @@ pub fn yin(frame: &[f32], sample_rate: f32, min_hz: f32, max_hz: f32, threshold: } let max_tau = ((sample_rate / min_hz.max(20.0)) as usize).min(frame.len() / 2); let min_tau = ((sample_rate / max_hz.max(min_hz + 1.0)) as usize).max(2); - if max_tau <= min_tau + 2 { + if max_tau <= min_tau.saturating_add(2) { return None; } let w = frame.len() - max_tau; @@ -140,4 +140,16 @@ mod tests { assert!(audio_to_notes(&[], f32::NAN).is_empty()); assert!(yin(&[0.0; 10], 48_000.0, 50.0, 1600.0, 0.15).is_none()); } + + #[test] + fn huge_finite_rate_is_unvoiced() { + // #241: the shortest lag saturates to usize::MAX; that is "no pitch", not an overflow. + assert!(yin(&[0.0; 64], 1.0e30, 50.0, 1600.0, 0.15).is_none()); + assert!(yin(&[0.5; 8192], f32::MAX, 50.0, 1600.0, 0.15).is_none()); + } + + #[test] + fn audio_to_notes_survives_huge_finite_rate() { + assert!(audio_to_notes(&[0.5; 8192], 1.0e30).is_empty()); + } } diff --git a/crates/dsp/src/plugins/eq.rs b/crates/dsp/src/plugins/eq.rs index 6aefe91..fd948d4 100644 --- a/crates/dsp/src/plugins/eq.rs +++ b/crates/dsp/src/plugins/eq.rs @@ -184,12 +184,18 @@ impl Eq { } fn update(&mut self) { + let mut snapped = false; for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() { s.set(self.p.v(i)); if matches!(info.unit, Unit::Toggle | Unit::Choice) { s.snap(); + snapped |= self.vals.get(i) != Some(&s.value()); } } + // A snapped smoother never settles, so `process` would not redesign for it. + if snapped { + self.redesign(); + } let g = db_to_gain(self.total_gain_db(|i| self.p.v(i))); self.gain.set(g); } diff --git a/crates/dsp/src/plugins/modulation.rs b/crates/dsp/src/plugins/modulation.rs index 38284f3..962f57d 100644 --- a/crates/dsp/src/plugins/modulation.rs +++ b/crates/dsp/src/plugins/modulation.rs @@ -316,7 +316,15 @@ impl Plugin for ModFx { } fn tail_samples(&self) -> usize { - ms_to_samples(200.0, self.sr) as usize + // Time for feedback to fall by 60 dB at the longest delay of the sweep, as for the delay + // above; never shorter than the 200 ms this used to report. + let longest = match self.kind { + ModKind::Chorus => self.p.v(2) + self.p.v(1) / 100.0 * 6.0, + ModKind::Flanger => self.p.v(2), + }; + let fb = (self.p.v(3).abs() / 100.0).clamp(0.0, 0.99); + let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 }; + ms_to_samples((longest * repeats).max(200.0), self.sr) as usize } } diff --git a/crates/dsp/tests/offline_meters.rs b/crates/dsp/tests/offline_meters.rs index f83f241..305ef17 100644 --- a/crates/dsp/tests/offline_meters.rs +++ b/crates/dsp/tests/offline_meters.rs @@ -65,6 +65,21 @@ fn loudness_full_scale_mono_and_44k() { } } +#[test] +fn loudness_stays_bounded_at_low_sample_rates() { + // The K-weighting shelf corner (~1.7 kHz) sits at or above Nyquist below ~3.4 kHz; the + // filter must stay stable there instead of reading hundreds of LUFS (issue #240). + for sr in [1000.0f32, 1200.0, 1600.0, 2000.0, 3000.0, 3400.0, 8000.0, 48_000.0] { + let len = sr as usize; + let mut m = LoudnessMeter::new(sr, 1); + assert_eq!(m.sample_rate(), sr); + m.process(&[sine(100.0, 0.5, sr, len)], len); + for level in [m.integrated_lufs(), m.momentary_lufs(), m.short_term_lufs()] { + assert!(level.is_finite() && (-30.0..=0.0).contains(&level), "{sr}: {level}"); + } + } +} + #[test] fn loudness_gating_ignores_silence() { let sr = 48_000.0; @@ -155,6 +170,19 @@ fn pitch_shift_preserves_length_and_shifts() { assert!((f - want).abs() < 4.0, "{f} vs {want}"); } +#[test] +fn pitch_shift_reaches_the_ends_of_its_range() { + let sr = 48_000.0; + let x = sine(440.0, 0.5, sr, 48_000); + for st in [40.0f32, 48.0, -40.0, -48.0] { + let out = offline::pitch_shift(std::slice::from_ref(&x), st, sr); + assert_eq!(out[0].len(), x.len()); + let f = dominant_hz(&out[0], sr); + let want = 440.0 * 2f32.powf(st / 12.0); + assert!((f / want - 1.0).abs() < 0.002, "{st} semitones: {f} vs {want}"); + } +} + #[test] fn normalize_hits_target_peak() { let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)]; @@ -200,6 +228,26 @@ fn non_silent_ranges_finds_bursts() { assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty()); } +#[test] +fn silence_scan_in_blocks_finds_what_one_pass_finds() { + let mut x = vec![0.0f32; 48_000]; + for (i, v) in sine(500.0, 0.5, 48_000.0, 4800).into_iter().enumerate() { + x[10_000 + i] = v; + x[30_000 + i] = v; + } + let whole: Vec<(u64, u64)> = + offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200).into_iter().map(|(a, b)| (a as u64, b as u64)).collect(); + let mut blocks = offline::SilenceScan::new(-40.0, 480); + x.chunks(1000).for_each(|b| blocks.push(&[b.to_vec()])); + assert_eq!(blocks.finish(100, 200), whole); + // Skipping the silence between the bursts counts its length like scanning it. + let mut skipped = offline::SilenceScan::new(-40.0, 480); + skipped.push(&[x[..20_000].to_vec()]); + skipped.skip(10_000); + skipped.push(&[x[30_000..].to_vec()]); + assert_eq!(skipped.finish(100, 200), whole); +} + #[test] fn transients_detected_near_onsets() { let sr = 48_000.0; diff --git a/crates/dsp/tests/plugins.rs b/crates/dsp/tests/plugins.rs index 5d71f6f..960f39f 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -199,6 +199,44 @@ fn eq_magnitude_at_center_matches_gain() { assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}"); } +#[test] +fn eq_switches_and_choices_apply_to_a_running_eq() { + // (plugin, settings, the one switch or choice changed while running, test tone) + let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [ + ("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0), + ("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0), + ("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0), + ("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0), + ("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0), + ("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0), + ("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0), + ]; + let mut stale = Vec::new(); + for (id, setup, (key, value), tone) in cases { + let x = [sine(tone, 0.5, 24_000)]; + let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..])); + let build = |settings: &[(&str, f32)]| { + let mut p = create(id).unwrap(); + p.prepare(SR, BLOCK, 1); + for &(k, v) in settings { + assert!(p.set_param(k, v), "{id} {k}"); + } + p.reset(); + p + }; + let mut live = build(setup); + let before = level(live.as_mut()); + assert!(live.set_param(key, value)); + let after = level(live.as_mut()); + let want = level(build(&[setup, &[(key, value)]].concat()).as_mut()); + assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart"); + if (after - want).abs() >= 0.1 { + stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB")); + } + } + assert!(stale.is_empty(), "{stale:#?}"); +} + #[test] fn compressor_steady_state_gain_reduction() { let mut p = prepared("compressor"); @@ -329,6 +367,23 @@ fn delay_repeats_at_time() { assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}"); } +#[test] +fn modulation_tail_covers_feedback_echoes() { + for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] { + let mut p = prepared(id); + for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] { + assert!(p.set_param(param, v)); + } + p.reset(); + let mut x = vec![0.0f32; 48_000 * 5]; + x[0] = 0.5; + let out = run(p.as_mut(), &[x]); + let floor = peak(&out[0]) * 1e-3; + let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap(); + assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples()); + } +} + #[test] fn trim_and_invert() { let mut p = prepared("invert"); diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index c889c99..607d5b5 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -31,6 +31,9 @@ struct GroupFile { /// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written. pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { + let p = with_extension(path); + // First, so a refused group file leaves nothing behind and is refused whatever else is wrong. + crate::file_access::check_write(&p).map_err(EngineError::Io)?; let s = e.session(); let mut lanes: Vec<(usize, Vec)> = Vec::new(); for id in ids { @@ -47,7 +50,6 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { }; let n = clips.len(); lanes.sort_by_key(|(i, _)| *i); - let p = with_extension(path); let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); @@ -62,10 +64,10 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { && let (Some(buf), Some(src)) = (s.pool.get(source), s.sources.iter().find(|x| x.id == source)) { let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0); - std::fs::create_dir_all(dir.join(&audio_dir_name)).map_err(|err| EngineError::Io(format!("{audio_dir_name}: {err}")))?; + crate::io::make_dir(dir.join(&audio_dir_name))?; let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None }; let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(dir.join(&rel), bytes).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + crate::io::write_path(dir.join(&rel), bytes)?; slot.insert(rel); } lane.push(c); @@ -74,7 +76,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { } let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio }; let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?; + crate::io::write_path(&p, text)?; Ok(n) } @@ -82,7 +84,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result { /// are made when the session runs out). Returns the ids of the placed clips. pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) -> Result> { let id = "file.import_clip_groups"; - let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text = crate::io::read_text(path)?; let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; if file.format != FORMAT { return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into())); @@ -98,7 +100,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - if !full.starts_with(&dir) || rel.contains("..") { return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); } - let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?; + let bytes = crate::io::read_file(&full)?; let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; decoded.insert(*old, (rel.clone(), buf)); } diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index e2abfca..3cf88b2 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -40,12 +40,25 @@ fn process(e: &mut Engine, p: &Value) -> Result { return Err(bad("audiosuite.process", "select audio clips first")); } let sr = e.session().sample_rate; + let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0)); + let semitones = get("semitones", 0.0).clamp(-24.0, 24.0); + let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32; + // These processes cut their output at MAX_OFFLINE_SAMPLES frames. + let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize; + let most = match name.as_str() { + "time_stretch" => longest(ratio), + "pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)), + "varispeed" => longest(f64::from(to) / sr.as_f64()), + _ => usize::MAX, + }; let mut done = 0; for id in ids { let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; if !clip.is_audio() { continue; } + let channels = e.session().track(track).map_or(1, |t| t.channels()); + crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?; let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range()); // Render without the clip's own fades: re-render a copy without fades so they stay editable. let mut tmp = e.session().clone(); @@ -78,13 +91,9 @@ fn process(e: &mut Engine, p: &Value) -> Result { audio } "duplicate" => audio, - "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, f64::from(get("ratio", 1.0).clamp(0.25, 4.0)), srf), - "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, get("semitones", 0.0).clamp(-24.0, 24.0), srf), - "varispeed" => { - let speed = get("speed", 1.0).clamp(0.25, 4.0); - let to = (sr.as_f64() / f64::from(speed)).round().max(1.0) as u32; - soundcraft_dsp::offline::resample(&audio, sr.hz(), to) - } + "time_stretch" => soundcraft_dsp::offline::time_stretch(&audio, ratio, srf), + "pitch_shift" => soundcraft_dsp::offline::pitch_shift(&audio, semitones, srf), + "varispeed" => soundcraft_dsp::offline::resample(&audio, sr.hz(), to), plugin => { let Some(mut pl) = soundcraft_dsp::create(plugin) else { return Err(bad("audiosuite.process", format!("cannot create `{plugin}`"))) }; pl.prepare(srf, 1024, audio.len().max(1)); diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..06d9cd5 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -74,7 +74,7 @@ pub fn specs() -> Vec { let ids = clip_ids_param(e, p); let mut n = 0; for id in ids { - if crate::io::render_clip_gain(e, id)? { + if crate::io::render_clip_gain(e, "clip.gain_render", id)? { n += 1 } } @@ -199,9 +199,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result { continue; } let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1); - let mut warp = base.clone(); - warp.stretch = ratio; - let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &warp, out_len)? else { continue }; + let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue }; if orig.is_none() && let soundcraft_model::ClipContent::Audio { source, offset } = base.content { @@ -288,8 +286,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut made = 0; for id in ids { - let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; + let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue }; let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000); + if reps > 0 && c.loop_id.is_none() { + let lid = s.alloc(); + if let Some(src) = s.find_clip_mut(id) { + src.loop_id = Some(lid); + } + c.loop_id = Some(lid); + } let mut copies = Vec::new(); for k in 1..=reps { let mut nc = c.clone(); @@ -378,6 +383,45 @@ mod tests { assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0); } + /// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`. + fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) { + use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic")); + let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect(); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] }; + let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav); + let cid = s.new_clip_id(); + crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len)); + let mut e = crate::Engine::new(s); + e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap(); + (e, t, cid) + } + + fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 { + let range = e.session().find_clip(cid).unwrap().1.range(); + soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs())) + } + + /// Elastic Properties stretches the material the clip plays, by the requested ratio (#227). + #[test] + fn elastic_properties_stretches_the_clip_material() { + let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap(); + assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000); + assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost"); + + let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap(); + assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in"); + + for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] { + let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000); + e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap(); + assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}"); + } + } + #[test] fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/clip_more.rs b/crates/engine/src/cmd/clip_more.rs index 74dfd8d..59e5717 100644 --- a/crates/engine/src/cmd/clip_more.rs +++ b/crates/engine/src/cmd/clip_more.rs @@ -101,12 +101,13 @@ fn regroup(e: &mut Engine, p: &Value) -> Result { fn same_material(a: &Clip, b: &Clip) -> bool { a.length == b.length - && match (&a.content, &b.content) { - (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, - (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, - (ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, - _ => false, - } + && (a.loop_id.is_some() && a.loop_id == b.loop_id + || match (&a.content, &b.content) { + (ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + (ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2, + (ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2, + _ => false, + }) } /// The loop a clip belongs to: back-to-back copies of the same material. Origin first. @@ -160,6 +161,7 @@ fn unloop(e: &mut Engine, p: &Value) -> Result { } else { for c in pl.clips.iter_mut().filter(|c| chain.contains(&c.id)) { c.group = None; + c.loop_id = None; } } } @@ -204,7 +206,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let mut n = 0; for id in &ids { - if crate::io::render_clip_gain(e, *id)? { + if crate::io::render_clip_gain(e, "clip.effects_render", *id)? { n += 1; } let s = e.session_mut(); @@ -304,6 +306,42 @@ mod tests { assert!(e.execute("clip.unloop", &json!({"clip": ids[3].0})).is_err()); } + /// A 100-sample MIDI clip with one note, on a fresh instrument-less MIDI track. + fn midi_engine() -> (Engine, TrackId, ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let id = s.new_clip_id(); + let mut seq = soundcraft_midi::Sequence::default(); + seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 240 }); + crate::edit::place_clip(&mut s, t, Clip::midi(id, "m", 0, 100, seq)); + s.edit.selected_tracks = vec![t]; + (Engine::new(s), t, id) + } + + #[test] + fn unloop_recognises_loops_after_source_edit() { + let (mut e, t, id) = midi_engine(); + assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "count": 4})).unwrap()["loops"], 3); + // Editing the source note makes the copies' content differ from it. + if let Some(c) = e.session_mut().find_clip_mut(id) + && let ClipContent::Midi { sequence } = &mut c.content + { + sequence.notes[0].length = 1100; + } + e.execute("clip.unloop", &json!({"clip": id.0})).unwrap(); + let left: Vec = e.session().track(t).unwrap().clips().iter().map(|c| c.id).collect(); + assert_eq!(left, vec![id]); + } + + #[test] + fn unloop_flatten_turns_loop_into_independent_clips() { + let (mut e, t, id) = midi_engine(); + e.execute("clip.loop", &json!({"clip": id.0, "count": 3})).unwrap(); + e.execute("clip.unloop", &json!({"clip": id.0, "mode": "flatten"})).unwrap(); + assert_eq!(e.session().track(t).unwrap().clips().len(), 3); + assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none())); + } + #[test] fn regroup_reuses_group() { let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]); diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index f123d2e..b5e2b66 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -10,16 +10,15 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))), cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))), - cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection), + cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection), cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")), cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")), cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste), cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear), - cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain( - e, - p, - "edit.cut_clip_gain" - )), + cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| { + super::edit_more::copy_clip_gain(e, p)?; + reset_clip_gain(e, p, "edit.cut_clip_gain") + }), cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain( e, p, @@ -103,7 +102,15 @@ pub fn specs() -> Vec { cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file), cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill), cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips), - cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist), + cmd!( + "edit.copy_to_new_playlist", + "New Playlist", + ["Edit", "Copy Selection to..."], + Some("Alt+Shift+Down"), + "{}", + has_range, + copy_to_new_playlist + ), cmd!( "edit.strip_silence", "Strip Silence", @@ -125,7 +132,7 @@ pub fn specs() -> Vec { cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete), cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)), cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)), - cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge), + cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge), cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| { let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?; e.session_mut().edit.edit_mode = m; @@ -153,8 +160,28 @@ fn shuffle(s: &Session) -> bool { s.edit.edit_mode == EditMode::Shuffle } +fn has_last_selection(e: &Engine) -> std::result::Result<(), String> { + if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) } +} + +/// Remember the selection `before` had when a command changed it, for Restore Last Selection. +pub(crate) fn remember_selection(e: &mut Engine, before: &Session) { + let (old, new) = (&before.edit, &e.session().edit); + if old.selection == new.selection && old.selected_tracks == new.selected_tracks { + return; + } + e.last_selection = Some((old.selection, old.selected_tracks.clone())); +} + +/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so +/// invoking it again goes back. fn restore_selection(e: &mut Engine, _: &Value) -> Result { - Ok(json!({"selection": e.session().edit.selection})) + let (range, mut tracks) = e.last_selection.clone().ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?; + tracks.retain(|t| e.session().track(*t).is_some()); + let ed = &mut e.session_mut().edit; + ed.selection = range; + ed.selected_tracks = tracks; + Ok(json!({"selection": range})) } fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { @@ -429,20 +456,25 @@ fn scale_selection(e: &mut Engine, f: f64) -> Result { Ok(json!({"selection": s.edit.selection})) } +/// Adds the nearest track above (`dir` < 0) or below the selected ones that the Edit window shows. fn extend_tracks(e: &mut Engine, dir: i64) -> Result { - let s = e.session_mut(); + let s = e.session(); + let shown = |t: &&soundcraft_model::Track| !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open); let idxs: Vec = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect(); - let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) }; - if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) { + let next = match (idxs.iter().min(), idxs.iter().max()) { + (Some(&top), _) if dir < 0 => s.tracks.iter().take(top).rev().find(shown), + (_, Some(&bottom)) if dir > 0 => s.tracks.iter().skip(bottom + 1).find(shown), + (None, _) => s.tracks.iter().find(shown), + _ => None, + } + .map(|t| t.id); + let s = e.session_mut(); + if let Some(t) = next { if dir < 0 { s.edit.selected_tracks.insert(0, t); } else { s.edit.selected_tracks.push(t); } - } else if idxs.is_empty() - && let Some(t) = s.tracks.first() - { - s.edit.selected_tracks.push(t.id); } Ok(json!({"tracks": s.edit.selected_tracks})) } @@ -591,13 +623,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut n = 0; for id in ids { - let frames = - s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) { + if let Some((frames, lo, hi)) = trim_limits(s, id) + && let Some(c) = s.find_clip_mut(id) + { let off = c.source_offset(); let new_start = c.start - off; - c.trim_start_to(new_start); - c.trim_end_to(new_start + frames); + c.trim_start_to(new_start.max(lo)); + c.trim_end_to((new_start + frames).min(hi)); n += 1; } } @@ -610,18 +642,31 @@ fn trim_to_fill(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut n = 0; for id in ids { - let frames = - s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) { + if let Some((frames, lo, hi)) = trim_limits(s, id) + && let Some(c) = s.find_clip_mut(id) + { let file_start = c.start - c.source_offset(); - c.trim_start_to(r.start.max(file_start)); - c.trim_end_to(r.end.min(file_start + frames)); + c.trim_start_to(r.start.max(file_start).max(lo)); + c.trim_end_to(r.end.min(file_start + frames).min(hi)); n += 1; } } Ok(json!({"trimmed": n})) } +/// How far a trim may extend an audio clip: its source length in frames, and the earliest +/// start and latest end before it would run over the clip before or after it on the playlist +/// (an extension never overwrites, like the trim tool). An overlap the clip already has with +/// a neighbour (a crossfade) is kept rather than trimmed away. +fn trim_limits(s: &Session, id: ClipId) -> Option<(Samples, Samples, Samples)> { + let (track, c) = s.find_clip(id)?; + let frames = c.source().and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX))?; + let others = || s.track(track).into_iter().flat_map(|t| t.clips()).filter(|x| x.id != id); + let lo = others().filter(|x| x.start < c.start).map(|x| x.end()).max().map_or(Samples::MIN, |end| end.min(c.start)); + let hi = others().filter(|x| x.start > c.start).map(|x| x.start).min().map_or(Samples::MAX, |start| start.max(c.end())); + Some((frames, lo, hi)) +} + fn mute_clips(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session_mut(); @@ -665,7 +710,7 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let mut kept = 0; for t in &tracks { - kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post); + kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?; } Ok(json!({"clips": kept})) } @@ -715,7 +760,8 @@ fn nudge(e: &mut Engine, p: &Value) -> Result { let dir = i64_or(p, "direction", 1).signum(); let count = i64_or(p, "count", 1).clamp(1, 1000); let s = e.session(); - let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir }; + let value = if bool_or(p, "next", false) { s.edit.nudge.stepped(true) } else { s.edit.nudge }; + let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir }; let ids = clip_ids_param(e, p); let s = e.session_mut(); let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) }; @@ -760,7 +806,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count(); let mut n = 0; for t in tracks { - if crate::io::consolidate(e, t, r)? { + if crate::io::consolidate(e, "edit.consolidate", t, r)? { n += 1; } } @@ -829,4 +875,74 @@ mod tests { assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]); assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]); } + + /// A 60,000-frame source with `clips` placed on one track as (start, length, source offset). + fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec) { + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] }; + let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav); + let mut ids = Vec::new(); + for &(start, len, off) in clips { + let id = s.new_clip_id(); + ops::place_clip(&mut s, t, Clip::audio(id, "c", src, off, start, len)); + ids.push(id); + } + (Engine::new(s), t, ids) + } + + fn bounds(e: &Engine, t: TrackId) -> Vec<(Samples, Samples, Samples)> { + e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end(), c.source_offset())).collect() + } + + #[test] + fn trim_to_file_boundaries_stops_at_the_neighbours() { + let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]); + e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap(); + assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]); + } + + #[test] + fn trim_to_fill_selection_stops_at_the_neighbours() { + let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (20_000, 10_000, 20_000), (40_000, 5_000, 0)]); + e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[1].0], "start": 5_000, "end": 45_000})).unwrap(); + assert_eq!(bounds(&e, t), vec![(0, 10_000, 0), (10_000, 40_000, 10_000), (40_000, 45_000, 0)]); + } + + #[test] + fn trims_without_neighbours_expand_fully() { + let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]); + e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[0].0]})).unwrap(); + assert_eq!(bounds(&e, t), vec![(0, 60_000, 0)]); + let (mut e, t, ids) = trim_session(&[(20_000, 10_000, 20_000)]); + e.execute("edit.trim_to_fill_selection", &json!({"clips": [ids[0].0], "start": 5_000, "end": 45_000})).unwrap(); + assert_eq!(bounds(&e, t), vec![(5_000, 45_000, 5_000)]); + } + + #[test] + fn trim_to_file_boundaries_keeps_an_existing_crossfade() { + let (mut e, t, ids) = trim_session(&[(0, 10_000, 0), (10_000, 10_000, 10_000)]); + e.execute("edit.fades_create", &json!({"tracks": [t], "start": 9_500, "end": 10_500})).unwrap(); + let faded = bounds(&e, t); + assert_eq!((faded[0].1, faded[1].0), (10_500, 9_500), "the fixture needs an overlapping crossfade"); + e.execute("edit.trim_to_file_boundaries", &json!({"clips": [ids[1].0]})).unwrap(); + assert_eq!(bounds(&e, t), vec![faded[0], (9_500, 60_000, 9_500)]); + } + + #[test] + fn restore_last_selection_swaps_back_to_the_previous_range_and_tracks() { + let mut e = Engine::default(); + assert!(e.execute("edit.restore_last_selection", &json!({})).is_err(), "nothing to restore yet"); + let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 1_000, "end": 4_000})).unwrap(); + e.execute("edit.select", &json!({"tracks": [ids[1]], "start": 7_000, "end": 9_000})).unwrap(); + let tracks = |e: &Engine| e.session().edit.selected_tracks.iter().map(|t| t.0).collect::>(); + e.execute("edit.restore_last_selection", &json!({})).unwrap(); + assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(1_000, 4_000)); + assert_eq!(json!(tracks(&e)), json!([ids[0]])); + e.execute("edit.restore_last_selection", &json!({})).unwrap(); + assert_eq!(e.session().edit.selection, soundcraft_time::Range::new(7_000, 9_000)); + assert_eq!(json!(tracks(&e)), json!([ids[1]])); + } } diff --git a/crates/engine/src/cmd/edit_more.rs b/crates/engine/src/cmd/edit_more.rs index c5c1082..fba10d5 100644 --- a/crates/engine/src/cmd/edit_more.rs +++ b/crates/engine/src/cmd/edit_more.rs @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "edit.copy_to_target_playlist", "Target Playlist", ["Edit", "Copy Selection to..."], - None, + Some("Alt+Shift+Up"), "{tracks?, start?, end?} — copies the selection into each track's target playlist", has_range, |e, p| to_playlist(e, p, Dest::Target, false, "edit.copy_to_target_playlist") @@ -196,14 +196,20 @@ pub fn specs() -> Vec { "edit.move_to_target_playlist", "Target Playlist", ["Edit", "Move Selection to..."], - None, + Some("Alt+Shift+T"), "{tracks?, start?, end?} — copies to the target playlist and clears it from the main", has_range, |e, p| to_playlist(e, p, Dest::Target, true, "edit.move_to_target_playlist") ), - cmd!("edit.move_to_new_playlist", "New Playlist", ["Edit", "Move Selection to..."], None, "{tracks?, start?, end?}", has_range, |e, p| { - to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist") - }), + cmd!( + "edit.move_to_new_playlist", + "New Playlist", + ["Edit", "Move Selection to..."], + Some("Alt+Shift+N"), + "{tracks?, start?, end?}", + has_range, + |e, p| to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist") + ), cmd!( "edit.move_to_duplicate_playlist", "Duplicate of Main Playlist", @@ -326,7 +332,7 @@ fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Re Ok(json!({"lanes": lanes_n, "length": r.len()})) } -fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result { +pub(super) fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result { let id = "edit.copy_clip_gain"; let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; @@ -1044,6 +1050,22 @@ mod tests { assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}"); } + #[test] + fn cut_clip_gain_can_be_pasted() { + let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]); + e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap(); + e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap(); + // An older copy sits in the clipboard: the paste must get the cut gain, not this one. + e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap(); + e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap(); + e.execute("edit.cut_clip_gain", &json!({})).unwrap(); + let src = e.session().track(t).unwrap().clips()[0].clone(); + assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}"); + e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap(); + let c = e.session().track(t).unwrap().clips()[1].clone(); + assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}"); + } + #[test] fn move_to_new_playlist_clears_main() { let (mut e, t, _) = session_with_clips(&[(0, 1000)]); diff --git a/crates/engine/src/cmd/event.rs b/crates/engine/src/cmd/event.rs index 34fb355..e4b8123 100644 --- a/crates/engine/src/cmd/event.rs +++ b/crates/engine/src/cmd/event.rs @@ -22,7 +22,7 @@ pub fn specs() -> Vec { e.session_mut().tempo.scale_tempos(f).map_err(|err| bad("event.tempo_scale", err.to_string()))?; Ok(json!({})) }), - cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo), + cmd!("event.tempo_constant", "Constant...", ["Event", "Tempo Operations"], None, "{bpm, start?, end?}", always, tempo_constant), cmd!("event.insert_time", "Insert Time...", ["Event", "Time Operations"], None, "{start?, length | end}", has_tracks, insert_time), cmd!("event.cut_time", "Cut Time...", ["Event", "Time Operations"], None, "{start?, end?}", has_tracks, cut_time), cmd!( @@ -110,6 +110,30 @@ fn tempo(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tick": tick, "bpm": bpm})) } +fn tempo_constant(e: &mut Engine, p: &Value) -> Result { + let id = "event.tempo_constant"; + let r = range_param(e, id, p)?; + let s = e.session(); + let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate)); + if t1 <= t0 { + return tempo(e, p); + } + let bpm = f64_or(p, "bpm", f64::NAN); + let s = e.session_mut(); + // Read before the edit: the end is a musical position, and the new tempo moves it in samples. + let after = s.tempo.tempo_at_tick(t1); + let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1); + s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?; + let inside: Vec = s.tempo.tempos().iter().map(|x| x.tick).filter(|t| *t > t0 && *t < t1).collect(); + for t in inside { + s.tempo.remove_tempo(t); + } + if !had_end { + s.tempo.set_tempo(t1, after).map_err(|err| bad(id, err.to_string()))?; + } + Ok(json!({"tick": t0, "end_tick": t1, "bpm": bpm})) +} + fn meter(e: &mut Engine, p: &Value) -> Result { let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32; let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32; @@ -314,7 +338,8 @@ fn edit_marker(e: &mut Engine, p: &Value) -> Result { let at = position_param(e, "markers.edit", p, "at")?; let s = e.session_mut(); let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) }; - if let Some(n) = p.get("rename").and_then(Value::as_str) { + // `name` is the documented new name; when it was the selector it already matches, so this is a no-op. + if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) { m.name = n.to_string(); } if let Some(c) = str_param(p, "comments") { @@ -376,4 +401,63 @@ mod tests { let tick = s.tempo.samples_to_ticks(at, s.sample_rate); assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9); } + + /// Changing the initial meter keeps a later meter change at its bar (issue #250). + #[test] + fn earlier_meter_change_keeps_later_meter_on_its_bar() { + use soundcraft_time::BarBeat; + let mut e = crate::Engine::default(); + assert_eq!(e.session().sample_rate.hz(), 48_000); + e.execute("event.meter", &json!({"numerator": 3, "denominator": 4, "at": 192_000})).unwrap(); + e.execute("event.meter", &json!({"numerator": 6, "denominator": 4, "at": 0})).unwrap(); + let map = &e.session().tempo; + let later = map.meters().get(1).copied().unwrap(); + assert_eq!((later.numerator, later.denominator), (3, 4)); + assert_eq!(map.bar_beat_at_tick(later.tick), BarBeat { bar: 3, beat: 1, tick: 0 }); + let bar2_beat3 = BarBeat { bar: 2, beat: 3, tick: 0 }; + assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(bar2_beat3)), bar2_beat3); + } + + fn marker_name(e: &crate::Engine, id: u64) -> String { + e.session().markers.iter().find(|m| m.id.0 == id).map(|m| m.name.clone()).unwrap_or_default() + } + + /// `markers.edit` documents `name`: with `id` or `number` selecting the location, it is the new name. + #[test] + fn edit_marker_renames_with_documented_name() { + let mut e = crate::Engine::default(); + let verse = e.execute("markers.add", &json!({"name": "Verse", "at": 1000})).unwrap(); + let outro = e.execute("markers.add", &json!({"name": "Outro", "at": 3000})).unwrap(); + let (verse_id, outro_id) = (verse["id"].as_u64().unwrap(), outro["id"].as_u64().unwrap()); + + e.execute("markers.edit", &json!({"id": verse_id, "name": "Chorus"})).unwrap(); + assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Outro".into())); + + e.execute("markers.edit", &json!({"number": outro["number"], "name": "Bridge"})).unwrap(); + assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Bridge".into())); + + e.execute("markers.edit", &json!({"name": "Bridge", "comments": "last"})).unwrap(); + let s = e.session(); + let bridge = s.markers.iter().find(|m| m.id.0 == outro_id).unwrap(); + assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last")); + } + + /// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it. + #[test] + fn tempo_constant_is_bounded_by_its_range() { + let mut e = crate::Engine::default(); + e.execute("event.tempo", &json!({"bpm": 100.0, "at": 0})).unwrap(); + e.execute("event.tempo", &json!({"bpm": 140.0, "at": 48_000})).unwrap(); + let s = e.session(); + let (t0, t1) = (s.tempo.samples_to_ticks(24_000, s.sample_rate), s.tempo.samples_to_ticks(96_000, s.sample_rate)); + let mid = s.tempo.samples_to_ticks(72_000, s.sample_rate); + let after = s.tempo.tempo_at_tick(t1); + e.execute("event.tempo_constant", &json!({"bpm": 90.0, "start": 24_000, "end": 96_000})).unwrap(); + let tempo = &e.session().tempo; + assert_eq!(tempo.tempo_at_tick(t0 - 1), 100.0, "before the range"); + assert_eq!(tempo.tempo_at_tick(t0), 90.0); + assert_eq!(tempo.tempo_at_tick(mid), 90.0, "the interior change is replaced"); + assert_eq!(tempo.tempo_at_tick(t1), after, "the old tempo resumes at the end"); + assert_eq!(after, 140.0); + } } diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..2acf60b 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -66,7 +66,7 @@ pub fn specs() -> Vec { cmd!("file.import_midi", "MIDI...", ["File", "Import"], Some("Cmd+Alt+I"), "{path, at?}", always, |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.import_midi", "`path` required"))?.to_string(); let at = position_param(e, "file.import_midi", p, "at")?.unwrap_or(0); - let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(&path)?; let tracks = crate::io::import_midi_bytes(e, &bytes, at, bool_or(p, "tempo_map", true))?; Ok(json!({"tracks": tracks})) }), @@ -80,7 +80,7 @@ pub fn specs() -> Vec { |e, p| { let path = str_param(p, "path").ok_or_else(|| bad("file.import_video", "`path` required"))?.to_string(); let at = position_param(e, "file.import_video", p, "at")?.unwrap_or(0).max(0); - let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(&path)?; crate::io::import_video_bytes(e, &path, bytes, at) } ), @@ -123,6 +123,10 @@ pub fn specs() -> Vec { if ids.is_empty() { return Err(bad("file.export_clips", "no clips selected")); } + for (t, c) in ids.iter().filter_map(|id| e.session().find_clip(*id)) { + let channels = e.session().track(t).map_or(1, |x| x.channels()); + crate::io::refuse_longer(e.session(), "file.export_clips", &c.name, "export", c.length, crate::io::render_limit(channels))?; + } let n = crate::io::export_clips(e, &ids, &dir)?; Ok(json!({"written": n, "dir": dir})) }), @@ -194,6 +198,8 @@ pub fn specs() -> Vec { /// Writing a session over an existing file needs `replace: true` (see `io::replaces`). fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> { + // First, so a path outside the write root is refused as such and never reported as existing. + crate::file_access::check_write(file).map_err(EngineError::Io)?; if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) { return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display()))); } @@ -231,7 +237,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result { 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}")))?; + let bytes = crate::io::read_file(path)?; let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?; out.push(r); diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index fe9fdc2..c442ecb 100644 --- a/crates/engine/src/cmd/midi.rs +++ b/crates/engine/src/cmd/midi.rs @@ -13,7 +13,7 @@ pub fn specs() -> Vec { cmd!( "midi.new_clip", "New MIDI Clip", - [], + ["Clip"], None, "{track, start?, end?|length?} (default: the edit selection, or 1 bar)", has_tracks, diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 1b9b56d..c056942 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -100,7 +100,7 @@ pub fn specs() -> Vec { "Set Plugin Parameters", [], None, - "{track?, slot, params: {id: value}, preset?: name}", + "{track?, slot, params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)", has_selection, insert_params ), @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Add Automation Breakpoint", [], None, - "{track?, param: volume|pan|mute|send_a_level|plugin:a:, at, value}", + "{track?, param: volume|pan|mute|send_a_level|plugin:a:, at, value, from?}", has_selection, set_point ), @@ -360,7 +360,8 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result { Ok(json!({"value": v})) } -/// Apply many parameters at once (preset recall); unknown ids are ignored, values clamped. +/// Apply many parameters at once (preset recall); values are clamped. Unknown ids and +/// non-numeric values are errors, not silent no-ops: a typo must not look like success. fn insert_params(e: &mut Engine, p: &Value) -> Result { let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?; let obj = p.get("params").and_then(Value::as_object).cloned().ok_or_else(|| bad("mix.insert_params", "`params` must be an object"))?; @@ -372,11 +373,29 @@ fn insert_params(e: &mut Engine, p: &Value) -> Result { .and_then(Option::as_mut) .ok_or_else(|| bad("mix.insert_params", "empty slot"))?; let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?; + let mut unknown: Vec<&str> = Vec::new(); + let mut non_numeric: Vec<&str> = Vec::new(); + for (k, v) in &obj { + if !info.params.iter().any(|x| x.id == k.as_str()) { + unknown.push(k.as_str()); + } else if v.as_f64().is_none_or(|f| !f.is_finite()) { + non_numeric.push(k.as_str()); + } + } + if !unknown.is_empty() { + unknown.sort_unstable(); + return Err(bad("mix.insert_params", format!("`{}` has no parameter(s): {}", info.id, unknown.join(", ")))); + } + if !non_numeric.is_empty() { + non_numeric.sort_unstable(); + return Err(bad("mix.insert_params", format!("parameter(s) need a numeric value: {}", non_numeric.join(", ")))); + } let mut n = 0; for (k, v) in obj { - let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) else { continue }; - ins.params.insert(k, (f as f32).clamp(pi.min, pi.max)); - n += 1; + if let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) { + ins.params.insert(k, (f as f32).clamp(pi.min, pi.max)); + n += 1; + } } if let Some(name) = preset { ins.preset = name; @@ -442,15 +461,21 @@ fn lane_param(p: &Value, cmd: &str) -> Result { str_param(p, "param").map_or(Ok(AutoParam::Volume), |s| AutoParam::parse(s).ok_or_else(|| bad(cmd, format!("unknown automation param `{s}`")))) } +/// With `from` (a write pass's previous point), the points between `from` and `at` are removed first. fn set_point(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "automation.set_point", p)?; let param = lane_param(p, "automation.set_point")?; let at = position_param(e, "automation.set_point", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0); + let from = position_param(e, "automation.set_point", p, "from")?; let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.set_point", "`value` required"))? as f32; let s = e.session_mut(); for t in &tracks { if let Some(tr) = s.track_mut(*t) { - tr.lane_mut(¶m).set_point(at, v); + let lane = tr.lane_mut(¶m); + if let Some(from) = from { + lane.clear_range(from.saturating_add(1), at); + } + lane.set_point(at, v); } } Ok(json!({"at": at, "value": v})) @@ -788,6 +813,17 @@ mod tests { } } + #[test] + fn set_point_from_drops_the_points_in_between() { + let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]); + e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap(); + // A loop wrap: `from` lies after `at`, so nothing goes. + e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap(); + let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap(); + let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect(); + assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]); + } + #[test] fn clip_gain_points_inside_the_selection_are_kept() { let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]); @@ -802,6 +838,42 @@ mod tests { } } + #[test] + fn insert_params_rejects_unknown_and_non_numeric_keys() { + let mut e = Engine::default(); + e.execute("track.new", &json!({})).unwrap(); + let t = e.session().tracks[0].id.0; + e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap(); + fn gain(e: &Engine) -> f32 { + e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"] + } + + // A typo names no parameter: the call fails and says which key. + let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err(); + assert!(err.to_string().contains("gain_db"), "{err}"); + + // Mixed keys fail as a whole and change nothing. + let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err(); + assert!(err.to_string().contains("width"), "{err}"); + assert_eq!(gain(&e), 0.0); + + // Non-numeric and non-finite values are rejected too. + assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err()); + assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err()); + assert_eq!(gain(&e), 0.0); + + // Correct keys still apply, clamped to the parameter range. + let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap(); + assert_eq!(r["set"], 1); + assert_eq!(gain(&e), -14.0); + e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap(); + assert_eq!(gain(&e), 24.0); + + // Empty params with a preset name still recall (the rename path). + e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap(); + assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved"); + } + #[test] fn retargeting_a_send_keeps_its_settings() { let mut e = Engine::default(); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index d0a453c..da65d60 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -36,6 +36,8 @@ mod view; mod view_more; mod vst3; +pub(crate) use edit::remember_selection; + pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. pub type Enabled = fn(&Engine) -> std::result::Result<(), String>; @@ -275,7 +277,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result) { } } -/// Longest audio we render in one stretch (one hour at the session rate). +/// Longest audio we render in one stretch: one hour at the session rate, and no more than +/// `time_stretch` makes. fn max_render(s: &Session) -> Samples { - s.sample_rate.samples(3600.0) + s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples) } /// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new @@ -142,8 +143,10 @@ fn max_render(s: &Session) -> Samples { pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result> { let ClipContent::Audio { source, offset } = c.content else { return Ok(None) }; let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) }; - if out_len <= 0 || out_len > max_render(s) { - return Err(bad(cmd, "the target length must be between 1 sample and one hour")); + let max = max_render(s); + if out_len <= 0 || out_len > max { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(bad(cmd, format!("the target length must be between 1 sample and {secs:.0} s"))); } let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 }; let src_len = ((c.length as f64) / stretch).round().max(1.0); @@ -202,4 +205,12 @@ mod tests { assert_eq!(env_db(&[], 50), 0.0); assert_eq!(env_db(&env, 500), -10.0); } + + #[test] + fn stretches_are_no_longer_than_time_stretch_makes() { + let mut s = Session::default(); + assert_eq!(max_render(&s), s.sample_rate.samples(3600.0)); + s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap(); + assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples); + } } diff --git a/crates/engine/src/cmd/query.rs b/crates/engine/src/cmd/query.rs index 0501a28..72eb09e 100644 --- a/crates/engine/src/cmd/query.rs +++ b/crates/engine/src/cmd/query.rs @@ -34,7 +34,7 @@ pub fn specs() -> Vec { let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0); let s = e.session(); let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter); - Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)})) + Ok(json!({"text": s.format_position(at, f)})) }), ] } diff --git a/crates/engine/src/cmd/sampler.rs b/crates/engine/src/cmd/sampler.rs index ea659a8..7bac7e8 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -55,7 +55,7 @@ fn load(e: &mut Engine, p: &Value) -> Result { values.push((k.clone(), pi.clamp(x as f32))); } } - let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let bytes = crate::io::read_file(&path)?; let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 1d21e99..8dd1640 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result { let r = soundcraft_time::Range::new(0, end); let mut n = 0; for t in tracks { - if crate::io::consolidate(e, t, r)? { + // Each track keeps its own length (its material plus a second), as before. + let tr = crate::io::bounded_to_track(e.session(), t, r); + if crate::io::consolidate(e, id, t, tr)? { n += 1; } } @@ -318,7 +320,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result { let at = s.edit.selection.start.max(0); let frames_at = s.frame_rate.frames_at(at, s.sample_rate); let start = ft * 16 + fr - frames_at; - s.edit.values.insert("feet_frames.start".into(), start as f64); + s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64); Ok(json!({"feet_frames_start": start})) } @@ -404,7 +406,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result { /// Decode a source file of another session (paths relative to that session's folder). fn load_source(dir: &Path, src: &Source, sr: u32) -> Option { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; - let bytes = std::fs::read(&p).ok()?; + let bytes = crate::io::read_file(&p).ok()?; let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?; if buf.sample_rate != sr && buf.sample_rate > 0 { buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr); @@ -416,7 +418,7 @@ fn load_source(dir: &Path, src: &Source, sr: u32) -> Option Result { let id = "file.import_session_data"; let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string(); - let text_v = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text_v = crate::io::read_text(&path)?; let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default(); let names: Option> = @@ -556,8 +558,7 @@ mod tests { e.execute("edit.select", &json!({"start": 48_000})).unwrap(); e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap(); let s = e.session(); - let shown = - soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode); assert_eq!(shown, "01:00:00:00"); } diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs index 2f231f4..00c80db 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -98,7 +98,7 @@ pub fn specs() -> Vec { Ok(json!({"track": t})) }), cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))), - cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))), + cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, super::track_more::clear_trim), cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click), cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)), cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)), @@ -128,7 +128,10 @@ pub fn specs() -> Vec { }), cmd!("track.vca", "Assign to VCA", [], None, "{tracks?, vca: VCA track id|name | null}", has_selection, |e, p| { let tracks = tracks_required(e, "track.vca", p)?; - let vca = track_param(e, "track.vca", p, "vca")?; + let vca = match p.get("vca") { + Some(Value::Null) => None, + _ => track_param(e, "track.vca", p, "vca")?, + }; if let Some(v) = vca && e.session().track(v).is_none_or(|t| t.kind != TrackKind::Vca) { @@ -680,4 +683,24 @@ mod tests { assert!(matches!(kick.mixer.output, Route::Bus(_))); assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output)); } + + #[test] + fn vca_null_clears_the_assignment() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"name": "Member"})).unwrap(); + e.execute("track.new", &json!({"kind": "vca", "name": "Master"})).unwrap(); + let (member, master) = (e.session().tracks[0].id, e.session().tracks[1].id); + let vca = |e: &Engine| e.session().track(member).unwrap().mixer.vca; + e.execute("track.vca", &json!({"tracks": [member.0], "vca": master.0})).unwrap(); + assert_eq!(vca(&e), Some(master.0)); + // #237: null is documented as "no VCA"; anything else that is not a VCA is still an error. + for invalid in [json!(true), json!(999), json!("Missing"), json!(member.0)] { + assert!(e.execute("track.vca", &json!({"tracks": [member.0], "vca": invalid})).is_err()); + assert_eq!(vca(&e), Some(master.0)); + } + e.execute("track.vca", &json!({"tracks": [member.0], "vca": null})).unwrap(); + assert_eq!(vca(&e), None); + assert!(e.undo()); + assert_eq!(vca(&e), Some(master.0)); + } } diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs index 8818342..290a8d5 100644 --- a/crates/engine/src/cmd/track_more.rs +++ b/crates/engine/src/cmd/track_more.rs @@ -153,7 +153,7 @@ pub fn specs() -> Vec { "track.designate_target_playlist", "Designate as Target Playlist", ["Track"], - None, + Some("Cmd+Shift+Right"), "{tracks?, index?} — the active playlist (or `index`) becomes the target for Copy/Move Selection to", has_selection, designate_target @@ -162,7 +162,7 @@ pub fn specs() -> Vec { "track.show_target_playlist", "Show Target Playlist", ["Track"], - None, + Some("Shift+Right"), "{tracks?} — makes the target playlist the main playlist", has_selection, show_target @@ -271,7 +271,7 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result { let id = "track.load_preset"; let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string(); let tracks = tracks_required(e, id, p)?; - let text = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text = crate::io::read_text(&path)?; let v: Value = serde_json::from_str(&text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; if v.get("format").and_then(Value::as_str) != Some(PRESET_FORMAT) { return Err(EngineError::Io(format!("{path}: not a SoundCraft track preset"))); @@ -447,6 +447,21 @@ fn coalesce_trim(e: &mut Engine, p: &Value) -> Result { Ok(json!({"tracks": n})) } +pub(super) fn clear_trim(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.clear_trim_automation", p)?; + let s = e.session_mut(); + let mut n = 0; + for t in &tracks { + let Some(tr) = s.track_mut(*t) else { continue }; + let before = tr.automation.len(); + tr.automation.retain(|l| l.param != trim_param()); + if tr.automation.len() < before { + n += 1; + } + } + Ok(json!({"tracks": n})) +} + fn coalesce_vca(e: &mut Engine, p: &Value) -> Result { let id = "track.coalesce_vca"; let picked = tracks_param(e, id, p)?; @@ -649,6 +664,26 @@ mod tests { assert!(tr.lane(&trim_param()).is_none()); } + #[test] + fn clear_trim_automation_removes_only_trim() { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 2})).unwrap(); + let ids: Vec = e.session().tracks.iter().map(|t| t.id).collect(); + let (a, b) = (ids[0], ids[1]); + e.execute("automation.set_point", &json!({"track": a.0, "param": "volume", "at": 0, "value": -3.0})).unwrap(); + e.execute("track.trim_automation_write", &json!({"tracks": [a.0, b.0], "start": 0, "end": 4800, "db": -12.0})).unwrap(); + let has_trim = |e: &Engine, t: TrackId| e.session().track(t).unwrap().lane(&trim_param()).is_some_and(|l| !l.points.is_empty()); + e.execute("edit.select", &json!({"tracks": [a.0]})).unwrap(); + e.execute("track.clear_trim_automation", &json!({})).unwrap(); + assert!(!has_trim(&e, a), "the selected track loses its trim"); + assert!(has_trim(&e, b), "other tracks keep theirs"); + assert_eq!(e.session().track(a).unwrap().lane(&AutoParam::Volume).unwrap().points.len(), 1); + e.execute("track.clear_trim_automation", &json!({"tracks": [b.0]})).unwrap(); + assert!(!has_trim(&e, b), "an explicit track is cleared too"); + assert!(e.undo()); + assert!(has_trim(&e, b), "undo brings the trim back"); + } + #[test] fn target_and_recent_playlists() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/video.rs b/crates/engine/src/cmd/video.rs index 5277ca5..f361e0d 100644 --- a/crates/engine/src/cmd/video.rs +++ b/crates/engine/src/cmd/video.rs @@ -36,7 +36,7 @@ pub fn specs() -> Vec { .map(|v| { let clips = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| c.video_source() == Some(v.id)).count(); json!({"id": v.id, "name": v.name, "path": v.path, "width": v.width, "height": v.height, "frame_rate": v.frame_rate, - "duration": v.duration, "codec": v.codec, "clips": clips, "file_found": std::path::Path::new(&v.path).is_file()}) + "duration": v.duration, "codec": v.codec, "clips": clips, "file_found": crate::io::file_found(&v.path)}) }) .collect(); Ok(json!({"videos": rows, "online": !s.edit.flag(OFFLINE_FLAG)})) diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 7b36430..6dee816 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -72,8 +72,8 @@ pub fn specs() -> Vec { e.session_mut().edit.sub_counter = f; Ok(json!({"format": f})) }), - cmd!(noundo "view.grid", "Grid Value", [], None, "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, lines?: bool}", always, |e, p| { - let g = grid_param(p, "value").ok_or_else(|| bad("view.grid", "unknown grid value"))?; + cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| { + let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?; let lines = p.get("lines").and_then(Value::as_bool); let s = e.session_mut(); s.edit.grid = g; @@ -85,8 +85,8 @@ pub fn specs() -> Vec { s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines); Ok(json!({"value": s.edit.grid_lines})) }), - cmd!(noundo "view.nudge", "Nudge Value", [], None, "{value: like grid}", always, |e, p| { - let g = grid_param(p, "value").ok_or_else(|| bad("view.nudge", "unknown nudge value"))?; + cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| { + let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?; e.session_mut().edit.nudge = g; Ok(json!({"nudge": g.label()})) }), @@ -122,6 +122,12 @@ fn ruler(e: &mut Engine, p: &Value) -> Result { Ok(json!({"ruler": r, "visible": vis})) } +/// `value`, or the value `step` places larger (or smaller, when negative) than `current`. +fn value_or_step(p: &Value, current: GridValue) -> Option { + let Some(step) = p.get("step").and_then(Value::as_i64) else { return grid_param(p, "value") }; + Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0))) +} + /// Parse grid/nudge values. pub fn grid_param(p: &Value, key: &str) -> Option { let v = p.get(key)?; @@ -214,4 +220,26 @@ mod tests { e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap(); assert_eq!(state(&e), before); } + + #[test] + fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() { + let mut e = Engine::default(); + e.execute("view.grid", &json!({"value": "1/16"})).unwrap(); + e.execute("view.grid", &json!({"step": 2})).unwrap(); + assert_eq!(e.session().edit.grid.label(), "1/4 note"); + e.execute("view.grid", &json!({"step": -1})).unwrap(); + assert_eq!(e.session().edit.grid.label(), "1/8 note"); + e.execute("view.nudge", &json!({"value": {"seconds": 0.01}})).unwrap(); + e.execute("view.nudge", &json!({"step": i64::MIN})).unwrap(); + assert_eq!(e.session().edit.nudge, GridValue::Seconds(0.001)); + assert!(e.execute("view.nudge", &json!({"step": "up"})).is_err()); + let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone(); + e.execute("view.nudge", &json!({"value": "1/16"})).unwrap(); + e.execute("edit.select", &json!({"tracks": [t], "start": 0, "end": 1000})).unwrap(); + e.execute("edit.nudge", &json!({"direction": 1, "next": true})).unwrap(); + assert_eq!(e.session().edit.selection.start, 12_000); + e.execute("edit.nudge", &json!({"direction": -1})).unwrap(); + assert_eq!(e.session().edit.selection.start, 6_000); + assert_eq!(e.session().edit.nudge.label(), "1/16 note"); + } } diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index f78460d..44b6dc2 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -250,6 +250,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { n += 1; } } + pl.sort(); n } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 0000000..be4ea9d --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,487 @@ +//! The folders automation may read and write. +//! +//! `soundcraft-cli mcp --automation-read-root --automation-write-root ` confines the +//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in +//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]: +//! +//! - The path is made absolute against the working directory, as the file system reads it. Its +//! links are followed: for a file that doesn't exist yet, the links of its nearest existing +//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so +//! is a link that leads nowhere). +//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside +//! `/work`), without regard to case on Windows. +//! - Read and write access are separate: a root given alone grants only its own kind of access. +//! +//! Roots are in force on a thread inside [`confine`], and in the whole process after +//! [`confine_process`] (`soundcraft-cli mcp`), except inside [`unconfined`]. Without roots +//! nothing is checked. +//! +//! A path is checked, then opened: a process that can change the folders in between (swapping in +//! a link) can still win that race. The roots keep an agent to the files it was given; they don't +//! guard against other programs on the computer. + +use std::cell::RefCell; +use std::ffi::OsString; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +/// Read access or write access. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Read, + Write, +} + +/// The folders automation may read and write; a root left out grants no access of its kind. +#[derive(Debug)] +pub struct AutomationRoots { + read: Option, + write: Option, +} + +#[derive(Debug)] +struct Root { + /// The folder made absolute, links followed: what paths are compared with. + resolved: PathBuf, + /// The same without Windows' `\\?\` prefix, for people. + shown: PathBuf, +} + +impl Root { + /// The folder at `path`, which must exist. + fn open(path: &Path, access: Access) -> Result { + let kind = match access { + Access::Read => "read", + Access::Write => "write", + }; + let resolved = std::fs::canonicalize(path) + .and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) }) + .map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?; + Ok(Self { shown: plain(resolved.clone()), resolved }) + } +} + +impl AutomationRoots { + /// The roots `read` and `write`, which must be existing folders; `None` when neither is given + /// (nothing is confined then). + pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result>, String> { + if read.is_none() && write.is_none() { + return Ok(None); + } + let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; + let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; + Ok(Some(Arc::new(Self { read, write }))) + } + + /// The read root, absolute with its links followed. + pub fn read_root(&self) -> Option<&Path> { + self.read.as_ref().map(|r| r.shown.as_path()) + } + + /// The write root, absolute with its links followed. + pub fn write_root(&self) -> Option<&Path> { + self.write.as_ref().map(|r| r.shown.as_path()) + } + + /// May the file or folder at `path` be read (or written)? `Err` says why not. + pub fn check(&self, path: impl AsRef, access: Access) -> Result<(), String> { + let path = path.as_ref(); + let shown_path = path.display(); + let root = match access { + Access::Read => self.read.as_ref(), + Access::Write => self.write.as_ref(), + }; + let Some(Root { resolved: folder, shown }) = root else { + return match access { + Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")), + Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown_path}")), + }; + }; + if inside(&resolve(path)?, folder) { + return Ok(()); + } + let root = shown.display(); + match access { + Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")), + Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")), + } + } +} + +/// Where the roots are in force on a thread ([`confine`], [`unconfined`]). +#[derive(Clone, Debug)] +enum Scope { + Confined(Arc), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = OnceLock::new(); + +/// Put `roots` in force in the whole process, for good. `false` when roots were in force already +/// (they stay as they were). +pub fn confine_process(roots: Arc) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, f: impl FnOnce() -> T) -> T { + let _restore = Restore(SCOPE.with(|s| s.replace(scope))); + f() +} + +/// Run `f` with `roots` in force on this thread (`None`: as things stand). +pub fn confine(roots: Option<&Arc>, f: impl FnOnce() -> T) -> T { + match roots { + Some(r) => with_scope(Some(Scope::Confined(r.clone())), f), + None => f(), + } +} + +/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the +/// app's own folders. +pub fn unconfined(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + match SCOPE.with(|s| s.borrow().clone()) { + Some(Scope::Confined(r)) => Some(r), + Some(Scope::Unconfined) => None, + None => PROCESS.get().cloned(), + } +} + +/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read. +fn plain(path: PathBuf) -> PathBuf { + if !cfg!(windows) { + return path; + } + let text = path.to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return PathBuf::from(format!(r"\\{rest}")); + } + match text.strip_prefix(r"\\?\") { + Some(rest) => PathBuf::from(rest), + None => path, + } +} + +/// May `path` be read here? Always, without roots in force. +pub fn check_read(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path.as_ref(), Access::Read)) +} + +/// May `path` be written here? Always, without roots in force. +pub fn check_write(path: impl AsRef) -> Result<(), String> { + current().map_or(Ok(()), |r| r.check(path.as_ref(), Access::Write)) +} + +/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest +/// existing folder's links followed and the rest appended. +fn resolve(path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err("automation path rejected: path is empty".into()); + } + // Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows. + if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) { + return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display())); + } + let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?; + // The names below the nearest existing folder, last first. + let mut missing: Vec = vec![]; + let mut at = absolute.as_path(); + loop { + match std::fs::canonicalize(at) { + Ok(mut found) => { + found.extend(missing.iter().rev()); + return Ok(found); + } + Err(e) => match std::fs::symlink_metadata(at) { + Ok(m) if m.file_type().is_symlink() => { + return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display())); + } + // There, yet its real path can't be had (no permission): nothing to compare. + Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())), + }, + } + let parent = at.parent(); + match (at.components().next_back(), parent) { + (Some(Component::Normal(name)), Some(parent)) => { + missing.push(name.to_os_string()); + at = parent; + } + (Some(Component::ParentDir), _) => { + return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display())); + } + // Down to the root (or drive) and it doesn't exist either. + _ => { + let e = std::io::Error::from(std::io::ErrorKind::NotFound); + return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())); + } + } + } +} + +/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on +/// Windows, whose file systems ignore it)? +fn inside(path: &Path, root: &Path) -> bool { + let mut path = path.components(); + root.components().all(|r| path.next().is_some_and(|p| same_component(p, r))) +} + +fn same_component(a: Component, b: Component) -> bool { + if a == b { + return true; + } + if !cfg!(windows) { + return false; + } + match (a.as_os_str().to_str(), b.as_os_str().to_str()) { + (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(), + _ => false, + } +} + +/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file. +fn is_device_name(component: &str) -> bool { + let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase(); + let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")); + matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A fresh folder holding `in` (read root), `out` (write root) and `outside`. + fn folders(name: &str) -> (PathBuf, Arc) { + let base = std::env::temp_dir().join(format!("sc-file-access-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + for d in ["in/sub", "out", "outside", "in2"] { + std::fs::create_dir_all(base.join(d)).unwrap(); + } + std::fs::write(base.join("in/a.scraft"), "x").unwrap(); + std::fs::write(base.join("outside/secret.scraft"), "x").unwrap(); + let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap(); + (base, roots) + } + + fn s(p: PathBuf) -> String { + p.to_string_lossy().into_owned() + } + + #[test] + fn paths_inside_the_roots_pass_and_others_dont() { + let (base, roots) = folders("inside"); + assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(())); + assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself"); + assert_eq!(roots.check(s(base.join("in/sub/new.scraft")), Access::Read), Ok(()), "missing files are checked by their folder"); + assert_eq!(roots.check(s(base.join("out/new/deeper/x.wav")), Access::Write), Ok(()), "and by their nearest folder"); + let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err(); + assert!(e.starts_with("automation path rejected: outside the read root"), "{e}"); + // Read and write access are separate. + let e = roots.check(s(base.join("in/a.scraft")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "{e}"); + assert!(roots.check(s(base.join("out/x.wav")), Access::Read).unwrap_err().contains("outside the read root")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_sibling_with_the_same_prefix_is_outside() { + let (base, roots) = folders("prefix"); + std::fs::write(base.join("in2/b.scraft"), "x").unwrap(); + assert!(roots.check(s(base.join("in2/b.scraft")), Access::Read).is_err(), "in2 is not inside in"); + assert!(roots.check(format!("{}2/c.scraft", s(base.join("in"))), Access::Read).is_err()); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn parent_traversal_is_resolved_or_refused() { + let (base, roots) = folders("dotdot"); + let sep = std::path::MAIN_SEPARATOR; + let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in"))); + assert!(roots.check(up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root"); + let back = format!("{}{sep}..{sep}a.scraft", s(base.join("in/sub"))); + assert_eq!(roots.check(back, Access::Read), Ok(()), "`..` that stays inside"); + let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.scraft", s(base.join("out"))); + let e = roots.check(missing, Access::Write).unwrap_err(); + // Windows resolves `..` by name first (so does opening the file there): then it's outside. + assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn relative_paths_resolve_against_the_working_directory() { + let (base, roots) = folders("relative"); + // The tests' working directory is the crate's folder: not inside the roots. + assert!(roots.check("a.scraft", Access::Read).unwrap_err().contains("outside the read root")); + assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty")); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn a_missing_root_is_an_error_and_none_is_no_confinement() { + let (base, _) = folders("missing"); + let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err(); + assert!(e.starts_with("cannot open the automation read root"), "{e}"); + let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err(); + assert!( + e.starts_with("cannot open the automation write root") + && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), + "{e}" + ); + assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); + assert!(AutomationRoots::new(None, None).unwrap().is_none()); + // One root alone grants only its own access. + let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap(); + let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err(); + assert!(e.contains("write authority is absent"), "{e}"); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn links_are_followed_before_comparing() { + let (base, roots) = folders("links"); + #[cfg(unix)] + let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok(); + #[cfg(windows)] + let made = |target: &Path, link: &Path| { + let made = + if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) }; + made.is_ok() + }; + #[cfg(not(any(unix, windows)))] + let made = |_: &Path, _: &Path| false; + // Windows needs Developer Mode or elevation to make links: nothing to test without them. + if !made(&base.join("outside/secret.scraft"), &base.join("in/leak.scraft")) { + eprintln!("skipped: symbolic links can't be made here"); + let _ = std::fs::remove_dir_all(base); + return; + } + assert!(roots.check(s(base.join("in/leak.scraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out"); + assert!(made(&base.join("outside"), &base.join("out/away"))); + let e = roots.check(s(base.join("out/away/new.wav")), Access::Write).unwrap_err(); + assert!(e.contains("outside the write root"), "a folder link out: {e}"); + assert!(made(&base.join("outside/gone.scraft"), &base.join("out/dangling.wav"))); + let e = roots.check(s(base.join("out/dangling.wav")), Access::Write).unwrap_err(); + assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}"); + // A link to a folder inside the root stays inside. + assert!(made(&base.join("in/sub"), &base.join("in/alias"))); + assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(())); + // A root given through a link is the folder it leads to. + assert!(made(&base.join("in"), &base.join("in-link"))); + let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap(); + assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[cfg(windows)] + #[test] + fn windows_ignores_case_and_refuses_devices() { + let (base, roots) = folders("case"); + let upper = s(base.join("IN/a.scraft")); + assert_eq!(roots.check(upper, Access::Read), Ok(()), "{upper}"); + assert_eq!(roots.check(s(base.join("In/Sub/New.scraft")), Access::Read), Ok(())); + let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err(); + assert!(e.contains("reserved device names"), "{e}"); + assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err()); + assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn scopes_nest_and_stay_on_their_thread() { + let (base, roots) = folders("scopes"); + let outside = s(base.join("outside/secret.scraft")); + assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default"); + confine(Some(&roots), || { + assert!(check_read(&outside).is_err()); + assert_eq!(unconfined(|| check_read(&outside)), Ok(())); + assert!(check_read(&outside).is_err(), "restored after unconfined"); + let o = outside.clone(); + let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap(); + assert!(there, "another thread is not confined by this thread's scope"); + }); + assert_eq!(check_read(&outside), Ok(()), "restored after confine"); + let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom"))); + assert!(r.is_err()); + assert_eq!(check_read(&outside), Ok(()), "restored after a panic"); + assert_eq!(confine(None, || check_read(&outside)), Ok(())); + let _ = std::fs::remove_dir_all(base); + } + + #[test] + fn device_names() { + for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.scraft", "NUL "] { + assert!(is_device_name(d), "{d}"); + } + for f in ["console", "com10", "COM0", "lpt", "a.scraft", "nul-x"] { + assert!(!is_device_name(f), "{f}"); + } + } + + /// Every file a command reads or writes by path goes through `io` (`read_file`, `write_file` + /// and the other checked helpers there), which check the roots. Engine code that reaches for + /// the file system itself would bypass them, so none may: every file under `src/` except + /// `io.rs`, this module and the test files. Audio is decoded into the session when it enters + /// it (import, open, sampler load), so playback and bounce read nothing from disk and need no + /// check of their own. No engine module reads a fixed folder (plug-in scans live in the host + /// crates), so nothing else is skipped. + #[test] + fn commands_do_not_touch_the_file_system_themselves() { + fn walk(dir: &Path, files: &mut Vec) { + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + walk(&path, files); + } else if path.extension().is_some_and(|e| e == "rs") { + files.push(path); + } + } + } + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut files = vec![]; + walk(&src, &mut files); + assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}"); + let mut offenders = vec![]; + for path in files { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + // `io` and this module are the checked gateway; `tests*.rs` are test-only modules. + if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs") || name.starts_with("tests") { + continue; + } + let text = std::fs::read_to_string(&path).unwrap(); + // Test modules (which build their own fixtures) come last in each file. + let code = text.split("#[cfg(test)]").next().unwrap_or(""); + for (n, line) in code.lines().enumerate() { + let line = line.trim_start(); + let touches = + ["std::fs", "fs::", "File::open", "File::create", "OpenOptions", ".is_file()", ".exists()", ".is_dir()", "canonicalize"] + .iter() + .any(|w| line.contains(w)); + if !line.starts_with("//") && touches { + offenders.push(format!("{}:{}: {line}", path.display(), n + 1)); + } + } + } + assert!(offenders.is_empty(), "use the checked helpers in crate::io (read_file, write_file, ...): {offenders:#?}"); + } +} diff --git a/crates/engine/src/inspect.rs b/crates/engine/src/inspect.rs index 191adb8..9084a25 100644 --- a/crates/engine/src/inspect.rs +++ b/crates/engine/src/inspect.rs @@ -3,11 +3,11 @@ use crate::Engine; use serde_json::{Value, json}; use soundcraft_model::{Clip, ClipContent, ClipId, Track, TrackId}; -use soundcraft_time::{TimeFormat, format_position}; +use soundcraft_time::TimeFormat; fn clip_json(e: &Engine, c: &Clip) -> Value { let s = e.session(); - let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let fmt = |x| s.format_position(x, TimeFormat::MinSecs); let mut v = json!({ "id": c.id, "name": c.name, "start": c.start, "end": c.end(), "length": c.length, "start_time": fmt(c.start), "gain_db": c.gain_db, "muted": c.muted, @@ -110,7 +110,7 @@ pub fn clip(e: &Engine, id: ClipId) -> Option { /// Plain-text session report (File › Export › Session Info as Text). pub fn session_text(e: &Engine) -> String { let s = e.session(); - let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let fmt = |x| s.format_position(x, TimeFormat::MinSecs); let mut out = String::new(); out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n", s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(), diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index 108330e..ca5d7fe 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -11,6 +11,50 @@ use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. const MAX_IMPORT_SAMPLES: usize = 1 << 30; +// ─── Checked file access ────────────────────────────────────────────────────────────────────── +// +// Every file a command names by path is read and written through the helpers below, so the +// automation roots (`crate::file_access`, `soundcraft-cli mcp --automation-read-root` and +// `--automation-write-root`) are checked in one place. A test in `file_access` keeps the rest of +// the engine from calling `std::fs` itself. Audio is decoded into the session's pool the moment it +// enters it (import, open, sampler load), so playback and bounce read nothing from disk and +// need no check of their own. + +/// The bytes of the file at `path`, which must be inside the automation read root when there is one. +pub fn read_file(path: impl AsRef) -> Result> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(EngineError::Io)?; + std::fs::read(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))) +} + +/// [`read_file`] for a text file. +pub fn read_text(path: impl AsRef) -> Result { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(EngineError::Io)?; + std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))) +} + +/// Is there a file at `path` the automation read root lets us see? A path outside the root is +/// reported as not found, so a command can't probe for files beyond it. +pub fn file_found(path: impl AsRef) -> bool { + let path = path.as_ref(); + crate::file_access::check_read(path).is_ok() && path.is_file() +} + +/// Create `dir` and its parents, inside the automation write root when there is one. +pub(crate) fn make_dir(dir: impl AsRef) -> Result<()> { + let dir = dir.as_ref(); + crate::file_access::check_write(dir).map_err(EngineError::Io)?; + std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display()))) +} + +/// Write `bytes` to the file at `path`, inside the automation write root when there is one. +pub(crate) fn write_path(path: impl AsRef, bytes: impl AsRef<[u8]>) -> Result<()> { + let path = path.as_ref(); + crate::file_access::check_write(path).map_err(EngineError::Io)?; + std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display()))) +} + /// A relative `Audio Files/` path no other source in `s` already uses. fn unique_audio_rel(s: &Session, stem: &str) -> String { let stem = sanitize_name(stem); @@ -152,12 +196,13 @@ pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec { let mut missing = Vec::new(); for v in &mut s.videos { let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) }; - if p.is_file() { + if file_found(&p) { continue; } let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default(); - let near = - [session_dir.join(&name), session_dir.join("Video Files").join(&name)].into_iter().find(|c| !name.as_os_str().is_empty() && c.is_file()); + let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)] + .into_iter() + .find(|c| !name.as_os_str().is_empty() && file_found(c)); match near { Some(c) => v.path = c.to_string_lossy().into_owned(), None => missing.push(format!("{} (video)", p.display())), @@ -303,7 +348,7 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re /// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`. pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result> { let s = e.session(); - std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; + make_dir(dir)?; let ext = match opts.format { FileFormat::Aiff => "aif", FileFormat::Flac => "flac", @@ -325,7 +370,7 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch }; let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?; let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name))); - std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; + write_path(&path, bytes)?; out.push(path.to_string_lossy().into_owned()); } Ok(out) @@ -349,20 +394,13 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec usize { +pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result { let r = bounded_to_track(s, t, r); - let audio = soundcraft_mix::render_clips(s, t, r); - let keep = soundcraft_dsp::offline::non_silent_ranges( - &audio, - thr, - usize::try_from(min_len).unwrap_or(0), - usize::try_from(pre).unwrap_or(0), - usize::try_from(post).unwrap_or(0), - ); + let keep = non_silent(s, t, r, thr, min_len, pre, post)?; let mut cut_points = vec![r.start]; for (a, b) in &keep { - cut_points.push(r.start + *a as i64); - cut_points.push(r.start + *b as i64); + cut_points.push(*a); + cut_points.push(*b); } cut_points.push(r.end); for p in &cut_points { @@ -372,11 +410,10 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S let mut gaps = Vec::new(); let mut prev = r.start; for (a, b) in &keep { - let a = r.start + *a as i64; - if a > prev { - gaps.push(Range::new(prev, a)); + if *a > prev { + gaps.push(Range::new(prev, *a)); } - prev = r.start + *b as i64; + prev = *b; } if prev < r.end { gaps.push(Range::new(prev, r.end)); @@ -384,21 +421,67 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) { pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end)); } - keep.len() + Ok(keep.len()) } -/// Consolidate the selection on a track into one new clip backed by a rendered source. -pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { +/// Most samples (over all channels) Strip Silence renders at once. +const STRIP_WINDOW_SAMPLES: usize = 1 << 24; + +/// The non-silent ranges of `t`'s clips in `r`. The audio is rendered a window at a time where +/// audio clips play, so all of `r` is scanned however long it is (one render stops at +/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session. +fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result> { + let Some(tr) = s.track(t) else { return Ok(Vec::new()) }; + let mut clips: Vec = tr.clips().iter().filter(|c| c.is_audio() && !c.muted).filter_map(|c| c.range().intersect(&r)).collect(); + clips.sort_by_key(|x| x.start); + let mut spans: Vec = Vec::new(); + for x in clips { + match spans.last_mut() { + Some(last) if x.start <= last.end => last.end = last.end.max(x.end), + _ => spans.push(x), + } + } + if spans.iter().map(Range::len).fold(0, i64::saturating_add) > s.sample_rate.samples(86_400.0) { + return Err(EngineError::BadParams("edit.strip_silence".into(), format!("`{}` has more than a day of clips to scan", tr.name))); + } + let window = i64::try_from(STRIP_WINDOW_SAMPLES / tr.channels().max(1)).unwrap_or(i64::MAX); + let len = |n: Samples| u64::try_from(n).unwrap_or(0); + let mut scan = soundcraft_dsp::offline::SilenceScan::new(thr, len(min_len)); + let mut at = r.start; + for x in spans { + scan.skip(len(x.start - at)); + at = x.start; + while at < x.end { + let end = at.saturating_add(window).min(x.end); + scan.push(&soundcraft_mix::render_clips(s, t, Range::new(at, end))); + at = end; + } + } + scan.skip(len(r.end - at)); + Ok(scan.finish(len(pre), len(post)).into_iter().map(|(a, b)| (r.start.saturating_add_unsigned(a), r.start.saturating_add_unsigned(b))).collect()) +} + +/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the +/// whole range, silence included; a range too long to render is refused rather than shortened. +pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result { let s = e.session(); - if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) { + let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) }; + let start = r.start.max(0); + let len = r.end.saturating_sub(start); + if len <= 0 { return Ok(false); } - let r = bounded_to_track(s, t, r); - if r.is_empty() { - return Ok(false); + let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1); + if usize::try_from(len).map_or(true, |n| n > max) { + let secs = max as f64 / s.sample_rate.as_f64(); + return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name))); } + let r = Range::new(start, start + len); let audio = soundcraft_mix::render_clips(s, t, r); - let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str())); + if audio.iter().any(|ch| ch.len() as i64 != len) { + return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into())); + } + let name = format!("{}_consolidated", tr.name); let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio }; let s = e.session_mut(); let src = add_source(s, &name, buf, None, FileFormat::Wav); @@ -408,13 +491,28 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result { Ok(true) } +/// The most frames of `channels`-channel audio one offline render holds. +pub fn render_limit(channels: usize) -> usize { + soundcraft_mix::MAX_RENDER_SAMPLES / channels.max(1) +} + +/// Refuses `len` frames of `name` past `max`, where a render would cut the audio short. +pub fn refuse_longer(s: &Session, cmd: &str, name: &str, verb: &str, len: Samples, max: usize) -> Result<()> { + if len <= Samples::try_from(max).unwrap_or(Samples::MAX) { + return Ok(()); + } + let secs = max as f64 / s.sample_rate.as_f64(); + Err(crate::cmd::bad(cmd, format!("`{name}` is too long to {verb} (at most {secs:.0} s)"))) +} + /// Render clip gain into a new source and reset the clip's gain. -pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result { +pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result { let s = e.session(); let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) }; if !c.is_audio() { return Ok(false); } + refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?; let mut c2 = c.clone(); c2.fade_in = Default::default(); c2.fade_out = Default::default(); @@ -449,6 +547,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { return Ok(None); } let range = Range::new(0, end + s.sample_rate.samples(2.0)); + // The track renders through a main mix of at least two channels. + refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?; let audio = soundcraft_mix::render_track_pre_fader(s, t, range); let name = format!("{}.cm", tr.name); let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2)); @@ -479,7 +579,7 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result> { pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result { let s = e.session(); - std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?; + make_dir(dir)?; let mut n = 0; for id in ids { let Some((t, c)) = s.find_clip(*id) else { continue }; @@ -496,7 +596,7 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> return Err(EngineError::Io(format!("{dir}: too many files named {stem}"))); } } - std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?; + write_path(&path, bytes)?; n += 1; } Ok(n) @@ -541,6 +641,8 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result { /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { let p = session_file(path); + // Before any audio is written: a session saved outside the write root changes nothing. + crate::file_access::check_write(&p).map_err(EngineError::Io)?; let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let audio_dir = dir.join("Audio Files"); let mut s = session.clone(); @@ -568,7 +670,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess continue; } let Some(audio) = s.pool.get(src.id) else { continue }; - std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?; + make_dir(&audio_dir)?; let mut stem = sanitize_name(&src.name); if overwrite { // One file per source id so autosaves never collide or pile up. @@ -595,7 +697,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess s.name = stem.to_string(); } let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; - std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; + make_dir(&dir)?; // Atomic-ish write: temp file then rename. let tmp = p.with_extension("scraft.tmp"); if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) { @@ -608,6 +710,8 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess /// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the /// old or the new contents, never a mix. pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> { + crate::file_access::check_write(file).map_err(EngineError::Io)?; + crate::file_access::check_write(tmp).map_err(EngineError::Io)?; if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { let _ = std::fs::remove_file(tmp); return Err(EngineError::Io(format!("{}: {err}", file.display()))); @@ -636,6 +740,10 @@ fn back_up(file: &Path) { return; }; let backups = dir.join("Backups"); + if let Err(err) = crate::file_access::check_write(&backups) { + log::warn!("no backup of {}: {err}", file.display()); + return; + } let prefix = format!("{stem} backup "); let suffix = format!(".{ext}"); let numbers: Vec = std::fs::read_dir(&backups) @@ -659,7 +767,9 @@ fn back_up(file: &Path) { /// extension and isn't an existing file. pub fn session_file(path: &str) -> PathBuf { let p = PathBuf::from(path); - if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p } + // Only a path the write root allows is probed, so a refused one can't be used to test for files. + let is_file = crate::file_access::check_write(&p).is_ok() && p.is_file(); + if p.extension().is_none() && !is_file { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p } } /// True when `command` would write a session over the existing `file`. Save, Save As and Save @@ -708,12 +818,17 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> { /// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal). pub fn open_session(e: &mut Engine, path: &str) -> Result> { - let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?; + let text = read_text(path)?; let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?; let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); let mut missing = Vec::new(); for src in s.sources.clone() { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; + // A session file is hostile input too: a path in it that leaves the read root is not read. + if let Err(why) = crate::file_access::check_read(&p) { + missing.push(why); + continue; + } match std::fs::read(&p) { Ok(bytes) => { if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) { @@ -774,6 +889,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); return deliver(name, bytes).map_err(EngineError::Io); } + crate::file_access::check_write(path).map_err(EngineError::Io)?; std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}"))) } @@ -1139,7 +1255,10 @@ mod save_tests { open_session(&mut missing, &path("A/A.scraft")).unwrap(); save_session(&mut missing, &path("B/Moved.scraft")).unwrap(); let mut r = Engine::default(); - assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]); + assert_eq!( + open_session(&mut r, &path("B/Moved.scraft")).unwrap(), + vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()] + ); let _ = std::fs::remove_dir_all(&dir); } @@ -1229,6 +1348,43 @@ mod stem_tests { } } +#[cfg(test)] +mod strip_tests { + use super::*; + + /// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone. + fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) { + let mut e = Engine::default(); + e.execute("track.new", &json!({"format": "stereo"})).unwrap(); + let t = e.session().tracks[0].id; + let s = e.session_mut(); + let tone = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 48_000]; 2] }; + let src = add_source(s, "tone", tone, None, FileFormat::Wav); + for at in starts { + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "tone", src, 0, *at, length)); + } + (e, t) + } + + #[test] + fn clips_past_what_one_render_holds_are_kept() { + // 100 minutes in: a stereo render stops at 2^28 frames, about 93 minutes. + let far = 48_000 * 6000; + let (mut e, t) = tones(&[0, far], 48_000); + let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": far + 96_000})).unwrap(); + assert_eq!(r["clips"], 2); + assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far)); + } + + #[test] + fn more_than_a_day_of_clips_is_refused() { + let (mut e, t) = tones(&[0], 48_000 * 86_401); + assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 48_000i64 * 86_402})).is_err()); + assert_eq!(e.session().track(t).unwrap().clips().len(), 1); + } +} + #[cfg(test)] mod bundle_tests { use super::*; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b607534..b73384c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -11,6 +11,7 @@ pub mod clip_group_file; pub mod cmd; pub mod demo; pub mod edit; +pub mod file_access; pub mod inspect; pub mod io; pub mod score; @@ -118,8 +119,13 @@ pub struct Engine { dirty_at: Cell<(u64, bool)>, /// Gesture key for undo coalescing (see `execute_merged`). merge_key: Option, + /// The key every merged gesture takes while held (see `hold_merge`). + merge_hold: Option, /// Messages for the UI status line / agents. pub messages: Vec, + /// The edit selection (range, tracks) before the last change, for Restore Last Selection. + /// Kept out of the document so selecting never marks the session modified. + pub last_selection: Option<(soundcraft_time::Range, Vec)>, } impl Default for Engine { @@ -147,7 +153,9 @@ impl Engine { dirty: false, dirty_at: Cell::new((0, false)), merge_key: None, + merge_hold: None, messages: Vec::new(), + last_selection: None, } } @@ -181,6 +189,7 @@ impl Engine { self.saved = Arc::clone(&self.doc); self.undo.clear(); self.redo.clear(); + self.last_selection = None; self.revision = self.revision.wrapping_add(1); self.dirty = false; } @@ -266,7 +275,6 @@ impl Engine { if spec.view { return self.execute_view(spec, params); } - self.merge_key = None; let before = Arc::clone(&self.doc); let rev = self.revision; let dirty = self.dirty; @@ -280,8 +288,13 @@ impl Engine { return Err(EngineError::Internal(id.to_string(), panic_message(&*p))); } }; + if !Arc::ptr_eq(&before, &self.doc) { + // Only a change to the document ends a gesture, so Stop doesn't split the pass it ends. + self.merge_key = None; + } match result { Ok(v) => { + cmd::remember_selection(self, &before); if spec.undoable && !Arc::ptr_eq(&before, &self.doc) { self.push_undo(spec.label, before); } @@ -315,17 +328,26 @@ impl Engine { /// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls /// with the same `key` collapse into a single undo step. pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result { - let continuing = self.merge_key.as_deref() == Some(key); + let key = self.merge_hold.clone().unwrap_or_else(|| key.to_string()); + let continuing = self.merge_key.as_ref() == Some(&key); let before_len = self.undo.len(); let r = self.execute(id, params)?; if continuing && self.undo.len() == before_len + 1 && before_len > 0 { // Drop the step just pushed; the earlier one still holds the pre-gesture document. self.undo.pop(); } - self.merge_key = Some(key.to_string()); + self.merge_key = Some(key); Ok(r) } + /// While `key` is held, every `execute_merged` gesture takes it instead of its own, so they + /// all join one undo step (an automation pass and whatever is dragged during it). + pub fn hold_merge(&mut self, key: Option<&str>) { + if self.merge_hold.as_deref() != key { + self.merge_hold = key.map(str::to_string); + } + } + /// End a gesture started with `execute_merged`. pub fn end_merge(&mut self) { self.merge_key = None; @@ -400,6 +422,34 @@ mod merge_tests { e.undo(); assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0); } + + #[test] + fn a_held_merge_joins_other_gestures_into_its_step() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + e.hold_merge(Some("pass")); + for db in [-1.0, -2.0, -3.0] { + e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap(); + e.execute_merged("mix.pan", &json!({"track": t.0, "pan": db / 4.0}), "pan").unwrap(); + } + assert_eq!(e.undo_history().len(), 1); + e.hold_merge(None); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -6.0}), "fader").unwrap(); + assert_eq!(e.undo_history().len(), 2); + } + + #[test] + fn only_a_change_to_the_document_ends_a_gesture() { + let mut e = Engine::default(); + let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -1.0}), "fader").unwrap(); + e.execute("transport.stop", &json!({})).unwrap(); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -2.0}), "fader").unwrap(); + assert_eq!(e.undo_history().len(), 1); + e.execute("mix.mute", &json!({"track": t.0})).unwrap(); + e.execute_merged("mix.volume", &json!({"track": t.0, "db": -3.0}), "fader").unwrap(); + assert_eq!(e.undo_history().len(), 3); + } } #[cfg(test)] diff --git a/crates/engine/tests/consolidate_selection.rs b/crates/engine/tests/consolidate_selection.rs new file mode 100644 index 0000000..39af2f7 --- /dev/null +++ b/crates/engine/tests/consolidate_selection.rs @@ -0,0 +1,124 @@ +//! Consolidate makes one clip per track that covers exactly the edit selection (#164). + +use serde_json::json; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_engine::{Engine, EngineError}; +use soundcraft_model::{Clip, TrackId}; +use soundcraft_time::Range; + +fn stereo_tracks(n: u64) -> Engine { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": n, "format": "stereo"})).unwrap(); + e.session_mut().edit.selected_tracks = e.session().tracks.iter().map(|t| t.id).collect(); + e +} + +fn sr(e: &Engine) -> i64 { + e.session().sample_rate.samples(1.0) +} + +/// Put a constant-level clip of `secs` seconds at the start of track `i`. +fn add_clip(e: &mut Engine, i: usize, secs: i64) -> TrackId { + let frames = usize::try_from(secs * sr(e)).unwrap(); + let t = e.session().tracks[i].id; + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; frames]; 2] }; + let src = soundcraft_engine::io::add_source(e.session_mut(), "tone", buf, None, FileFormat::Wav); + let id = e.session_mut().new_clip_id(); + soundcraft_engine::edit::place_clip(e.session_mut(), t, Clip::audio(id, "tone", src, 0, 0, frames as i64)); + t +} + +fn clips(e: &Engine, i: usize) -> Vec<(i64, i64)> { + e.session().tracks[i].clips().iter().map(|c| (c.start, c.end())).collect() +} + +/// Frames in the source behind the only clip on track `i`, and its first sample. +fn source_of_only_clip(e: &Engine, i: usize) -> (usize, f32) { + let s = e.session(); + let c = &s.tracks[i].clips()[0]; + let audio = s.pool.get(c.source().unwrap()).unwrap(); + (audio.buffer.frames(), audio.buffer.channels[0][0]) +} + +fn assert_each_track_has(e: &Engine, start: i64, end: i64) { + for (i, t) in e.session().tracks.iter().enumerate() { + assert_eq!(clips(e, i), vec![(start, end)], "track {}", t.name); + assert_eq!(source_of_only_clip(e, i).0 as i64, end - start, "track {}", t.name); + } +} + +#[test] +fn consolidating_silence_from_zero_covers_the_whole_selection() { + let mut e = stereo_tracks(5); + let sr = sr(&e); + e.session_mut().edit.selection = Range::new(0, 5 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 0, 5 * sr); +} + +#[test] +fn consolidating_silence_later_in_the_timeline_covers_the_whole_selection() { + let mut e = stereo_tracks(5); + let sr = sr(&e); + e.session_mut().edit.selection = Range::new(2 * sr, 7 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 2 * sr, 7 * sr); +} + +#[test] +fn consolidating_past_the_end_of_a_clip_keeps_the_trailing_silence() { + let mut e = stereo_tracks(1); + let sr = sr(&e); + add_clip(&mut e, 0, 1); + e.session_mut().edit.selection = Range::new(0, 5 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + assert_each_track_has(&e, 0, 5 * sr); + assert_eq!(source_of_only_clip(&e, 0).1, 0.25); +} + +#[test] +fn multi_track_consolidate_is_one_undo_step_and_fills_empty_tracks() { + let mut e = stereo_tracks(3); + let sr = sr(&e); + add_clip(&mut e, 1, 2); + let before = e.session().clone(); + let undo_depth = e.undo_history().len(); + e.session_mut().edit.selection = Range::new(sr, 4 * sr); + e.execute("edit.consolidate", &json!({})).unwrap(); + // The clip on track 2 is split at the selection start; the head stays put. + assert_eq!(clips(&e, 0), vec![(sr, 4 * sr)]); + assert_eq!(clips(&e, 1), vec![(0, sr), (sr, 4 * sr)]); + assert_eq!(clips(&e, 2), vec![(sr, 4 * sr)]); + assert_eq!(e.undo_history().len(), undo_depth + 1); + assert!(e.undo()); + for (a, b) in e.session().tracks.iter().zip(before.tracks.iter()) { + assert_eq!(a.clips(), b.clips(), "track {}", a.name); + } +} + +#[test] +fn a_selection_too_long_to_render_is_refused_not_shortened() { + let mut e = stereo_tracks(2); + add_clip(&mut e, 0, 1); + let before: Vec<_> = (0..2).map(|i| clips(&e, i)).collect(); + // A stereo track renders at most MAX_RENDER_SAMPLES / 2 frames (about 93 minutes at 48 kHz). + let max = i64::try_from(soundcraft_mix::MAX_RENDER_SAMPLES / 2).unwrap(); + let err = e.execute("edit.consolidate", &json!({"start": 0, "end": max + 1})).unwrap_err(); + assert!(matches!(err, EngineError::BadParams(..)), "{err}"); + assert!(err.to_string().contains("too long"), "{err}"); + let after: Vec<_> = (0..2).map(|i| clips(&e, i)).collect(); + assert_eq!(after, before); +} + +#[test] +fn prepare_dpe_still_consolidates_each_track_to_its_own_material() { + let mut e = stereo_tracks(2); + let sr = sr(&e); + let a = add_clip(&mut e, 0, 1); + let b = add_clip(&mut e, 1, 3); + e.execute("options.prepare_dpe_tracks", &json!({"tracks": [a.0, b.0]})).unwrap(); + // Unchanged from before #164: each track runs to its last clip plus a second, capped at the session end. + assert_eq!(clips(&e, 0), vec![(0, 2 * sr)]); + assert_eq!(clips(&e, 1), vec![(0, 3 * sr)]); + assert_eq!(source_of_only_clip(&e, 0), (2 * sr as usize, 0.25)); +} diff --git a/crates/engine/tests/current_feet_frames_position.rs b/crates/engine/tests/current_feet_frames_position.rs new file mode 100644 index 0000000..467c49b --- /dev/null +++ b/crates/engine/tests/current_feet_frames_position.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::{Engine, model::Session}; +use soundcraft_time::{FrameRate, SampleRate}; + +fn feet_frames(engine: &mut Engine, at: i64) -> serde_json::Value { + engine.execute("session.format_time", &json!({"at": at, "format": "feet_frames"})).unwrap()["text"].clone() +} + +#[test] +fn current_feet_frames_sets_the_reading_at_the_insertion_point() { + let mut session = Session::new("Feet frames", SampleRate::HZ_48000); + session.frame_rate = FrameRate::Fps30; + let mut engine = Engine::new(session); + engine.execute("edit.select", &json!({"start": 48_000})).unwrap(); + assert_eq!(feet_frames(&mut engine, 48_000), "1+14"); + + let response = engine.execute("setup.current_feet_frames", &json!({"feet_frames": "12+08"})).unwrap(); + assert_eq!(response, json!({"feet_frames_start": 170})); + + assert_eq!(feet_frames(&mut engine, 48_000), "12+08"); + // 30 frames later, at 16 frames per foot. + assert_eq!(feet_frames(&mut engine, 96_000), "14+06"); + assert_eq!(engine.session().edit.selection.start, 48_000); + let samples = engine.execute("session.format_time", &json!({"at": 48_000, "format": "samples"})).unwrap(); + assert_eq!(samples["text"], "48000"); + + // Typed positions go through the same reference. + engine.execute("edit.select", &json!({"start": "feet_frames:14+06"})).unwrap(); + assert_eq!(engine.session().edit.selection.start, 96_000); +} diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/long_renders.rs new file mode 100644 index 0000000..c2c9fce --- /dev/null +++ b/crates/engine/tests/long_renders.rs @@ -0,0 +1,49 @@ +//! Renders longer than one offline buffer holds are refused, rather than cut short and kept. + +use serde_json::{Value, json}; +use soundcraft_audio_io::{AudioBuffer, FileFormat}; +use soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES; +use soundcraft_engine::Engine; +use soundcraft_mix::MAX_RENDER_SAMPLES; +use soundcraft_model::{ChannelFormat, Clip, ClipId, Session, TrackKind}; + +/// A stereo track "Talk" with one clip `frames` long. Its source is one second: the commands +/// refuse on the clip's length before they render anything. +fn long_clip(frames: usize) -> (Engine, ClipId) { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Talk")); + let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]; 2] }; + let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav); + let id = s.new_clip_id(); + soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64)); + (Engine::new(s), id) +} + +fn refused(e: &mut Engine, cmd: &str, params: &Value) { + let before = serde_json::to_value(e.session()).unwrap(); + let err = e.execute(cmd, params).expect_err(cmd).to_string(); + assert!(err.contains("is too long to"), "{cmd}: {err}"); + assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "{cmd} changed the session"); +} + +#[test] +fn clips_longer_than_one_render_are_refused() { + let (mut e, id) = long_clip(MAX_RENDER_SAMPLES / 2 + 1); + let dir = std::env::temp_dir().join(format!("soundcraft-long-renders-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + refused(&mut e, "clip.gain_render", &json!({"clips": [id.0]})); + refused(&mut e, "clip.effects_render", &json!({"clips": [id.0]})); + refused(&mut e, "audiosuite.process", &json!({"process": "reverse", "clips": [id.0]})); + refused(&mut e, "track.commit", &json!({"tracks": ["Talk"]})); + refused(&mut e, "file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [id.0]})); + assert!(!dir.exists(), "Export Clips started writing before it refused"); +} + +#[test] +fn audiosuite_refuses_results_longer_than_one_render() { + let (mut e, id) = long_clip(MAX_OFFLINE_SAMPLES / 4 + 1); + let fourfold = [("time_stretch", json!({"ratio": 4.0})), ("pitch_shift", json!({"semitones": 24.0})), ("varispeed", json!({"speed": 0.25}))]; + for (process, params) in fourfold { + refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]})); + } +} diff --git a/crates/engine/tests/midi_offset.rs b/crates/engine/tests/midi_offset.rs new file mode 100644 index 0000000..e4a1b1b --- /dev/null +++ b/crates/engine/tests/midi_offset.rs @@ -0,0 +1,30 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_time::Range; + +fn render(e: &Engine) -> Vec> { + soundcraft_mix::render_range(e.session(), Range::new(0, 72_000), 512) +} + +#[test] +fn midi_track_offset_moves_the_notes_like_moving_the_clip() { + let mut e = Engine::default(); + let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Timing", "instrument": "subtractive_synth"})).unwrap(); + let track = r["tracks"][0].as_u64().unwrap(); + let r = e.execute("midi.new_clip", &json!({"track": track, "start": 12_000, "length": 96_000})).unwrap(); + let clip = r["clip"].as_u64().unwrap(); + e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap(); + let baseline = render(&e); + assert!(baseline.iter().flatten().any(|x| x.abs() > 1e-6), "the fixture must make sound"); + + e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 24_000})).unwrap(); + let offset = render(&e); + assert!(offset != baseline, "the offset must change the render"); + + e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 0})).unwrap(); + assert!(render(&e) == baseline, "a zero offset plays as before"); + + // The offset shifts playback by exactly as much as moving the clip would. + e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap(); + assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples"); +} diff --git a/crates/engine/tests/trim_to_selection.rs b/crates/engine/tests/trim_to_selection.rs new file mode 100644 index 0000000..f632899 --- /dev/null +++ b/crates/engine/tests/trim_to_selection.rs @@ -0,0 +1,20 @@ +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_model::{ChannelFormat, Clip, Session, SourceId, TrackKind}; + +#[test] +fn trim_to_selection_keeps_clips_sorted() { + let mut session = Session::default(); + let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let long = session.new_clip_id(); + let short = session.new_clip_id(); + session.track_mut(track).unwrap().playlist_mut().unwrap().clips = + vec![Clip::audio(long, "long", SourceId(1), 0, 0, 30), Clip::audio(short, "short", SourceId(1), 0, 5, 5)]; + let mut engine = Engine::new(session); + engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 15, "end": 20})).unwrap(); + let clips = engine.session().track(track).unwrap().clips(); + assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::>(), vec![(short, 5, 5), (long, 15, 5)]); + engine.undo(); + let clips = engine.session().track(track).unwrap().clips(); + assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::>(), vec![(long, 0, 30), (short, 5, 5)]); +} diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab..a8c8b56 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -58,6 +58,7 @@ use soundcraft_model::{ }; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; +use std::ops::Bound; use std::sync::Arc; /// Default (stereo) main mix width. The actual width is [`main_channels`]. @@ -251,6 +252,9 @@ impl DelayLine { } self.idx = (start + frames) % self.len.max(1); } + fn clear(&mut self) { + self.bufs.iter_mut().for_each(|b| b.fill(0.0)); + } } /// Clip Effects: an EQ and a compressor per clip, configured from `edit.values` @@ -344,6 +348,13 @@ impl SlotCtx<'_> { } } + /// True when the host handed over an instance for this slot. It replaces whatever the slot + /// holds, even an instance of the same plugin: the host made it because it no longer tracks + /// the old one (it was removed and re-added between two renders), and drives its editor. + fn waiting(&self, t: TrackId, idx: usize) -> bool { + self.external && self.adopt.contains_key(&(t, idx)) + } + /// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels. fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option { let third = is_third_party(&ins.plugin); @@ -474,6 +485,9 @@ impl MixEngine { i.plugin.reset(); } s.held.clear(); + s.align.clear(); + s.align_pre.clear(); + s.out_extra.clear(); } self.meters.clear(); self.main_meter = StripMeter::default(); @@ -511,7 +525,7 @@ impl MixEngine { strip.plugins.resize_with(t.mixer.inserts.len(), || None); for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() { match ins { - Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => { + Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => { let old = slot.take(); cx.retire(old); *slot = cx.make(t.id, idx, i, ch); @@ -525,7 +539,9 @@ impl MixEngine { } let want = t.instrument.as_ref(); let ich = ch.max(2); - if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) { + if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) + || (want.is_some() && cx.waiting(t.id, INSTRUMENT_SLOT)) + { let old = strip.instrument.take(); cx.retire(old); strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich)); @@ -763,8 +779,7 @@ impl MixEngine { let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law); apply_matrix(src, frames, &mut dst.buf, &m, gain); } - let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() }; - match &t.mixer.output { + let src: &[Vec] = match &t.mixer.output { Route::Main if strip.out_extra.len > 0 => { if strip.outbuf.len() != strip.buf.len() { strip.outbuf = vec![Vec::new(); strip.buf.len()]; @@ -774,20 +789,25 @@ impl MixEngine { d.extend_from_slice(src.get(..frames).unwrap_or(&[])); } strip.out_extra.process(&mut strip.outbuf, frames); - let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law); - apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0); + &strip.outbuf } - Route::Main => { - let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law); - apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0); - } - Route::Bus(b) => { - if let Some(dst) = self.busses.get_mut(b) { - let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law); - apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0); - } - } - _ => {} + _ => &strip.buf, + }; + let (dst, dst_fmt) = match &t.mixer.output { + Route::Main => (&mut self.main, self.main_fmt), + Route::Bus(b) => match self.busses.get_mut(b) { + Some(d) => (&mut d.buf, d.fmt), + None => return, + }, + _ => return, + }; + let mut start = 0; + while start < frames { + let (at, end) = pan_segment(t, pos, start, frames); + let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() }; + let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law); + apply_matrix_span(src, start..end, dst, &m, 1.0); + start = end; } } } @@ -852,7 +872,7 @@ fn process_strip( } render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch); let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch)); - fx.configure(¶ms); + fx.configure(params); fx.process(&mut strip.clipbuf, frames); for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) { for (x, y) in d.iter_mut().zip(c.iter()).take(frames) { @@ -980,19 +1000,49 @@ fn process_strip( } /// Clip-effect parameters of a clip, or None when it has none or they are bypassed. -fn clip_fx_params(s: &Session, clip: u64) -> Option> { - if s.edit.values.is_empty() { +fn clip_fx_params(s: &Session, clip: u64) -> Option + Clone> { + if s.edit.values.is_empty() || Key::new(format_args!("clip_fx.bypass.{clip}")).is_none_or(|k| s.edit.flag(k.as_str())) { return None; } - let pre = format!("clip_fx.{clip}."); - let v: Vec<(String, f32)> = s + let pre = Key::new(format_args!("clip_fx.{clip}."))?; + let mut params = s .edit .values - .range(pre.clone()..) - .take_while(|(k, _)| k.starts_with(&pre)) - .map(|(k, v)| (k.get(pre.len()..).unwrap_or("").to_string(), *v as f32)) - .collect(); - if v.is_empty() || s.edit.flag(&format!("clip_fx.bypass.{clip}")) { None } else { Some(v) } + .range::((Bound::Included(pre.as_str()), Bound::Unbounded)) + .take_while(move |(k, _)| k.starts_with(pre.as_str())) + .map(move |(k, v)| (k.get(pre.len..).unwrap_or(""), *v as f32)) + .peekable(); + params.peek()?; + Some(params) +} + +/// A short key formatted on the stack: lookups made on the audio thread must not allocate. +#[derive(Clone, Copy)] +struct Key { + buf: [u8; 64], + len: usize, +} + +impl Key { + /// None when the key doesn't fit. + fn new(args: std::fmt::Arguments<'_>) -> Option { + let mut k = Key { buf: [0; 64], len: 0 }; + std::fmt::Write::write_fmt(&mut k, args).ok()?; + Some(k) + } + + fn as_str(&self) -> &str { + self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("") + } +} + +impl std::fmt::Write for Key { + fn write_str(&mut self, s: &str) -> std::fmt::Result { + let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?; + self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes()); + self.len = end; + Ok(()) + } } impl ClipFx { @@ -1006,10 +1056,10 @@ impl ClipFx { }) } /// Modules exist only when one of their parameters is set. - fn configure(&mut self, params: &[(String, f32)]) { + fn configure<'a>(&mut self, params: impl Iterator + Clone) { self.gain_db = 0.0; - let has_eq = params.iter().any(|(k, _)| k.starts_with("eq.")); - let has_comp = params.iter().any(|(k, _)| k.starts_with("comp.")); + let has_eq = params.clone().any(|(k, _)| k.starts_with("eq.")); + let has_comp = params.clone().any(|(k, _)| k.starts_with("comp.")); if has_eq && self.eq.is_none() { self.eq = self.make("eq_7band"); } else if !has_eq { @@ -1023,18 +1073,18 @@ impl ClipFx { for (k, v) in params { if let Some(p) = k.strip_prefix("eq.") { if let Some(eq) = &mut self.eq - && eq.param(p) != Some(*v) + && eq.param(p) != Some(v) { - eq.set_param(p, *v); + eq.set_param(p, v); } } else if let Some(p) = k.strip_prefix("comp.") { if let Some(c) = &mut self.comp - && c.param(p) != Some(*v) + && c.param(p) != Some(v) { - c.set_param(p, *v); + c.set_param(p, v); } } else if k == "gain" { - self.gain_db = *v; + self.gain_db = v; } } } @@ -1305,9 +1355,27 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut } } +/// The track's MIDI Track Offset (`midi.offset.` in the edit values), in samples. Looked up +/// without formatting the key, since this runs on the audio thread every block. +fn midi_offset(s: &Session, t: &Track) -> i64 { + const PREFIX: &str = "midi.offset."; + s.edit + .values + .range::((std::ops::Bound::Included(PREFIX), std::ops::Bound::Unbounded)) + .take_while(|(k, _)| k.starts_with(PREFIX)) + .find(|(k, _)| k.get(PREFIX.len()..).and_then(|id| id.parse::().ok()) == Some(t.id.0)) + .map(|(_, v)| *v) + .filter(|v| v.is_finite()) + .map_or(0, |v| v as i64) +} + /// Send note on/offs for MIDI clips overlapping the block. fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec) { let sr = s.sample_rate; + // MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so + // the block looks that far back into the clips. + let offset = midi_offset(s, t); + let pos = pos.saturating_sub(offset); let end = pos.saturating_add(frames as i64); for clip in t.clips() { if clip.muted { @@ -1319,7 +1387,7 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu } let base = s.tempo.samples_to_ticks(clip.start, sr); // MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay). - let rtp = |name: &str, d: f64| s.edit.value(&format!("rtp.{}.{name}", t.id.0), d); + let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d)); let (dv, dp, dur, delay) = if s.edit.values.is_empty() { (0i64, 0i64, 1.0f64, 0i64) } else { @@ -1458,6 +1526,23 @@ fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 { base } +/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block, +/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically. +const PAN_STEP: Samples = 64; + +/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end +/// of the frames that share it. Without pan automation the whole block shares one pan. +fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) { + let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty()); + if !automated { + return (pos, frames); + } + let here = pos.saturating_add(start as i64); + let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP); + let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1); + (at, start.saturating_add(left).min(frames)) +} + fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 { if t.mixer.automation_mode.reads() && let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0))) @@ -1707,6 +1792,11 @@ fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: u /// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation). fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matrix, gain: f32) { + apply_matrix_span(src, 0..frames, dst, m, gain); +} + +/// [`apply_matrix`] over the frames in `span` only. +fn apply_matrix_span(src: &[Vec], span: std::ops::Range, dst: &mut [Vec], m: &Matrix, gain: f32) { let ns = src.len().min(MAX_CHANNELS); for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) { let mut idx = [(0usize, 0.0f32); MAX_CHANNELS]; @@ -1724,7 +1814,7 @@ fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matri if taps.is_empty() { continue; } - for (i, o) in out.iter_mut().enumerate().take(frames) { + for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) { let mut acc = 0.0f32; for &(s, g) in taps { acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g; @@ -1852,17 +1942,14 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec> let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1)); let mut out = vec![vec![0.0f32; len]; t.channels().max(1)]; let mut scratch = Vec::new(); - for c in t.clips() { - if c.muted { - continue; - } + for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) { match clip_fx_params(s, c.id.0) { None => render_clip(s, c, range.start, len, &mut out, &mut scratch), Some(params) => { let mut tmp = vec![vec![0.0f32; len]; out.len()]; render_clip(s, c, range.start, len, &mut tmp, &mut scratch); let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len()); - fx.configure(¶ms); + fx.configure(params); let mut done = 0; while done < len { let n = (len - done).min(4096); @@ -2063,6 +2150,22 @@ mod tests { assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); } + #[test] + fn pan_automation_does_not_depend_on_the_block_size() { + // Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244). + let (mut s, t) = session_with_dc(0.5, 4800); + { + let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0)); + l.set_point(0, -1.0); + l.set_point(480, 1.0); + } + let bounce = render_range(&s, Range::new(0, 4800), 1024); + assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]); + for block in [512, 100, 37] { + assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}"); + } + } + #[test] fn fades_and_clip_gain_apply() { let (mut s, t) = session_with_dc(1.0, 48_000); @@ -2168,6 +2271,55 @@ mod tests { } } + struct Keys { + inner: Box, + heard: Arc>>, + } + + impl Plugin for Keys { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.inner.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.inner.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.inner.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.inner.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.inner.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.inner.process(io, frames); + } + fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) { + self.heard.lock().unwrap().push((note, velocity)); + } + } + + #[test] + fn real_time_properties_reach_the_instrument() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None); + s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys")); + let mut seq = soundcraft_midi::Sequence::default(); + seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 }); + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq)); + s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0); + s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]); + render_block(&mut eng, &s, 0); + assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]); + } + fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 { let mut out = vec![vec![0.0f32; 512]; 2]; eng.render(s, pos, 512, &mut out); @@ -2218,6 +2370,31 @@ mod tests { assert!(eng.drain_plugins().is_empty()); } + #[test] + fn a_new_instance_replaces_a_matching_one_in_its_slot() { + // The host removed and re-added the plugin, and both session updates reached the audio + // thread in one go: the slot still holds the old instance (same id, same channels), but + // the host made a new one and drives its editor. The new one must win, or the editor + // talks to an instance that was retired ("the plugin is gone"). + let (mut s, t) = session_with_dc(0.5, 48_000); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake")); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let make = |blob: &[u8]| { + let mut gain = soundcraft_dsp::create("gain").unwrap(); + gain.prepare(48_000.0, 512, 1); + Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) }) + }; + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]); + render_block(&mut eng, &s, 0); + assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]); + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]); + render_block(&mut eng, &s, 512); + assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]); + assert_eq!(eng.take_retired().len(), 1, "the old instance is retired"); + } + #[test] fn stored_state_comes_first_then_session_params() { let (mut s, t) = session_with_dc(0.5, 48_000); @@ -2287,6 +2464,27 @@ mod tests { assert_ne!(argmax(&out[0]), argmax(&out[1])); } + /// A reset (stop, locate) must not leave the previous position's audio in the alignment delay. + #[test] + fn reset_clears_delay_compensation_history() { + let (mut s, t) = session_with_dc(0.5, 4800); + s.edit.click = false; + s.track_mut(t).unwrap().mixer.pan = vec![1.0]; + let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let mut comp = Insert::new("compressor"); + comp.params.insert("lookahead".into(), 5.0); + s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp); + let mut eng = MixEngine::new(48_000.0, 512); + let mut out = vec![vec![0.0f32; 512]; 2]; + for b in 0..9 { + eng.render(&s, b * 512, 512, &mut out); + } + assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay"); + eng.reset(); + eng.render(&s, 1_000_000, 512, &mut out); + assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset"); + } + fn impulse_at(ch: &[f32]) -> Vec { ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect() } diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs index c4ee449..3a68b04 100644 --- a/crates/model/src/automation.rs +++ b/crates/model/src/automation.rs @@ -204,21 +204,28 @@ impl AutomationLane { /// Thin: remove points whose removal changes the curve by less than `tolerance`. pub fn thin(&mut self, tolerance: f32) -> usize { - if self.points.len() < 3 { + if self.points.len() < 3 || tolerance.is_nan() { return 0; } + let tolerance = f64::from(tolerance); let mut keep = vec![true; self.points.len()]; let mut last = 0usize; + // Slopes from the last kept point that keep every point removed since then within `tolerance`. + let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY); for i in 1..self.points.len() - 1 { let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue }; - let span = (c.at - a.at) as f32; - let interp = if span > 0.0 { a.value + (c.value - a.value) * ((b.at - a.at) as f32 / span) } else { a.value }; - if (interp - b.value).abs() <= tolerance { + let dt = b.at.saturating_sub(a.at) as f64; + let rise = f64::from(b.value) - f64::from(a.value); + lo = lo.max((rise - tolerance) / dt); + hi = hi.min((rise + tolerance) / dt); + let slope = (f64::from(c.value) - f64::from(a.value)) / c.at.saturating_sub(a.at) as f64; + if (lo..=hi).contains(&slope) { if let Some(k) = keep.get_mut(i) { *k = false; } } else { last = i; + (lo, hi) = (f64::NEG_INFINITY, f64::INFINITY); } } let before = self.points.len(); @@ -279,6 +286,23 @@ mod tests { assert_eq!(l.points.len(), 2); } + #[test] + fn thin_keeps_removed_points_within_tolerance() { + for direction in [1.0, -1.0] { + let mut lane = AutomationLane::new(AutoParam::Volume); + for (at, value) in [(0, 0.0), (100, 0.0), (200, 0.0), (300, 0.12), (400, 0.26)] { + lane.set_point(at, value * direction); + } + let original = lane.points.clone(); + let tolerance = 0.1; + assert!(lane.thin(tolerance) > 0); + for point in original { + let error = (lane.value_at(point.at, 0.0) - point.value).abs(); + assert!(error <= tolerance, "at {}: error {error} exceeds {tolerance}", point.at); + } + } + } + #[test] fn param_ids() { assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1))); diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea..3a3a2e2 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -105,6 +105,10 @@ pub struct Clip { /// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews). #[serde(default)] pub group: Option, + /// Marks the clips made by `clip.loop` (the origin and its copies share one id), so + /// `clip.unloop` still recognises the loop after the source's content is edited. + #[serde(default)] + pub loop_id: Option, } fn one() -> f64 { @@ -131,6 +135,7 @@ impl Clip { color: None, stretch: 1.0, group: None, + loop_id: None, } } @@ -215,7 +220,12 @@ impl Clip { self.length -= delta; self.start = new_start; self.clamp_fades(); - self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect(); + // Keep the gain at the cut when earlier breakpoints are dropped. + if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) { + self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta))); + } + self.gain_env.retain(|&(t, _)| t >= delta); + self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta)); } /// Move the end edge to `new_end`. @@ -282,6 +292,27 @@ mod tests { assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4); } + #[test] + fn trim_start_keeps_gain_envelope_at_cut() { + for (env, cut, expected) in [ + (vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]), + (vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]), + (vec![], 500, vec![]), + (vec![(100, -3.0)], 0, vec![(100, -3.0)]), + (vec![(100, -3.0)], -100, vec![(200, -3.0)]), + ] { + let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000); + clip.gain_env = env; + let gain_at_cut = clip.gain_at(cut); + clip.trim_start_to(cut); + assert_eq!(clip.gain_env, expected); + if cut > 0 { + assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6); + } + } + } + #[test] fn trims_keep_material_anchored() { let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000); diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 55d605d..80be067 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -22,7 +22,7 @@ pub use markers::{Group, MarkerKind, MemoryLocation}; pub use mixer::{ Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db, }; -pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState}; +pub use session::{BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState}; pub use source::{Source, SourceAudio, SourcePool, VideoSource}; pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind}; diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 5956796..3432da0 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -9,6 +9,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap /// Native session file extension. pub const SESSION_EXTENSION: &str = "scraft"; +/// `EditState::values` key holding the feet+frames reading of sample 0, in frames. +pub const FEET_FRAMES_START: &str = "feet_frames.start"; + #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum SessionError { #[error("not a SoundCraft session: {0}")] @@ -421,6 +424,21 @@ impl Session { self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0) } + /// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position). + pub fn feet_frames_start(&self) -> i64 { + self.edit.value(FEET_FRAMES_START, 0.0) as i64 + } + + /// Format a position against this session's timecode and feet+frames references. + pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String { + soundcraft_time::format_position(at, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start()) + } + + /// Parse a position typed in `fmt` against this session's references. + pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result { + soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start()) + } + pub fn next_track_color(&self) -> [u8; 3] { let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count(); TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128]) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..5ac7409 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -15,8 +15,9 @@ //! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored //! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and //! travel to the audio thread together with the session snapshot that needs them; the mix -//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires come back -//! on a return channel and are dropped on the UI thread ([`Player::idle`]). Plugin states for +//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires, and the +//! session snapshots the callback replaces, come back on return channels and are dropped on the UI +//! thread ([`Player::idle`]). Plugin states for //! saving are read on request ([`Player::capture_states`]: a command with a reply; the audio //! thread answers between two blocks, never blocking). Plugin editors are driven on the UI thread //! through handles taken from each instance before it leaves ([`Player::open_editor`]). @@ -30,11 +31,14 @@ use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering}; -use std::sync::mpsc::{Receiver, Sender, channel}; +use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel}; use std::sync::{Arc, Mutex, PoisonError}; const BLOCK: usize = 512; +/// Room for replaced snapshots on their way back; `update_session` empties it before each send. +const OLD_SESSIONS: usize = 16; + /// 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}; @@ -55,6 +59,8 @@ pub type EditorEdit = (TrackId, usize, String, f32); enum Cmd { /// A new snapshot plus the third-party instances it needs (created on the UI thread). Session(Arc, Vec), + /// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees). + OldSessions(SyncSender>), /// Read every plugin state and reply. CaptureStates(Sender), Input(Arc), @@ -97,6 +103,7 @@ struct AudioState { recording: bool, /// Retired plugin instances go back to the UI thread to be dropped there. garbage: Sender>>, + old_sessions: Option>>, } fn new_engine(sr: f32) -> MixEngine { @@ -112,6 +119,7 @@ impl AudioState { let nch = main_channels(&session); AudioState { garbage, + old_sessions: None, fold: fold_gains(&session), rx, shared, @@ -155,8 +163,12 @@ impl AudioState { self.read = 0.0; } self.fold = fold_gains(&s); - self.session = s; + let old = std::mem::replace(&mut self.session, s); + if let Some(tx) = &self.old_sessions { + let _ = tx.try_send(old); + } } + Cmd::OldSessions(tx) => self.old_sessions = Some(tx), Cmd::Play { from, end, looped } => { self.pos = from; self.end = end; @@ -176,6 +188,7 @@ impl AudioState { self.pos = at; self.pending_len = 0; self.read = 0.0; + self.mix.reset(); } } } @@ -326,14 +339,28 @@ struct Side { /// Editor handles of the live instances. editors: HashMap<(TrackId, usize), Box>, garbage: Receiver>>, + old_sessions: Option>>, } impl Side { - /// Drops retired instances (closing their editors first, inside the plugin's drop). + /// Drops retired instances (closing their editors first, inside the plugin's drop) and + /// replaced session snapshots. fn collect_garbage(&mut self) { while let Ok(g) = self.garbage.try_recv() { - drop(g); + for p in g { + log::debug!("plugin instance released: {}", p.info().name); + drop(p); + } } + if let Some(rx) = &self.old_sessions { + rx.try_iter().for_each(drop); + } + } + + fn return_old_sessions(&mut self, tx: &Sender) { + let (otx, orx) = sync_channel(OLD_SESSIONS); + self.old_sessions = Some(orx); + let _ = tx.send(Cmd::OldSessions(otx)); } fn forget(&mut self, key: (TrackId, usize)) { @@ -365,6 +392,7 @@ impl Player { /// Open the default output device. Never fails: without a device it runs a silent clock. pub fn new(session: Arc) -> Player { let p = Player::open(Arc::clone(&session)); + p.side().return_old_sessions(&p.tx); // Hand the initial session's third-party plugins over. p.update_session(session); p @@ -376,7 +404,13 @@ impl Player { fn open(session: Arc) -> Player { let (gtx, grx) = channel(); - let side = Mutex::new(Side { shadow: HashMap::new(), rate: session.sample_rate.as_f64() as f32, editors: HashMap::new(), garbage: grx }); + let side = Mutex::new(Side { + shadow: HashMap::new(), + rate: session.sample_rate.as_f64() as f32, + editors: HashMap::new(), + garbage: grx, + old_sessions: None, + }); let shared = Arc::new(Shared { position: AtomicI64::new(0), playing: AtomicBool::new(false), @@ -681,6 +715,7 @@ impl Side { } self.forget(key); let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch); + log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some()); self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some())); if let Some(mut plugin) = made { if let Some(e) = plugin.editor() { @@ -916,7 +951,7 @@ mod tests { fn side_creates_each_missing_instance_once() { use soundcraft_model::{ChannelFormat, Insert, TrackKind}; let (_gtx, grx) = channel(); - let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx }; + let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None }; let mut s = soundcraft_model::Session::default(); let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:does.not.exist")); @@ -930,6 +965,34 @@ mod tests { assert!(side.shadow.is_empty()); } + #[test] + fn replaced_sessions_are_freed_on_the_ui_thread() { + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let (gtx, grx) = channel(); + let mut st = AudioState::new(rx, shared, Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx); + let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None }; + side.return_old_sessions(&tx); + let mut out = vec![0.0f32; 256]; + let mut watched = Vec::new(); + for _ in 0..3 * OLD_SESSIONS { + let s = Arc::new(soundcraft_model::Session::default()); + watched.push(Arc::downgrade(&s)); + side.collect_garbage(); + tx.send(Cmd::Session(s, Vec::new())).unwrap(); + st.fill(&mut out, 2); + let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i)); + assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread"); + } + side.collect_garbage(); + assert_eq!(watched.iter().filter(|w| w.strong_count() > 0).count(), 1, "only the live snapshot is left"); + } + #[test] fn looping_wraps() { let s = Arc::new(soundcraft_model::Session::default()); @@ -949,6 +1012,49 @@ mod tests { assert!(shared.playing.load(Ordering::Relaxed)); } + #[test] + fn locate_while_playing_drops_the_old_position_audio() { + use soundcraft_model::{ChannelFormat, Clip, Insert, SourceAudio, SourceId, TrackKind}; + for (plugin, params) in [("compressor", &[("lookahead", 5.0), ("ratio", 1.0)][..]), ("room_reverb", &[][..])] { + let mut s = soundcraft_model::Session::default(); + s.edit.click = false; + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + s.pool.insert( + SourceId(5), + Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })), + ); + let id = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 4800)); + let mut ins = Insert::new(plugin); + for (k, v) in params { + ins.params.insert((*k).into(), *v); + } + tr.mixer.inserts[0] = Some(ins); + let shared = Arc::new(Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(false), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }); + let (tx, rx) = channel(); + let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0); + tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap(); + let mut out = vec![0.0f32; 2 * 512]; + let mut before = 0.0f32; + for _ in 0..9 { + st.fill(&mut out, 2); + before = out.iter().fold(before, |m, x| m.max(x.abs())); + } + assert!(before > 0.1, "{plugin}: the clip plays before the locate"); + tx.send(Cmd::Locate(1_000_000)).unwrap(); + for _ in 0..8 { + st.fill(&mut out, 2); + assert!(out.iter().all(|x| x.abs() < 1e-6), "{plugin}: audio from before the locate"); + } + } + } + #[test] fn armed_tracks_are_heard_and_metered_after_playback_stops() { use soundcraft_model::{ChannelFormat, TrackKind}; diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index a5f4a1a..e193151 100644 --- a/crates/time/src/format.rs +++ b/crates/time/src/format.rs @@ -50,14 +50,15 @@ fn min_secs(s: Samples, sr: SampleRate) -> String { format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" }) } -/// Format an absolute position. -pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> String { +/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the +/// feet+frames reading of sample 0 in frames. +pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String { match fmt { TimeFormat::Samples => s.to_string(), TimeFormat::MinSecs => min_secs(s, sr), TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate), TimeFormat::FeetFrames => { - let (ft, fr) = feet_frames(s, rate, sr); + let (ft, fr) = feet_frames(s, rate, sr, ff_start); format!("{ft}+{fr:02}") } TimeFormat::BarsBeats => { @@ -80,12 +81,20 @@ pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRa format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1)) } TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate), - other => format_position(len, other, sr, map, rate, 0), + other => format_position(len, other, sr, map, rate, 0, 0), } } /// Parse a position typed into a counter or dialog. -pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> Result { +pub fn parse_position( + text: &str, + fmt: TimeFormat, + sr: SampleRate, + map: &TempoMap, + rate: FrameRate, + tc_start: Samples, + ff_start: i64, +) -> Result { let t = text.trim(); let err = || TimeError::Parse(t.to_string(), fmt.label()); match fmt { @@ -110,7 +119,7 @@ pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMa let (ft, fr) = t.split_once('+').ok_or_else(err)?; let ft: i64 = ft.trim().parse().map_err(|_| err())?; let fr: i64 = fr.trim().parse().map_err(|_| err())?; - Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr), sr)) + Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr)) } TimeFormat::BarsBeats => { let parts: Vec = @@ -136,11 +145,20 @@ mod tests { #[test] fn formats_like_pro_tools_counters() { let (sr, map, rate) = ctx(); - assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000"); - assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500"); - assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000"); - assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00"); - assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345"); + assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000"); + assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500"); + assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000"); + assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00"); + assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345"); + } + + #[test] + fn minimum_bar_parses_without_overflow() { + let (sr, map, rate) = ctx(); + let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0); + assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0)); + assert_eq!(p("2|3|000").ok(), Some(144_000)); + assert_eq!(p("0|1|000").ok(), Some(-96_000)); } #[test] @@ -148,8 +166,8 @@ mod tests { let (sr, map, rate) = ctx(); for fmt in TimeFormat::ALL { for s in [0i64, 48_000, 96_000 * 3, 1_440_000] { - let text = format_position(s, fmt, sr, &map, rate, 0); - let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap(); + let text = format_position(s, fmt, sr, &map, rate, 0, 0); + let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap(); assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}"); } } @@ -158,9 +176,9 @@ mod tests { #[test] fn parse_rejects_garbage() { let (sr, map, rate) = ctx(); - assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err()); - assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err()); - assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err()); + assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err()); + assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err()); } #[test] diff --git a/crates/time/src/grid.rs b/crates/time/src/grid.rs index 30e8f63..dea9053 100644 --- a/crates/time/src/grid.rs +++ b/crates/time/src/grid.rs @@ -86,6 +86,24 @@ impl GridValue { } } + /// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their + /// dot or triplet; seconds, frames and samples step by tens. + pub fn stepped(self, larger: bool) -> GridValue { + fn next(ladder: &[T], v: T, larger: bool) -> Option { + if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) } + } + match self { + GridValue::Note { value, dotted, triplet } => { + let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0); + let j = if larger { i.checked_sub(1) } else { i.checked_add(1) }; + j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet }) + } + GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds), + GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames), + GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples), + } + } + fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 { let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() }; let mut t = base; @@ -199,4 +217,19 @@ mod tests { let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }; assert_eq!(d.step_samples(0, sr, &map, rate), 36_000); } + + #[test] + fn values_step_to_the_next_larger_or_smaller() { + let note = |value, triplet| GridValue::Note { value, dotted: false, triplet }; + assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false)); + assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true)); + assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false)); + assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false)); + assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1)); + assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0)); + assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10)); + assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10)); + assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1)); + } } diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..c4f0482 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -96,13 +96,31 @@ impl TempoMap { } /// Insert or replace the meter at `tick` (snapped to the bar line at or before it). + /// Later meter changes keep their bar numbers, so they stay on bar lines. pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> { if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) { return Err(TimeError::Meter(numerator, denominator)); } let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) }; - let ev = MeterEvent { tick, numerator, denominator }; - match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) { + let later = self.take_meters_after(tick); + self.put_meter(MeterEvent { tick, numerator, denominator }); + self.restore_meters_at_bars(later); + Ok(()) + } + + /// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers. + pub fn remove_meter(&mut self, tick: i64) -> bool { + if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) { + return false; + } + let later = self.take_meters_after(tick); + self.meters.retain(|e| e.tick != tick); + self.restore_meters_at_bars(later); + true + } + + fn put_meter(&mut self, ev: MeterEvent) { + match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) { Ok(i) => { if let Some(e) = self.meters.get_mut(i) { *e = ev; @@ -110,16 +128,21 @@ impl TempoMap { } Err(i) => self.meters.insert(i, ev), } - Ok(()) } - pub fn remove_meter(&mut self, tick: i64) -> bool { - if tick == 0 { - return false; + /// Remove the meters after `tick`, returning each with the bar it starts. + fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> { + let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect(); + self.meters.retain(|e| e.tick <= tick); + later + } + + /// Put meters back at the start of their bars under the current earlier meters, in bar order. + fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) { + for (bar, ev) in later { + let tick = self.bar_start_tick(bar); + self.put_meter(MeterEvent { tick, ..ev }); } - let before = self.meters.len(); - self.meters.retain(|e| e.tick != tick); - before != self.meters.len() } /// Tempo in effect at `tick`. @@ -200,7 +223,7 @@ impl TempoMap { let mut cur_tick = 0i64; let first = self.meter_at_tick(0); if bar < 1 { - return (bar - 1).saturating_mul(first.ticks_per_bar()); + return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar()); } for (i, m) in self.meters.iter().enumerate() { let next_tick = self.meters.get(i + 1).map(|n| n.tick); @@ -288,6 +311,13 @@ mod tests { assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 }); } + #[test] + fn minimum_bar_saturates_instead_of_overflowing() { + let m = TempoMap::default(); + assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN); + assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4); + } + #[test] fn tempo_change_is_integrated() { let mut m = TempoMap::default(); diff --git a/crates/time/src/timecode.rs b/crates/time/src/timecode.rs index c0db20f..f8f6041 100644 --- a/crates/time/src/timecode.rs +++ b/crates/time/src/timecode.rs @@ -109,7 +109,7 @@ impl FrameRate { return 0; } let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n); - i64::try_from(v).unwrap_or(0) + i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX }) } /// Samples per frame (fractional). @@ -153,13 +153,14 @@ impl Timecode { /// Absolute frame count of this label. pub fn to_frames(&self, rate: FrameRate) -> i64 { let base = rate.nominal().max(1); - let total_min = self.hours * 60 + self.minutes; - let mut f = (total_min * 60 + self.seconds) * base + self.frames; + let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes); + let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames); if rate.is_drop() { let drop = if base >= 60 { 4 } else { 2 }; f -= drop * (total_min - total_min / 10); } - if self.negative { -f } else { f } + let f = if self.negative { -f } else { f }; + i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX }) } pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode { @@ -202,9 +203,9 @@ impl Timecode { } } -/// Feet+Frames for 35 mm film (16 frames per foot). -pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate) -> (i64, i64) { - let f = rate.frames_at(s, sr); +/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0. +pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) { + let f = rate.frames_at(s, sr).saturating_add(start); (f.div_euclid(16), f.rem_euclid(16)) } @@ -233,6 +234,19 @@ mod tests { } } + #[test] + fn huge_timecode_saturates_instead_of_overflowing() { + let sr = SampleRate::HZ_48000; + for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] { + let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MAX); + let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap(); + assert_eq!(tc.to_samples(r, sr), i64::MIN); + } + let tc = Timecode::parse("01:02:03:04").unwrap(); + assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400); + } + #[test] fn parse_variants() { let tc = Timecode::parse("01:02:03:04").unwrap(); @@ -246,6 +260,6 @@ mod tests { #[test] fn feet_and_frames() { let sr = SampleRate::HZ_48000; - assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr), (1, 8)); + assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8)); } } diff --git a/crates/time/tests/meter_grid_contract.rs b/crates/time/tests/meter_grid_contract.rs new file mode 100644 index 0000000..5d22432 --- /dev/null +++ b/crates/time/tests/meter_grid_contract.rs @@ -0,0 +1,72 @@ +//! Meter changes stay on bar lines when an earlier meter is edited (issue #250). + +use soundcraft_time::{BarBeat, MeterEvent, SampleRate, TempoMap}; + +fn assert_positions_round_trip(map: &TempoMap) { + for bar in 1..=5 { + let meter = map.meter_at_tick(map.bar_start_tick(bar)); + for beat in 1..=i64::from(meter.numerator) { + let position = BarBeat { bar, beat, tick: 0 }; + assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(position)), position); + } + } +} + +fn meter_bars(map: &TempoMap) -> Vec<(i64, u32, u32)> { + map.meters() + .iter() + .map(|m| { + let bb = map.bar_beat_at_tick(m.tick); + assert_eq!((bb.beat, bb.tick), (1, 0), "meter at tick {} is off the bar line", m.tick); + (bb.bar, m.numerator, m.denominator) + }) + .collect() +} + +#[test] +fn earlier_meter_edit_preserves_bar_positions() { + let mut map = TempoMap::default(); + let at = map.samples_to_ticks(192_000, SampleRate::HZ_48000); + assert_eq!(at, 7680); + map.set_meter(at, 3, 4).unwrap(); + assert_positions_round_trip(&map); + map.set_meter(0, 6, 4).unwrap(); + assert_positions_round_trip(&map); + // The 3/4 change stays at bar 3, now two 6/4 bars in. + assert_eq!(meter_bars(&map), vec![(1, 6, 4), (3, 3, 4)]); + assert_eq!(map.meters().get(1).map(|m| m.tick), Some(2 * 5760)); +} + +#[test] +fn inserting_and_removing_meters_keeps_later_bars() { + let mut map = TempoMap::default(); + map.set_meter(map.bar_start_tick(5), 5, 4).unwrap(); + map.set_meter(map.bar_start_tick(3), 3, 4).unwrap(); + assert_eq!(meter_bars(&map), vec![(1, 4, 4), (3, 3, 4), (5, 5, 4)]); + // Inserting 7/8 at bar 2 keeps 3/4 at bar 3 and 5/4 at bar 5. + map.set_meter(map.bar_start_tick(2), 7, 8).unwrap(); + assert_eq!(meter_bars(&map), vec![(1, 4, 4), (2, 7, 8), (3, 3, 4), (5, 5, 4)]); + assert_positions_round_trip(&map); + // Removing 3/4 leaves 5/4 at bar 5. + let three_four = map.meters().iter().find(|m| m.numerator == 3).map(|m| m.tick).unwrap(); + assert!(map.remove_meter(three_four)); + assert_eq!(meter_bars(&map), vec![(1, 4, 4), (2, 7, 8), (5, 5, 4)]); + assert_positions_round_trip(&map); +} + +#[test] +fn initial_meter_first_and_restoring_controls() { + // Setting the initial meter first, then the later change at the same raw tick, snaps to a bar line. + let mut map = TempoMap::default(); + map.set_meter(0, 6, 4).unwrap(); + map.set_meter(7680, 3, 4).unwrap(); + assert_eq!(map.meters().get(1), Some(&MeterEvent { tick: 5760, numerator: 3, denominator: 4 })); + assert_positions_round_trip(&map); + // Restoring 4/4 after the edit puts the 3/4 change back where it started. + let mut map = TempoMap::default(); + map.set_meter(7680, 3, 4).unwrap(); + map.set_meter(0, 6, 4).unwrap(); + map.set_meter(0, 4, 4).unwrap(); + assert_eq!(map.meters().get(1), Some(&MeterEvent { tick: 7680, numerator: 3, denominator: 4 })); + assert_positions_round_trip(&map); +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2ea380e..13ac4c2 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -108,8 +108,13 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti Ok(v) => v, Err(e) => return Some(err(e)), }; + // Same predicate as the engine's `engine.commands`: id or label substring, ASCII case-insensitive. + let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase); + let matches = |id: &str, label: &str| { + filter.as_deref().is_none_or(|f| id.to_ascii_lowercase().contains(f) || label.to_ascii_lowercase().contains(f)) + }; if let Some(arr) = list.as_array_mut() { - for (id, label, path, sc) in crate::menus::UI_COMMANDS { + for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) { arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"})); } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 2f1a34b..2ca955d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,6 +15,7 @@ pub enum Dialog { Bounce { path: String, format: String, bit_depth: String, normalize: bool }, RenameTrack { id: TrackId, name: String }, RenameClip { id: ClipId, name: String }, + RenameMarker { id: soundcraft_model::MarkerId, name: String }, ClearClips { clips: Vec, sources: Vec, message: String }, PathPrompt { cmd: String, title: String, path: String, key: String }, Number { cmd: String, title: String, key: String, value: f64, suffix: String }, @@ -42,6 +43,7 @@ impl Dialogs { Dialog::Bounce { .. } => "bounce", Dialog::RenameTrack { .. } => "rename_track", Dialog::RenameClip { .. } => "rename_clip", + Dialog::RenameMarker { .. } => "rename_marker", Dialog::ClearClips { .. } => "clear_clips", Dialog::PathPrompt { .. } => "path", Dialog::Number { .. } => "number", @@ -63,6 +65,10 @@ impl Dialogs { self.open = Some(Dialog::RenameClip { id, name: name.to_string() }); } + pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) { + self.open = Some(Dialog::RenameMarker { id, name: name.to_string() }); + } + /// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear. pub fn open_clear(&mut self, e: &Engine, clips: Vec, sources: Vec) -> bool { let Some(message) = clear_message(e.session(), &clips, &sources) else { return false }; @@ -261,6 +267,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { .. } => "Bounce Mix", Dialog::RenameTrack { .. } => "Rename Track", Dialog::RenameClip { .. } => "Rename Clip", + Dialog::RenameMarker { .. } => "Rename Marker", Dialog::ClearClips { .. } => "Clear", Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(), Dialog::TempoChange { .. } => "Tempo Change (BPM)", @@ -379,6 +386,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name}))); } } + Dialog::RenameMarker { id, name } => { + name_field(ui, name); + if buttons(ui, "OK", enter) { + action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name}))); + } + } Dialog::ClearClips { clips, sources, message } => { ui.set_max_width(340.0); ui.label(message.as_str()); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index e0b4cc0..7ab5575 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1,27 +1,43 @@ //! The Edit window: toolbar, rulers, track headers and playlists. use crate::theme::{Tokens, bold, clip_colors, regular, rgb}; -use crate::widgets::{rec_toggle, selector_box, text_toggle}; -use crate::{Gesture, SoundApp, icons, toolbar}; +use crate::widgets::{groove, level_pos, meter_pos, raised_key, shade}; +use crate::{Gesture, SoundApp, toolbar}; use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; -pub const HEADER_W: f32 = 236.0; +pub const HEADER_W: f32 = 136.0; pub const COLUMN_W: f32 = 112.0; +/// Insert, send and instrument columns: Pro Tools' narrow stacks of slots. +const PLUGIN_COLUMN_W: f32 = 75.0; +/// The track colour strip at the left of each track header. +const STRIP_W: f32 = 14.0; +/// The row under the rulers that holds the column titles, when any column is shown. +const COLUMN_TITLES_H: f32 = 16.0; -/// Optional Edit-window columns (View › Edit Window Views) that are switched on. -pub fn edit_columns(s: &Session) -> Vec<&'static str> { +/// An optional Edit-window column: view id, menu label, title and width. +type Column = (&'static str, &'static str, &'static str, f32); + +const COLUMNS: [Column; 7] = [ + ("instruments", "Instruments", "INSTRUMENT", PLUGIN_COLUMN_W), + ("inserts_ae", "Inserts A-E", "INSERTS A-E", PLUGIN_COLUMN_W), + ("inserts_fj", "Inserts F-J", "INSERTS F-J", PLUGIN_COLUMN_W), + ("sends_ae", "Sends A-E", "SENDS A-E", PLUGIN_COLUMN_W), + ("sends_fj", "Sends F-J", "SENDS F-J", PLUGIN_COLUMN_W), + ("io", "I/O", "I/O", 94.0), + ("comments", "Comments", "COMMENTS", COLUMN_W), +]; + +/// Optional Edit-window columns (View › Edit Window Views) that are switched on, left to right. +pub fn edit_columns(s: &Session) -> Vec { let all = s.edit.flag("edit_view.all"); - ["io", "inserts_ae", "inserts_fj", "sends_ae", "sends_fj", "comments"] - .into_iter() - .filter(|c| all || s.edit.flag(&format!("edit_view.{c}"))) - .collect() + COLUMNS.into_iter().filter(|c| all || s.edit.flag(&format!("edit_view.{}", c.0))).collect() } -pub fn header_width(s: &Session) -> f32 { - HEADER_W + COLUMN_W * edit_columns(s).len() as f32 +pub fn header_width(cols: &[Column]) -> f32 { + HEADER_W + cols.iter().map(|c| c.3).sum::() } pub const RULER_H: f32 = 17.0; @@ -186,7 +202,7 @@ pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) { if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") { ruler_height_total += ruler_height("tempo_editor"); } - let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0); + let minimum = vec2(header_width(&edit_columns(session)) + 64.0, ruler_height_total + 100.0); crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui)); } @@ -200,9 +216,11 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { { rulers.insert(i + 1, "tempo_editor".into()); } - let rulers_h = rulers.iter().map(|r| ruler_height(r)).sum::() + 6.0; + let cols = edit_columns(app.engine.session()); + let titles_h = if cols.is_empty() { 0.0 } else { COLUMN_TITLES_H }; + let rulers_h = rulers.iter().map(|r| ruler_height(r)).sum::() + 6.0 + titles_h; let hscroll_h = 14.0; - let hw = header_width(app.engine.session()); + let hw = header_width(&cols); let header = Rect::from_min_max(full.min, pos2(full.min.x + hw, full.max.y - hscroll_h)); let tl = Rect::from_min_max(pos2(full.min.x + hw, full.min.y + rulers_h), pos2(full.max.x - 12.0, full.max.y - hscroll_h)); let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h)); @@ -210,6 +228,8 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(full, 0.0, t.window_bg); scroll_and_zoom(app, ui, full, tl); draw_rulers(app, ui, rulers_rect, tl, &rulers); + views_button(app, ui, rulers_rect); + column_titles(ui, rulers_rect, &cols); // Tracks. let mut y = tl.min.y - app.edit_layout.scroll_y; app.edit_layout.rows.clear(); @@ -227,13 +247,18 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y { let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row)); child.set_clip_rect(clip_rect); - track_row(app, &mut child, TrackId(id), row, tl); + track_row(app, &mut child, TrackId(id), row, tl, &cols); } } - // Empty area click clears track selection. + // Below the tracks a click clears the selection and a double-click opens New Tracks. let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max); - if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() { - let _ = app.run("edit.select_none", json!({})); + if empty.height() > 4.0 { + let resp = ui.interact(empty, ui.id().with("empty"), Sense::click()); + if resp.double_clicked() { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + } else if resp.clicked() { + let _ = app.run("edit.select_none", json!({})); + } } overlay(app, ui, tl, Rect::from_min_max(pos2(tl.min.x, rulers_rect.min.y), tl.max)); let _ = header; @@ -425,7 +450,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ for (i, id) in rulers.iter().enumerate() { let h = ruler_height(id); let row = Rect::from_min_max(pos2(tl.min.x, y0), pos2(tl.max.x, y0 + h)); - let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(tl.min.x, y0 + h)); + let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(area.min.x + HEADER_W, y0 + h)); y0 += h; let painter = ui.painter().with_clip_rect(row); ui.painter().text(pos2(lab.max.x - 12.0, lab.center().y), Align2::RIGHT_CENTER, ruler_label(id), bold(11.0), t.ruler_text); @@ -535,18 +560,23 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ )); 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. + // Click on the marker ruler: recall a marker; double-click renames it, or adds one. let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click()); if let Some(p) = resp.interact_pointer_pos() { + let hit = s + .markers + .iter() + .filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0)) + .find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0); if resp.double_clicked() { - let at = sample_at(&s, tl, p.x).max(0); - let _ = app.run("markers.add", json!({"at": at, "ruler": lane})); + match hit { + Some(m) => app.dialogs.open_rename_marker(m.id, &m.name), + None => { + let _ = app.run("markers.add", json!({"at": sample_at(&s, tl, p.x).max(0), "ruler": lane})); + } + } } else if resp.clicked() - && let Some(m) = s - .markers - .iter() - .filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0)) - .find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0) + && let Some(m) = hit { let _ = app.run("markers.recall", json!({"number": m.number})); } @@ -655,13 +685,9 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: TimeFormat::BarsBeats => s.tempo.bar_beat_at(at, sr).bar.to_string(), TimeFormat::MinSecs => { let secs = sr.seconds(at); - if secs.fract().abs() < 1e-9 { - format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) - } else { - format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start) - } + if secs.fract().abs() < 1e-9 { format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) } else { s.format_position(at, f) } } - _ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start), + _ => s.format_position(at, f), }; painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text); } else { @@ -673,31 +699,81 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: } } +/// Captions over the header columns, boxed in their own row under the rulers. +fn column_titles(ui: &Ui, rulers: Rect, cols: &[Column]) { + if cols.is_empty() { + return; + } + let t = Tokens::current(); + let line = Stroke::new(1.0, t.border); + let mut x = rulers.min.x + HEADER_W; + let end = rulers.min.x + header_width(cols); + let row = Rect::from_min_max(pos2(x, rulers.max.y - COLUMN_TITLES_H - 3.0), pos2(end, rulers.max.y)); + ui.painter().rect_filled(row, 0.0, t.panel_bg2); + ui.painter().hline(rulers.min.x..=end, row.min.y + 0.5, line); + ui.painter().hline(rulers.min.x..=end, row.max.y - 0.5, line); + ui.painter().vline(end - 0.5, row.y_range(), line); + for &(_, _, title, w) in cols { + ui.painter().vline(x + 0.5, row.y_range(), line); + ui.painter().text(pos2(x + w / 2.0, row.center().y), Align2::CENTER_CENTER, title, bold(10.0), t.section_label); + x += w; + } +} + +/// The Edit window view selector: which columns sit beside the track headers. +fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) { + let r = Rect::from_min_size(rulers.min + vec2(6.0, 5.0), vec2(50.0, 18.0)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); + let resp = crate::widgets::selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views")); + let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…"); + egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let shown = edit_columns(app.engine.session()); + for (id, label, _, _) in COLUMNS { + if ui.add(egui::Button::new(label).selected(shown.iter().any(|c| c.0 == id))).clicked() { + let _ = app.run(&format!("view.edit_{id}"), json!({})); + } + } + ui.separator(); + if ui.button("All").clicked() { + let _ = app.run("view.edit_all", json!({})); + } + if ui.button("Minimal").clicked() { + let _ = app.run("view.edit_minimal", json!({})); + } + }); +} + // ---- track rows ------------------------------------------------------------------------------ -fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) { +fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) { 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); let head = Rect::from_min_max(row.min, pos2(row.min.x + HEADER_W, row.max.y)); - let cols = edit_columns(app.engine.session()); - for (i, c) in cols.iter().enumerate() { - let cr = Rect::from_min_size(pos2(head.max.x + i as f32 * COLUMN_W, row.min.y), vec2(COLUMN_W, row.height())); - header_column(app, ui, &track, c, cr); + let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint); + let mut x = head.max.x; + for &(col, _, _, w) in cols { + header_column(app, ui, &track, col, Rect::from_min_size(pos2(x, row.min.y), vec2(w, row.height())), head_bg); + x += w; } let main_h = track.height.points(); let lane = lane_rect(&track, row, tl); - // Header background. - 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().rect_filled(head, 0.0, head_bg); + ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(STRIP_W, 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); + ui.painter().hline(head.min.x + STRIP_W..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light)); + track_header(app, ui, &track, head, selected, head_bg); // Lane. + ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border)); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); - painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg }); + painter.rect_filled(lane, 0.0, lane_bg); let s = app.engine.session().clone(); - grid_lines(&painter, &s, tl, lane); + // Shown grid lines go over the clips; otherwise the bar lines stay behind them. + if !s.edit.grid_lines { + grid_lines(&painter, &s, tl, lane); + } let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); let auto_view = AutoParam::parse(&track.view); for clip in track.clips() { @@ -707,6 +783,9 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some()); } crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane); + if s.edit.grid_lines { + grid_lines(&painter, &s, tl, lane); + } if let Some(p) = &auto_view { draw_automation(&painter, &s, &track, p, tl, lane); } @@ -768,7 +847,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) { +fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) { let t = Tokens::current(); let id = track.id; // Drag the bottom edge to resize (snaps to the height presets). @@ -786,7 +865,6 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let _ = app.run("track.height", json!({"tracks": [id.0], "height": best.label()})); } } - let x0 = head.min.x + 12.0; let h = head.height(); // Click on header background selects the track. let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click()); @@ -794,28 +872,61 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } bg.context_menu(|ui| track_context_menu(app, ui, id)); - // Name field. - let name_r = Rect::from_min_size(pos2(x0, head.min.y + 4.0), vec2(150.0, 17.0)); - ui.painter().rect( - name_r, - CornerRadius::same(2), - if selected { t.name_field_sel } else { t.name_field }, - Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), - StrokeKind::Inside, - ); + let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h)); + let strip = rgb(track.color); + let ink = shade(strip, -0.8); + // The strip's round button opens a folder, or any other track's options. + let round = at(2.0, 2.0, 10.0, 10.0); + let rresp = ui.interact(round, ui.id().with(("options", id.0)), Sense::click()); + ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 })); + arrow(ui.painter(), round.center(), !track.is_folder() || track.folder_open, ink); + if track.is_folder() { + if rresp.on_hover_text(if track.folder_open { "Close folder" } else { "Open folder" }).clicked() { + let _ = app.run("track.folder_toggle", json!({"track": id.0})); + } + } else { + egui::Popup::menu(&rresp.on_hover_text("Track options")).show(|ui| track_context_menu(app, ui, id)); + } + if h >= 40.0 && track.kind.has_playlist() { + let ar = at(3.0, h - 12.0, 9.0, 9.0); + let aresp = ui.interact(ar, ui.id().with(("auto_lane", id.0)), Sense::click()); + let showing = AutoParam::parse(&track.view).is_some(); + ui.painter().rect_filled(ar, 1.5, shade(strip, if aresp.hovered() || showing { 0.6 } else { 0.45 })); + let (a, b) = (ar.min + vec2(2.5, 2.5), ar.max - vec2(2.5, 2.5)); + ui.painter().line_segment([a, b], Stroke::new(1.0, ink)); + ui.painter().circle_filled(a, 1.3, ink); + ui.painter().circle_filled(b, 1.3, ink); + if aresp.on_hover_text(if showing { "Hide automation" } else { "Show volume automation" }).clicked() { + let view = if !showing { + "volume" + } else if track.kind.is_midi() { + "notes" + } else { + "waveform" + }; + let _ = app.run("track.view", json!({"track": id.0, "view": view})); + } + } + // Name, highlighted when the track is selected, and the playlist menu beside it. + let name_r = at(16.0, 1.5, 97.0, 15.0); + if selected { + ui.painter().rect_filled(name_r, 2.0, t.name_field_sel); + } let shown_name = if app.engine.session().edit.flag("edit_view.track_number") { let n = app.engine.session().track_index(id).map_or(0, |i| i + 1); format!("{n} {}", track.name) } else { track.name.clone() }; - ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &shown_name, bold(12.0), t.text_dark); + let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) }; + ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color); let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click_and_drag()); if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } else if nresp.clicked() { click_track(app, id, ui.input(|i| i.modifiers), in_edit_window); } + nresp.context_menu(|ui| track_context_menu(app, ui, id)); // Drag the name plate to reorder tracks. if nresp.dragged() && let Some(p) = ui.ctx().pointer_latest_pos() @@ -847,11 +958,11 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele let _ = app.run("track.move", json!({"track": id.0, "to": to})); } } - // Playlist selector arrow. - let pl_r = Rect::from_min_size(pos2(name_r.max.x + 3.0, name_r.min.y), vec2(16.0, 17.0)); - icons::draw(ui.painter(), pl_r.shrink(3.0), "triangle_down", t.text); + let pl_r = at(114.0, 1.0, 11.0, 14.5); let presp = ui.interact(pl_r, ui.id().with(("pl", id.0)), Sense::click()); - egui::Popup::menu(&presp).show(|ui| { + ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 })); + arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text); + egui::Popup::menu(&presp.on_hover_text("Playlists")).show(|ui| { if ui.button("New...").clicked() { let _ = app.run("track.playlist_new", json!({"track": id.0})); } @@ -868,172 +979,237 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele } } }); - if track.is_folder() { - let fr = Rect::from_min_size(pos2(head.min.x + 6.0, name_r.min.y), vec2(10.0, 17.0)); - icons::draw(ui.painter(), fr, if track.folder_open { "triangle_down" } else { "triangle_right" }, t.text); - if ui.interact(fr, ui.id().with(("fold", id.0)), Sense::click()).clicked() { - let _ = app.run("track.folder_toggle", json!({"track": id.0})); - } - } - let kind_label = match track.kind { - TrackKind::Audio => "", - TrackKind::Aux => "AUX", - TrackKind::Master => "MASTER", - TrackKind::Midi => "MIDI", - TrackKind::Instrument => "INST", - TrackKind::Vca => "VCA", - TrackKind::Folder => "FOLDER", - TrackKind::Video => "VIDEO", - }; - if !kind_label.is_empty() { - 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; - } - // Buttons row. - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(Rect::from_min_size(pos2(x0, name_r.max.y + 5.0), vec2(170.0, 20.0))) - .layout(egui::Layout::left_to_right(egui::Align::Min)), - ); - child.spacing_mut().item_spacing.x = 4.0; let m = &track.mixer; - let bs = vec2(26.0, 18.0); - if track.kind.has_playlist() { - if rec_toggle(&mut child, bs, m.record_arm, "Record enable").clicked() { - let _ = app.run("mix.record_arm", json!({"track": id.0})); - } - if text_toggle(&mut child, bs, "I", m.input_monitor, t.input, "Input monitoring").clicked() { - let _ = app.run("mix.input_monitor", json!({"track": id.0})); - } - } else { - child.add_space(bs.x * 2.0 + 4.0); - } - if track.kind != TrackKind::Master { - if text_toggle(&mut child, bs, "S", m.solo, t.solo, "Solo").clicked() { - let _ = app.run("mix.solo", json!({"track": id.0})); - } - if text_toggle(&mut child, bs, "M", m.mute, t.mute, "Mute").clicked() { - let _ = app.run("mix.mute", json!({"track": id.0})); - } - } - if h < 60.0 { - return; - } - // View selector. - let view_r = Rect::from_min_size(pos2(x0, head.min.y + 49.0), vec2(118.0, 16.0)); - let mut c2 = ui.new_child(egui::UiBuilder::new().max_rect(view_r)); - let vresp = selector_box(&mut c2, view_r.width(), view_r.height(), &view_label(&track.view), t.text); - egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); - // Automation mode. - let am_r = Rect::from_min_size(pos2(x0, head.min.y + 68.0), vec2(118.0, 16.0)); - let mode = m.automation_mode; - let col = match mode { - soundcraft_model::AutomationMode::Read => t.auto_read, - soundcraft_model::AutomationMode::Write => t.auto_write, - soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch, - soundcraft_model::AutomationMode::Latch => t.auto_latch, - _ => t.text_dim, - }; - let mut c3 = ui.new_child(egui::UiBuilder::new().max_rect(am_r)); - let aresp = selector_box(&mut c3, am_r.width(), am_r.height(), mode.label(), col); - egui::Popup::menu(&aresp).show(|ui| { - for m in soundcraft_model::AutomationMode::ALL { - if ui.selectable_label(m == mode, m.label()).clicked() { - let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + if h >= 37.0 { + let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master); + let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo); + let keys = [ + (HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records), + (HeaderKey::Input, m.input_monitor, false, "mix.input_monitor", records), + (HeaderKey::Solo, m.solo, false, "mix.solo", mixes), + (HeaderKey::Mute, m.mute, implied, "mix.mute", mixes), + ]; + for (i, (key, lit, implied, cmd, shown)) in keys.into_iter().enumerate() { + if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, key, lit, implied).clicked() { + let _ = app.run(cmd, json!({"track": id.0})); } } - }); - // Inline header meter. - let md = app.meters.get(&id).copied().unwrap_or_default(); - let mr = Rect::from_min_max(pos2(head.max.x - 16.0, head.min.y + 4.0), pos2(head.max.x - 6.0, head.max.y - 4.0)); - if track.channels() >= 2 { - let w = mr.width() / 2.0 - 0.5; - crate::widgets::meter(ui, Rect::from_min_size(mr.min, vec2(w, mr.height())), md.level[0], md.hold[0], md.clip); - crate::widgets::meter(ui, Rect::from_min_size(pos2(mr.min.x + w + 1.0, mr.min.y), vec2(w, mr.height())), md.level[1], md.hold[1], md.clip); - } else { - crate::widgets::meter(ui, mr, md.level[0], md.hold[0], md.clip); } - // Volume readout and inserts summary. - let info = format!("vol {} pan {}", crate::widgets::db_text(m.volume_db), m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p))); - ui.painter().text(pos2(view_r.max.x + 8.0, view_r.center().y), Align2::LEFT_CENTER, info, regular(10.0), t.text_dim); - let ins: Vec<&str> = m.inserts.iter().flatten().filter_map(|i| soundcraft_dsp::plugin_info(&i.plugin).map(|p| p.short_name)).collect(); - if !ins.is_empty() { - ui.painter().with_clip_rect(Rect::from_min_max(pos2(am_r.max.x + 6.0, am_r.min.y), pos2(head.max.x - 20.0, am_r.max.y))).text( - pos2(am_r.max.x + 8.0, am_r.center().y), - Align2::LEFT_CENTER, - ins.join(" · "), - regular(10.0), - t.text_dim, - ); + if h >= 58.0 { + let vresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.id().with(("view", id.0)), &view_label(&track.view), t.text, true); + egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track)); + } + if h >= 76.0 { + if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Aux) { + let r = at(17.0, 57.0, 13.0, 17.0); + let resp = ui.interact(r, ui.id().with(("freeze", id.0)), Sense::click()); + raised_key(ui.painter(), r, if track.frozen { t.slot_bypassed } else { t.slot_face }, resp.hovered()); + for a in [0.0f32, 60.0, 120.0] { + let d = vec2(a.to_radians().sin(), a.to_radians().cos()) * 3.5; + ui.painter().line_segment([r.center() - d, r.center() + d], Stroke::new(1.5, t.text)); + } + if resp.on_hover_text(if track.frozen { "Unfreeze" } else { "Freeze" }).clicked() { + let _ = app.run("track.freeze", json!({"tracks": [id.0]})); + } + } + if !matches!(track.kind, TrackKind::Master | TrackKind::Vca | TrackKind::Folder) { + let (voice, color) = if track.inactive { ("off", t.text_dim) } else { ("dyn", t.text) }; + let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.id().with(("voice", id.0)), voice, color, false); + egui::Popup::menu(&resp.on_hover_text("Voice: dyn plays the track, off makes it inactive")).show(|ui| { + for (label, inactive) in [("dyn", false), ("off", true)] { + if ui.selectable_label(track.inactive == inactive, label).clicked() { + let _ = app.run("track.make_inactive", json!({"tracks": [id.0], "inactive": inactive})); + } + } + }); + } + let mode = m.automation_mode; + let col = match mode { + soundcraft_model::AutomationMode::Read => t.auto_read, + soundcraft_model::AutomationMode::Write => t.auto_write, + soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch, + soundcraft_model::AutomationMode::Latch => t.auto_latch, + _ => t.text_dim, + }; + let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.id().with(("auto_mode", id.0)), mode.label(), col, true); + egui::Popup::menu(&aresp).show(|ui| { + for m in soundcraft_model::AutomationMode::ALL { + if ui.selectable_label(m == mode, m.label()).clicked() { + let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()})); + } + } + }); + } + if h >= 94.0 { + if track.kind.has_playlist() { + let r = at(17.0, 75.0, 13.0, 17.0); + let resp = ui.interact(r, ui.id().with(("timebase", id.0)), Sense::click()); + raised_key(ui.painter(), r, t.slot_face, resp.hovered()); + let c = r.center(); + if track.ticks_timebase { + let green = Color32::from_rgb(86, 172, 103); + ui.painter().add(Shape::convex_polygon( + vec![c + vec2(-3.0, 3.25), c + vec2(3.0, 3.25), c + vec2(1.0, -3.25), c + vec2(-1.0, -3.25)], + green, + Stroke::NONE, + )); + ui.painter().line_segment([c + vec2(0.0, 2.0), c + vec2(2.0, -3.0)], Stroke::new(1.0, t.text)); + } else { + ui.painter().circle_filled(c, 3.25, Color32::from_rgb(100, 184, 249)); + ui.painter().line_segment([c, c + vec2(0.0, -3.25)], Stroke::new(1.0, t.slot_face[1])); + ui.painter().line_segment([c, c + vec2(3.25, 0.0)], Stroke::new(1.0, t.slot_face[1])); + } + let tip = if track.ticks_timebase { "Timebase: ticks" } else { "Timebase: samples" }; + egui::Popup::menu(&resp.on_hover_text(tip)).show(|ui| { + for (label, ticks) in [("Samples", false), ("Ticks", true)] { + if ui.selectable_label(track.ticks_timebase == ticks, label).clicked() { + let _ = app.run("track.timebase", json!({"tracks": [id.0], "ticks": ticks})); + } + } + }); + } + let field = at(30.0, 75.0, 92.0, 17.0); + if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument) { + let resp = ui.interact(field, ui.id().with(("field", id.0)), Sense::click()); + ui.painter().rect_filled(field, 2.0, shade(head_bg, if resp.hovered() { -0.02 } else { -0.1 })); + if track.kind == TrackKind::Instrument { + let name = + track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("no instrument", |p| p.short_name); + ui.painter().with_clip_rect(field).text(pos2(field.min.x + 5.0, field.center().y), Align2::LEFT_CENTER, name, bold(10.0), t.text); + egui::Popup::menu(&resp.on_hover_text("Instrument")).show(|ui| crate::mix_window::instrument_menu(app, ui, id)); + } else { + let c = pos2(field.min.x + 7.0, field.center().y); + ui.painter().line_segment([c + vec2(0.0, -3.5), c + vec2(0.0, 1.0)], Stroke::new(1.0, t.text)); + ui.painter().add(Shape::convex_polygon(vec![c + vec2(0.0, 0.5), c + vec2(3.0, 3.5), c + vec2(-3.0, 3.5)], t.text, Stroke::NONE)); + if let Some(alg) = &track.elastic { + ui.painter().with_clip_rect(field).text(pos2(field.min.x + 14.0, field.center().y), Align2::LEFT_CENTER, alg, bold(10.0), t.text); + } + egui::Popup::menu(&resp.on_hover_text("Elastic Audio")).show(|ui| { + for alg in ["none", "polyphonic", "rhythmic", "monophonic", "varispeed", "x-form"] { + let current = track.elastic.as_deref().unwrap_or("none") == alg; + if ui.selectable_label(current, alg).clicked() { + let _ = app.run("track.elastic", json!({"tracks": [id.0], "algorithm": alg})); + } + } + }); + } + } + } + let md = app.meters.get(&id).copied().unwrap_or_default(); + header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2); +} + +/// A small menu arrow, pointing down, or right for a closed folder. +fn arrow(p: &egui::Painter, c: Pos2, down: bool, color: Color32) { + let pts = if down { + vec![c + vec2(-2.5, -1.75), c + vec2(2.5, -1.75), c + vec2(0.0, 1.75)] + } else { + vec![c + vec2(-1.75, -2.5), c + vec2(1.75, 0.0), c + vec2(-1.75, 2.5)] + }; + p.add(Shape::convex_polygon(pts, color, Stroke::NONE)); +} + +/// A header selector (track view, automation mode…): the value centred, and for menus an arrow +/// in the top-right corner. +fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool) -> egui::Response { + let resp = ui.interact(r, id, Sense::click()); + raised_key(ui.painter(), r, Tokens::current().slot_face, resp.hovered()); + let tr = if menu { r.with_max_x(r.max.x - 7.0) } else { r }; + ui.painter().with_clip_rect(tr.shrink(1.0)).text(tr.center(), Align2::CENTER_CENTER, text, bold(10.0), color); + if menu { + arrow(ui.painter(), pos2(r.max.x - 5.5, r.min.y + 5.75), true, Tokens::current().text); + } + resp +} + +#[derive(Clone, Copy, PartialEq)] +enum HeaderKey { + Rec, + Input, + Solo, + Mute, +} + +/// Record, input, solo or mute: `lit` gives the key its colour, `implied` a dark shade of it (a +/// mute that another track's solo implies). Record keys stay dark red when off, as in Pro Tools. +fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, implied: bool) -> egui::Response { + let t = Tokens::current(); + let (label, color) = match key { + HeaderKey::Rec => ("Record enable", t.rec), + HeaderKey::Input => ("Input monitoring", t.input), + HeaderKey::Solo => ("Solo", t.solo), + HeaderKey::Mute => ("Mute", t.mute), + }; + let resp = ui.interact(r, ui.id().with((label, track.0)), Sense::click()); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), lit, label)); + let rec = key == HeaderKey::Rec; + let (tint, k) = if lit { + (color, 0.85) + } else if implied { + (shade(color, -0.35), 0.55) + } else if rec { + (shade(color, -0.5), 0.35) + } else { + (color, 0.0) + }; + let [top, bottom] = t.slot_face.map(|f| f.lerp_to_gamma(tint, k)); + raised_key(ui.painter(), r, [shade(top, 0.08), shade(bottom, -0.12)], resp.hovered()); + let ink = if lit { + t.text_dark + } else if implied { + t.text.lerp_to_gamma(color, 0.3) + } else { + t.text + }; + if rec { + ui.painter().circle_filled(r.center(), 4.0, if lit { Color32::WHITE } else { t.text.lerp_to_gamma(color, 0.3) }); + } else { + ui.painter().text(r.center(), Align2::CENTER_CENTER, label.get(..1).unwrap_or_default(), bold(11.0), ink); + } + resp.on_hover_text(label) +} + +/// The header's level meter: black-rimmed bars, dim where unlit, with a clip light on top. +fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) { + let t = Tokens::current(); + let p = ui.painter(); + p.rect_filled(r, 0.0, Color32::BLACK); + let inner = r.shrink(1.0); + let clip = inner.with_max_y(inner.min.y + 4.0); + p.rect_filled(clip, 0.0, if md.clip { t.meter_red } else { t.meter_red.gamma_multiply(0.18) }); + let body = inner.with_min_y(clip.max.y + 1.0); + let n = if stereo { 2 } else { 1 }; + let w = (body.width() - (n - 1) as f32) / n as f32; + let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0)); + for ch in 0..n { + let bar = Rect::from_min_size(pos2(body.min.x + ch as f32 * (w + 1.0), body.min.y), vec2(w, body.height())); + let y = |f: f32| bar.max.y - bar.height() * f; + let lv = level_pos(md.level[ch]); + for (lo, hi, col) in [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)] { + p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(lo)), 0.0, col.gamma_multiply(0.18)); + if lv > lo { + p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(lv.min(hi))..=y(lo)), 0.0, col); + } + } + let hv = level_pos(md.hold[ch]); + if hv > 0.01 { + p.hline(bar.x_range(), y(hv), Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); + } } } +/// A header column's own Ui, so its slots' ids can't collide with the track header's. +fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui { + ui.new_child(egui::UiBuilder::new().id_salt(("edit_column", col)).max_rect(r)) +} + /// 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::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; - let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize; - let item = |i: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 3.0 + i as f32 * line_h), vec2(r.width() - 8.0, line_h - 2.0)); - let text = |ui: &Ui, rr: Rect, s: &str, c: Color32| { - ui.painter().rect_filled(rr, 2.0, t.slot_bg); - ui.painter().with_clip_rect(rr).text(pos2(rr.min.x + 4.0, rr.center().y), Align2::LEFT_CENTER, s, regular(10.0), c); - }; - let route = |r: &soundcraft_model::Route| match r { - soundcraft_model::Route::None => "none".to_string(), - soundcraft_model::Route::Main => "Out 1-2".to_string(), - soundcraft_model::Route::Bus(b) => app.engine.session().bus(*b).map_or("bus".into(), |b| b.name.clone()), - soundcraft_model::Route::Hardware(h) => h.clone(), - }; +fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect, head_bg: Color32) { match col { - "io" => { - if rows >= 1 { - text(ui, item(0), &format!("in: {}", route(&track.mixer.input)), t.text); - } - if rows >= 2 { - text(ui, item(1), &format!("out: {}", route(&track.mixer.output)), t.text); - } - } - "inserts_ae" | "inserts_fj" => { - let off = if col == "inserts_ae" { 0 } else { 5 }; - for i in 0..5.min(rows) { - let name = track - .mixer - .inserts - .get(off + i) - .cloned() - .flatten() - .and_then(|x| soundcraft_dsp::plugin_info(&x.plugin).map(|p| p.name)) - .unwrap_or(""); - text(ui, item(i), name, t.text); - } - } - "sends_ae" | "sends_fj" => { - let off = if col == "sends_ae" { 0 } else { 5 }; - for i in 0..5.min(rows) { - let s = track - .mixer - .sends - .get(off + i) - .cloned() - .flatten() - .map(|x| format!("{} {:.0}", route(&x.target), x.level_db.max(-99.0))) - .unwrap_or_default(); - text(ui, item(i), &s, t.text); - } - } - _ => { + "io" => io_column(app, ui, track, r), + "comments" => { + ui.painter().rect_filled(r, 0.0, head_bg); + ui.painter().vline(r.min.x, r.y_range(), Stroke::new(1.0, Tokens::current().border)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0))); if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() { @@ -1041,6 +1217,82 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R app.engine.execute_merged("track.comments", &json!({"track": track.id.0, "comments": c}), &format!("comments:{}", track.id.0)); } } + _ => plugin_column(app, ui, track, col, r), + } +} + +/// The I/O column as Pro Tools draws it: input and output selectors, then volume and pan. +fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { + let t = Tokens::current(); + let id = track.id; + ui.painter().rect_filled(r, 0.0, t.slot_well); + let ui = column_ui(ui, "io", r); + let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h))); + if let Some(input) = row(4.0, 15.0) { + let resp = header_selector(&ui, input, ui.id().with(("in", id.0)), &crate::mix_window::input_name(app, track), t.text, true); + crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true); + } + if let Some(out) = row(21.0, 17.0) { + let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0)); + let resp = ui.interact(sel, ui.id().with(("out", id.0)), Sense::click()); + let fresp = ui.interact(fader, ui.id().with(("out_fader", id.0)), Sense::click()); + raised_key(ui.painter(), out, t.slot_face, resp.hovered() || fresp.hovered()); + groove(ui.painter(), fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, t.slot_face[0]); + let name = crate::mix_window::route_name(app, &track.mixer.output); + ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, name, bold(10.0), t.text); + let c = fader.center() + vec2(0.5, 0.0); + ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text)); + ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text); + crate::mix_window::route_popup(app, &resp.on_hover_text("Output"), id, false); + if fresp.on_hover_text("Show in the Mix window").clicked() { + let _ = app.run("edit.select", json!({"tracks": [id.0]})); + let _ = app.run("window.mix", json!({})); + } + } + if let Some(vr) = row(45.0, 30.0).map(|vr| vr.shrink2(vec2(1.0, 0.0))) { + ui.painter().rect_filled(vr, 2.0, Color32::from_gray(15)); + ui.painter().rect_filled(vr.shrink(1.5), 1.5, Color32::BLACK); + let m = &track.mixer; + let pan = m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p)); + // The readout is black in both themes, so it takes the dark palette's green. + let ink = Tokens::DARK.counter_text; + for (y, label, value) in [(7.5, "vol", crate::widgets::db_text(m.volume_db)), (22.0, "pan", pan)] { + ui.painter().text(pos2(vr.min.x + 7.0, vr.min.y + y), Align2::LEFT_CENTER, label, bold(10.0), ink); + ui.painter().text(pos2(vr.max.x - 6.5, vr.min.y + y), Align2::RIGHT_CENTER, value, bold(10.0), ink); + } + ui.painter().hline(vr.min.x + 7.0..=vr.max.x - 6.0, vr.min.y + 14.5, Stroke::new(1.0, Color32::from_gray(35))); + } +} + +/// Inserts, sends or the instrument, laid out as Pro Tools does: 15-point cells every 18 points +/// on a neutral well, or on tracks too short for three of them, a row of squares. +fn plugin_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) { + let t = Tokens::current(); + ui.painter().rect_filled(r, 0.0, t.slot_well); + let rows = if r.height() < 16.0 { 0 } else { ((r.height() - 16.0) / 18.0) as usize + 1 }; + let cell = |row: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 1.0 + row as f32 * 18.0), vec2(r.width() - 8.0, 15.0)); + let square = |i: usize, row: usize| Rect::from_min_size(cell(row).min + vec2(i as f32 * 14.0, 0.0), vec2(10.0, 15.0)); + let mut ui = column_ui(ui, col, r); + if col == "instruments" { + if rows > 0 && track.kind == TrackKind::Instrument { + crate::mix_window::instrument_slot(app, &mut ui, track, cell(0)); + } + return; + } + let off = if col.ends_with("ae") { 0 } else { 5 }; + let slot = if col.starts_with("inserts") { crate::mix_window::insert_slot } else { crate::mix_window::send_slot }; + let n = match rows { + 0 => 0, + 1 | 2 => 5, + _ => rows.min(5), + }; + for i in 0..n { + slot(app, &mut ui, track, off + i, if rows < 3 { square(i, 0) } else { cell(i) }); + } + if rows == 2 { + for i in 0..5 { + ui.painter().rect_filled(square(i, 1), 1.5, t.slot_empty); + } } } @@ -1079,7 +1331,7 @@ fn view_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track) { } } -fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { +pub(crate) fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let tid = id.0; let items: [(&str, &str); 10] = [ ("Rename...", "ui.rename_track"), @@ -1130,22 +1382,35 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) { let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px; if bar_px > 6.0 { + let col = grid_veil(t.bar_line, t.playlist_bg); for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) { let x = x_of(s, tl, at); - painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.bar_line)); + painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col)); } } if s.edit.grid_lines { let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px; if step_px > 7.0 { + let col = grid_veil(t.grid_line, t.playlist_bg); for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) { let x = x_of(s, tl, at); - painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.grid_line)); + painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col)); } } } } +/// The translucent white or black that turns `bg` into `line`, so grid lines also show over clips. +fn grid_veil(line: Color32, bg: Color32) -> Color32 { + let level = |c: Color32| (f32::from(c.r()) + f32::from(c.g()) + f32::from(c.b())) / 3.0; + let (l, b) = (level(line), level(bg)); + if l > b { + Color32::from_white_alpha(((l - b) / (255.0 - b) * 255.0).round() as u8) + } else { + Color32::from_black_alpha(((b - l) / b.max(1.0) * 255.0).round() as u8) + } +} + fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) { let x0 = x_of(s, tl, clip.start); let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0); @@ -1325,7 +1590,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.line_segment([pos2(x, r.min.y + name_h), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90))); } let time_text = if s.edit.flag("view.clip.time.current") { - Some(format_position(clip.start, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)) + Some(s.format_position(clip.start, s.edit.main_counter)) } else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") { clip.source().and_then(|src| s.source(src)).map(|src| { format_position( @@ -1335,6 +1600,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track &s.tempo, s.frame_rate, 0, + 0, ) }) } else { @@ -1636,7 +1902,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, Tool::Pencil => { if let Some(param) = &auto_view { let (lo, hi, _) = param.range(); - let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { (-60.0, 12.0) } else { (lo, hi) }; + let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { + (-60.0, 12.0) + } else if matches!(param, AutoParam::Plugin { .. }) { + plugin_range(track, param) + } else { + (lo, hi) + }; let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0); let _ = app.run( "automation.set_point", @@ -2494,4 +2766,24 @@ mod tests { let want: Vec = [1, 0, 2, 3].iter().map(|&i| before[i]).collect(); assert_eq!(order(&h), want); } + + #[test] + fn a_pencil_click_on_a_plugin_lane_uses_the_plugin_range() { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.execute("track.view", &json!({"track": "Kick", "view": "plugin:a:input_gain"})).unwrap(); + engine.execute("edit.tool", &json!({"tool": "pencil"})).unwrap(); + let mut h = harness_with(engine); + let s = h.state().engine.session_arc(); + let kick = s.track_by_name("Kick").unwrap(); + let i = h.state().edit_layout.rows.iter().position(|(id, _)| *id == kick.id.0).unwrap(); + let lane = lane_rect(kick, row(&h, i), timeline(&h)); + // A quarter up the lane: -12 dB in the Input range of -24..24 dB. + let at = pos2(lane.min.x + 100.0, lane.max.y - 4.0 - (lane.height() - 8.0) * 0.25); + click(&mut h, at); + let s = h.state().engine.session_arc(); + let param = AutoParam::Plugin { slot: 0, param: "input_gain".into() }; + let points = s.track_by_name("Kick").and_then(|t| t.lane(¶m)).map(|l| l.points.clone()).unwrap_or_default(); + assert_eq!(points.len(), 1, "status: {}", h.state().ui.status); + assert!((points[0].value + 12.0).abs() < 0.01, "{}", points[0].value); + } } diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 7de4119..885824d 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -123,9 +123,6 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { "triangle_down" => { painter.add(Shape::convex_polygon(vec![p(r, 0.25, 0.35), p(r, 0.75, 0.35), p(r, 0.5, 0.68)], c, Stroke::NONE)); } - "triangle_right" => { - painter.add(Shape::convex_polygon(vec![p(r, 0.35, 0.25), p(r, 0.68, 0.5), p(r, 0.35, 0.75)], c, Stroke::NONE)); - } "note" => { painter.circle_filled(p(r, 0.38, 0.72), r.width() * 0.13, c); painter.line_segment([p(r, 0.5, 0.72), p(r, 0.5, 0.2)], s); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e36dcd..423ef5b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -68,8 +68,13 @@ pub struct UiState { pub mix_views: Vec, /// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color. pub edit_views: Vec, + /// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip). + pub track_tint: f32, /// Open plugin windows (track, slot). pub plugin_windows: Vec<(TrackId, usize)>, + /// The send in the Send window (track, slot). + #[serde(skip)] + pub send_window: Option<(TrackId, usize)>, pub audiosuite: Option, pub status: String, pub show_automation: bool, @@ -128,7 +133,9 @@ impl Default for UiState { narrow_mix: false, mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()], edit_views: vec!["color".into()], + track_tint: 0.5, plugin_windows: Vec::new(), + send_window: None, audiosuite: None, status: String::new(), show_automation: false, @@ -260,17 +267,24 @@ pub struct SoundApp { pub(crate) docking_context: Option, /// Transport simulation when there is no player (tests, offscreen renders). sim: Option<(Samples, Option, Option)>, + /// When Record last found nothing to record on, and why: the Record buttons show it for a moment. + record_hint: Option<(f64, &'static str)>, pub quit_requested: bool, /// Reset floating-window positions next frame (Window › Arrange). pub arrange_request: bool, /// Tracks whose automation was touched during this pass (Touch/Latch writing). touched: std::collections::HashSet, + /// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped. last_write_at: Samples, pub frame_ms: f32, last_frame: Option, last_meter_poll: f64, /// Movies for the Video window and Video track (decoded in the background). pub video: video_track::VideoPool, + /// Test-only: owns the process-wide palette lock while this app exists, so frames painted + /// and colour assertions in one test can never straddle another test's palette change. + #[cfg(test)] + _palette_lock: theme::TestPaletteGuard, } /// Whether another automation-write pass is due: the transport moved at least @@ -319,6 +333,7 @@ impl SoundApp { theme_applied: None, docking_context: None, sim: None, + record_hint: None, quit_requested: false, arrange_request: false, touched: std::collections::HashSet::new(), @@ -327,6 +342,8 @@ impl SoundApp { last_frame: None, last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), + #[cfg(test)] + _palette_lock: theme::lock_test_palette(), } } @@ -370,6 +387,13 @@ impl SoundApp { self.engine.transport.playing } + /// Why the last Record press did nothing, for a few seconds and while it still holds. + pub(crate) fn record_hint(&self) -> Option<&'static str> { + let (at, why) = self.record_hint?; + let fresh = self.last_frame.unwrap_or_default() - at < 4.0; + (fresh && !self.engine.session().tracks.iter().any(|t| t.mixer.record_arm)).then_some(why) + } + /// Current transport position (playhead while playing, else the edit insertion). pub fn position(&self) -> Samples { if self.engine.transport.playing { self.engine.transport.position } else { self.engine.session().edit.selection.start } @@ -384,6 +408,7 @@ impl SoundApp { } self.engine.transport.playing = true; self.engine.transport.position = from; + self.last_write_at = i64::MIN; // 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. @@ -436,7 +461,13 @@ impl SoundApp { fn start_recording(&mut self) { let armed = self.engine.session().tracks.iter().any(|t| t.mixer.record_arm); if !armed { - self.ui.status = "Record-enable a track first (the red button in its header).".into(); + let why = if self.engine.session().tracks.is_empty() { + "Nothing to record on yet: add a track with Track › New…, then click its R button." + } else { + "No track is record-enabled: click the R button on the track to record." + }; + self.ui.status = why.into(); + self.record_hint = Some((self.last_frame.unwrap_or_default(), why)); return; } self.ensure_input(); @@ -604,23 +635,26 @@ impl SoundApp { } /// While playing, record volume automation from fader positions for tracks in Write mode, and - /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. One undo step per pass. + /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the + /// automation since the previous one, and Stop writes a last point. A pass and whatever is + /// dragged during it, its fader included, are one undo step. fn write_automation(&mut self, ctx: &egui::Context) { use soundcraft_model::AutomationMode as M; - if !self.is_playing() { - if !self.touched.is_empty() { - self.touched.clear(); - self.engine.end_merge(); - } + const PASS: &str = "automation_pass"; + let playing = self.is_playing(); + let may_write = + playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch)); + self.engine.hold_merge(may_write.then_some(PASS)); + if !playing && self.touched.is_empty() { self.last_write_at = i64::MIN; return; } - let pos = self.position(); + let pos = self.engine.transport.position; let step = self.engine.session().sample_rate.samples(0.02); - if !automation_step_due(pos, self.last_write_at, step) { + if playing && !automation_step_due(pos, self.last_write_at, step) { return; } - self.last_write_at = pos; + let from = std::mem::replace(&mut self.last_write_at, if playing { pos } else { i64::MIN }); let dragging = ctx.input(|i| i.pointer.any_down()); let fader_track = match &self.gesture { Some(Gesture::Fader { track, .. }) => Some(*track), @@ -645,11 +679,15 @@ impl SoundApp { .collect(); for (t, v) in writes { self.touched.insert(t); - let _ = self.engine.execute_merged( - "automation.set_point", - &serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}), - "automation_pass", - ); + let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}); + if from != i64::MIN { + point["from"] = from.into(); + } + let _ = self.engine.execute_merged("automation.set_point", &point, PASS); + } + if !playing { + self.touched.clear(); + self.engine.end_merge(); } } @@ -719,6 +757,7 @@ impl SoundApp { if let Some(sim) = &mut self.sim { sim.0 = at; } + self.last_write_at = i64::MIN; } self.engine.transport.position = at; } @@ -999,6 +1038,29 @@ mod tests { assert_eq!(app.engine.transport.position, 0); } + #[test] + fn record_with_nothing_armed_says_what_to_do() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("transport.record", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(!app.is_playing() && !app.engine.transport.recording); + assert!(app.record_hint().is_some_and(|why| why.contains("Track › New")), "{:?}", app.record_hint()); + + app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap(); + app.run("transport.record", json!({})).unwrap(); + app.handle_transport(0.25); + assert!(app.record_hint().is_some_and(|why| why.contains("R button")), "{:?}", app.record_hint()); + assert_eq!(Some(app.ui.status.as_str()), app.record_hint()); + app.last_frame = Some(5.0); + assert_eq!(app.record_hint(), None, "the hint shows for a few seconds"); + + app.last_frame = None; + app.run("transport.record", json!({})).unwrap(); + app.handle_transport(0.25); + app.run("mix.record_arm", json!({})).unwrap(); + assert_eq!(app.record_hint(), None, "arming a track answers the hint"); + } + #[test] fn a_hidden_window_still_autosaves() { let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id())); @@ -1061,6 +1123,67 @@ mod tests { assert!(automation_step_due(0, 1_000_000, 960)); } + #[test] + fn a_write_pass_replaces_the_automation_it_plays_over() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let fader = app.engine.session().track_by_name("Kick").unwrap().mixer.volume_db; + let q = i64::from(app.engine.session().sample_rate.hz()) / 4; + // The old ride: a point every 10 ms for 3 s. + for at in (0..12 * q).step_by(usize::try_from(q / 25).unwrap()) { + app.run("automation.set_point", json!({"track": "Kick", "at": at, "value": -30.0})).unwrap(); + } + app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap(); + app.run("transport.locate", json!({"at": 0})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + // Passes a quarter second apart, sparse like a covered window's. + let ctx = egui::Context::default(); + let pass = |app: &mut SoundApp| { + app.handle_transport(0.25); + app.write_automation(&ctx); + }; + pass(&mut app); + pass(&mut app); + // Two jumps: the stretches they skip keep the old ride. + app.run("transport.locate", json!({"at": 4 * q})).unwrap(); + pass(&mut app); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 7 * q, "end": 11 * q})).unwrap(); + app.run("transport.play_selection", json!({})).unwrap(); + pass(&mut app); + app.handle_transport(0.25); + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(&ctx); + let lane = app.engine.session().track_by_name("Kick").unwrap().lane(&soundcraft_model::AutoParam::Volume).unwrap(); + let values = |from, to| lane.points.iter().filter(|p| (from..=to).contains(&p.at)).map(|p| p.value).collect::>(); + assert_eq!(values(q, 2 * q), [fader; 2]); + assert_eq!(lane.value_at(3 * q, 0.0), -30.0); + assert_eq!(lane.value_at(6 * q, 0.0), -30.0); + // Stop writes the last point, at 9q. + assert_eq!(values(8 * q, 9 * q), [fader; 2]); + assert_eq!(lane.value_at(10 * q, 0.0), -30.0); + } + + #[test] + fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + let steps = app.engine.undo_history().len(); + let ctx = egui::Context::default(); + for frame in 0..60 { + app.handle_transport(1.0 / 60.0); + app.write_automation(&ctx); + // What the Mix window's fader sends while it is dragged. + let db = -10.0 + frame as f32 / 10.0; + app.engine.execute_merged("mix.volume", &json!({"track": kick.0, "db": db}), &format!("fader:{}", kick.0)).unwrap(); + } + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(&ctx); + assert_eq!(app.engine.undo_history().len(), steps + 1); + } + #[test] fn preferences_saved_before_the_window_was_remembered_keep_full_scale() { let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 32a74a4..3c20019 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -26,23 +26,23 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")), ("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")), ("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None), - ("window.universe", "Universe", "View > Other Displays > Universe", None), + ("window.universe", "Universe", "View > Other Displays > Universe", Some("Alt+7")), ("window.video", "Video", "Window > Video", Some("Cmd+9")), ("window.video_universe", "Video Universe", "Window > Video Universe", None), ("window.automation", "Automation", "Window > Automation", Some("Cmd+4")), ("window.color_palette", "Color Palette", "Window > Color Palette", None), ("window.disk_usage", "Disk Usage", "Window > Disk Usage", None), ("window.system_usage", "System Usage", "Window > System Usage", None), - ("window.task_manager", "Task Manager", "Window > Task Manager", None), + ("window.task_manager", "Task Manager", "Window > Task Manager", Some("Alt+'")), ("window.metadata", "Metadata Inspector", "Window > Metadata Inspector", None), - ("window.event_list", "MIDI Event List", "Window > MIDI Event List", None), - ("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", None), - ("window.workspace", "Workspace", "Window > New Workspace > Default", None), + ("window.event_list", "MIDI Event List", "Window > MIDI Event List", Some("Alt+=")), + ("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", Some("Shift+K")), + ("window.workspace", "Workspace", "Window > New Workspace > Default", Some("Alt+I")), ("window.workspace_presets", "Workspace", "Window > New Workspace > Track Presets", None), ("window.workspace_sounds", "Workspace", "Window > New Workspace > Soundbase", None), - ("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", None), + ("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", Some("Alt+J")), ("window.workspace_close", "Close All Workspaces", "Window > Workspaces > Close All Workspaces", None), - ("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", None), + ("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", Some("Cmd+Alt+J")), ("window.config_new", "New Configuration", "Window > Configurations > New Configuration...", None), ("window.config_update", "Update Active Configuration", "Window > Configurations > Update Active Configuration", None), ("window.config_auto_update", "Auto-Update Active Configuration", "Window > Configurations > Auto-Update Active Configuration", None), @@ -55,7 +55,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[ ("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None), ("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None), ("window.clip_effects", "Clip Effects", "Window > Clip Effects", None), - ("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", None), + ("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")), ("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")), ("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")), ("window.beat_detective", "Beat Detective", "", None), @@ -200,6 +200,12 @@ pub fn tree() -> Vec { insert_path(&mut roots, line); } insert_path(&mut roots, "Window > Reset Panel Layout"); + // Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282). + // It creates a clip rather than acting on the selected ones, so it leads the Clip menu. + insert_path(&mut roots, "Clip > New MIDI Clip"); + if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") { + clip.children.rotate_right(1); + } for (path, _) in MENU_WINDOWS { insert_path(&mut roots, path); } @@ -490,7 +496,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option toggle(&mut app.ui.show_about), - "window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor), + "window.midi_editor" => toggle(&mut app.ui.show_midi_editor), + "window.midi_front" => { + app.ui.show_midi_editor = true; + json!({"value": true}) + } "window.midi_back" => { app.ui.show_midi_editor = false; json!({}) @@ -557,6 +567,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option Option { let s = app.engine.session(); for id in &s.edit.selected_clips { if let Some((_, c)) = s.find_clip(*id) - && !c.is_audio() + && matches!(c.content, ClipContent::Midi { .. }) { return Some(*id); } @@ -101,6 +101,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { regular(11.0), t.text_dim, ); + let grid_ticks = match app.engine.session().edit.grid { + soundcraft_time::GridValue::Note { value, dotted, triplet } => { + let mut g = value.ticks(); + if dotted { + g = g * 3 / 2; + } + if triplet { + g = g * 2 / 3; + } + g.max(1) + } + _ => TICKS_PER_QUARTER / 4, + }; // Header toolbar: whole-clip MIDI operations. let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0)); let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center))); @@ -116,7 +129,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { op = Some(("event.change_velocity", json!({"add": 10}))); } if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() { - op = Some(("event.quantize", json!({"grid": "1/16", "strength": 100}))); + op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100}))); } if let Some((id, mut p)) = op { p["clips"] = json!([cid.0]); @@ -126,19 +139,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let sr = s.sample_rate; let base_tick = s.tempo.samples_to_ticks(clip.start, sr); let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1); - let grid_ticks = match s.edit.grid { - soundcraft_time::GridValue::Note { value, dotted, triplet } => { - let mut g = value.ticks(); - if dotted { - g = g * 3 / 2; - } - if triplet { - g = g * 2 / 3; - } - g.max(1) - } - _ => TICKS_PER_QUARTER / 4, - }; let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0)); let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y)); let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0)); @@ -317,12 +317,13 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { // Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid. if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() { let (all, up, down, left, right, shift) = ui.input(|i| { + let arrows = !i.modifiers.alt && !i.modifiers.command && !i.modifiers.ctrl; ( i.modifiers.command && i.key_pressed(egui::Key::A), - i.key_pressed(egui::Key::ArrowUp), - i.key_pressed(egui::Key::ArrowDown), - i.key_pressed(egui::Key::ArrowLeft), - i.key_pressed(egui::Key::ArrowRight), + arrows && i.key_pressed(egui::Key::ArrowUp), + arrows && i.key_pressed(egui::Key::ArrowDown), + arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowLeft), + arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowRight), i.modifiers.shift, ) }); @@ -347,7 +348,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi})); } } - if !app.midi.selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + if !app.midi.selected.is_empty() + && !ui.ctx().egui_wants_keyboard_input() + && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + { let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected})); app.midi.selected.clear(); } diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index 314267a..01bb21a 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -3,8 +3,8 @@ use crate::SoundApp; use crate::theme::{Tokens, bold, regular, rgb}; use crate::widgets::{ - PannerEdit, PannerSpeaker, db_text, fader, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box, selector_box_with_tooltip, - surround_panner, text_toggle, + PannerEdit, PannerSpeaker, db_text, fader, groove, inset, meter, multi_meter, pan_knob, pan_text, raised_key, rec_toggle, selector_box, + selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient, }; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; @@ -29,6 +29,10 @@ pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) { for id in ids { strip(app, ui, id, snap.as_ref()); } + let rest = Rect::from_min_max(pos2(ui.cursor().min.x, ui.clip_rect().min.y), ui.clip_rect().max); + if rest.width() > 4.0 && ui.interact(rest, ui.id().with("past_strips"), Sense::click()).double_clicked() { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + } }); }); } @@ -75,11 +79,29 @@ fn channel_levels(ui: &Ui, key: (&str, u64), peaks: &[f32], n: usize) -> Vec &str { + match text { + "INSERTS A-E" => "I A-E", + "INSERTS F-J" => "I F-J", + "SENDS A-E" => "S A-E", + "SENDS F-J" => "S F-J", + "INSTRUMENT" => "INST", + _ => text, + } } -fn route_name(app: &SoundApp, r: &Route) -> String { +fn section_label(ui: &Ui, r: Rect, text: &str) { + let p = ui.painter(); + let color = Tokens::current().section_label; + let mut galley = p.layout_no_wrap(text.to_owned(), bold(10.0), color); + if galley.size().x > r.width() - 8.0 { + galley = p.layout_no_wrap(short_label(text).to_owned(), bold(10.0), color); + } + p.galley(Align2::CENTER_CENTER.anchor_size(pos2(r.center().x, r.min.y + 7.0), galley.size()).min, galley, color); +} + +pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String { match r { Route::None => "no output".into(), Route::Main => app.engine.session().outputs.first().map_or_else(|| "Out 1-2".into(), |o| o.name.clone()), @@ -88,6 +110,16 @@ fn route_name(app: &SoundApp, r: &Route) -> String { } } +pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String { + if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) } +} + +/// The input or output menu under `resp`. It closes only on a click outside, so its bus search +/// field can be typed in. +pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId, input: bool) { + egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input)); +} + fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) { let t = Tokens::current(); let Some(track) = app.engine.session().track(id).cloned() else { return }; @@ -96,7 +128,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh 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 { t.strip_selected } else { t.strip_bg }); + let bg = if selected { t.strip_selected } else { t.strip_bg }; + vgradient(ui.painter(), r, shade(bg, 0.06), bg); + ui.painter().vline(r.min.x + 0.5, r.y_range(), Stroke::new(1.0, t.edge_light)); let mut y = r.min.y + 4.0; let inner_w = w - 8.0; let x0 = r.min.x + 4.0; @@ -109,7 +143,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh if all || has("mic_preamps") { // Input gain stage ahead of the inserts (the track's trim). let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "PREAMP"); if has_input { let kr = Rect::from_min_size(pos2(x0 + 6.0, sec.min.y + 15.0), vec2(inner_w - 12.0, 16.0)); @@ -127,7 +161,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } if all || has("instruments") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "INSTRUMENT"); 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))); @@ -163,7 +197,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh continue; } let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, title); for k in 0..5 { let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + k as f32 * 17.0), vec2(inner_w - 4.0, 15.0)); @@ -208,25 +242,24 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 44.0)); let mut c = track.comments.clone(); let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec)); - if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text("comments").font(regular(10.0))).changed() { + let hint = if narrow { "" } else { "comments" }; + if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))).changed() { let _ = app.engine.execute_merged("track.comments", &json!({"track": id.0, "comments": c}), &format!("comments:{}", id.0)); } y = sec.max.y + 4.0; } if all || has("io") { let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0)); - ui.painter().rect_filled(sec, 2.0, t.strip_section); + inset(ui.painter(), sec, t.strip_section); section_label(ui, sec, "I / O"); let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0)); let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)); - let input = - if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }; let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r)); - let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text); - egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true)); + let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input_name(app, &track), t.text); + route_popup(app, &resp, id, true); let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r)); let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text); - egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false)); + route_popup(app, &resp, id, false); // Automation mode. let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0)); ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim); @@ -258,7 +291,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh y = sec.max.y + 4.0; } // Group selector. - let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or("no group".to_string(), |g| g.name.clone()); + let no_group = if narrow { "nogrp" } else { "no group" }; + let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or(no_group.to_string(), |g| g.name.clone()); let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0)); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr)); let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim); @@ -292,7 +326,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 }; for i in 0..n { let cx = if n == 2 { x0 + inner_w * (0.25 + 0.5 * i as f32) } else { x0 + inner_w * 0.5 }; - let kr = Rect::from_center_size(pos2(cx, y + ks * 0.5), vec2(ks, ks)); + let kr = Rect::from_center_size(pos2(cx, y + 17.0), vec2(ks, ks)); let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr)); let mut v = track.mixer.pan.get(i).copied().unwrap_or(0.0); let before = v; @@ -301,14 +335,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } let _ = resp; - let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0)); - ui.painter().rect_filled(pr, 1.0, t.counter_bg); - let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan); + let pr = Rect::from_center_size(pos2(cx, y + 42.0), vec2(ks + 8.0, 13.0)); + inset(ui.painter(), pr, t.counter_bg); + let (_, entered) = + editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan, 0.0); if let Some(v) = entered { let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0)); } } - y += ks + 20.0; + y += 54.0; } // Rec / Input / Solo / Mute. let bw = (inner_w - 6.0) / 2.0; @@ -370,7 +405,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh } // Volume readout and name. let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0)); - ui.painter().rect_filled(vr, 1.0, t.counter_bg); + inset(ui.painter(), vr, t.counter_bg); let (_, entered) = editable_readout( ui, vr, @@ -378,6 +413,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh &db_text(track.mixer.volume_db), &format!("{:.1}", track.mixer.volume_db), parse_db, + 0.0, ); if let Some(db) = entered { let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0)); @@ -409,6 +445,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh if nresp.double_clicked() { app.dialogs.open_rename_track(id, &track.name); } + nresp.context_menu(|ui| crate::edit_window::track_context_menu(app, ui, id)); if app.ui.mix_views.iter().any(|v| v == "color") { ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x, r.max.y - 5.0), vec2(w, 5.0)), 0.0, rgb(track.color)); } @@ -417,21 +454,16 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh /// 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) { +pub(crate) 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() { t.insert_on } else { t.slot_bg }; - ui.painter().rect( - r, - CornerRadius::same(2), - if resp.hovered() { fill.gamma_multiply(1.3) } else { fill }, - Stroke::new(1.0, t.slot_border), - StrokeKind::Inside, - ); - ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text); + match info { + Some(info) => used_slot(ui.painter(), r, t.slot_face, Mark::Dot, info.short_name, t.text, None), + None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), + } 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)); @@ -441,33 +473,101 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) { popup.show(|ui| instrument_menu(app, ui, id)); } -fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { +/// What a plugin slot shows at its left: a dot for inserts, the send's letter, or an arrow for +/// a send on a short track. +#[derive(Clone, Copy)] +enum Mark { + Dot, + Letter(char), + Arrow, +} + +/// Slots narrower than this are a short track's squares: a mark, no name. +const COMPACT_SLOT: f32 = 30.0; +const NAME_SIZE: f32 = 9.5; + +fn draw_mark(p: &egui::Painter, r: Rect, mark: Mark, color: Color32) { + let at = r.min + vec2(4.75, 4.75); + match mark { + Mark::Dot => { + p.circle_filled(at, 2.0, color); + } + Mark::Letter(l) => { + p.text(pos2(r.min.x + 2.5, r.center().y), Align2::LEFT_CENTER, l, bold(NAME_SIZE), color); + } + Mark::Arrow => { + let at = at + vec2(0.0, 0.5); + p.rect_filled(Rect::from_min_max(at + vec2(-2.5, -1.5), at + vec2(0.0, 1.5)), 0.0, color); + p.add(egui::Shape::convex_polygon(vec![at + vec2(-0.5, -3.5), at + vec2(2.75, 0.0), at + vec2(-0.5, 3.5)], color, Stroke::NONE)); + } + } +} + +fn empty_slot(p: &egui::Painter, r: Rect, hovered: bool, mark: Mark) { + let t = Tokens::current(); + p.rect_filled(r, 2.0, if hovered { shade(t.slot_empty, 0.06) } else { t.slot_empty }); + let color = if matches!(mark, Mark::Letter(_)) { t.slot_mark.lerp_to_gamma(t.text, 0.55) } else { t.slot_mark }; + draw_mark(p, r, mark, color); +} + +/// A used slot, a button a point bigger than the empty cell: the mark, a groove, the name +/// centred, and for sends a level box at the right. It doesn't brighten on hover, which would +/// cost its name contrast. +fn used_slot(p: &egui::Painter, r: Rect, faces: [Color32; 2], mark: Mark, name: &str, color: Color32, level: Option) { + let t = Tokens::current(); + let outer = r.expand(1.0); + let top = raised_key(p, outer, faces, false); + draw_mark(p, r, mark, if matches!(mark, Mark::Dot) { t.slot_mark.lerp_to_gamma(color, 0.35) } else { color }); + if r.width() < COMPACT_SLOT { + return; + } + let groove_x = r.min.x + 10.5; + groove(p, groove_x, r.y_range(), top); + let mut text = r.with_min_x(groove_x + 1.5); + if let Some(k) = level { + let well = level_icon(r); + p.rect_filled(well, 1.0, Color32::BLACK); + let inner = well.shrink(1.0); + p.rect_filled(inner, 0.0, Color32::from_rgb(36, 38, 8)); + p.rect_filled(inner.with_min_y(inner.max.y - inner.height() * k.clamp(0.0, 1.0)), 0.0, Color32::from_rgb(51, 49, 11)); + text.max.x = well.min.x - 1.0; + } + let g = p.layout_no_wrap(name.to_string(), bold(NAME_SIZE), color); + let x = if g.size().x <= text.width() { text.center().x - g.size().x / 2.0 } else { text.min.x + 1.0 }; + p.with_clip_rect(text).galley(pos2(x, text.center().y - g.size().y / 2.0), g, color); +} + +/// A used send's level icon: the small fader at the right of its slot. +fn level_icon(r: Rect) -> Rect { + let outer = r.expand(1.0); + Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0)) +} + +pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { 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 => 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, t.slot_border), - StrokeKind::Inside, - ); - let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name); - if label.is_empty() { - ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); - } else { - ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, label, regular(10.5), t.text); + let info = ins.as_ref().and_then(|i| plugin_info(&i.plugin)); + let name = ins.as_ref().map(|i| info.map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default(); + match &ins { + None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot), + Some(i) => { + let faces = if i.bypass { t.slot_bypassed } else { t.slot_face }; + let color = if info.is_some() { t.text } else { t.text_dim }; + used_slot(ui.painter(), r, faces, Mark::Dot, name, color, None); + } } - if resp.clicked() && ins.is_some() && !app.ui.plugin_windows.contains(&(id, slot)) { - app.ui.plugin_windows.push((id, slot)); + if resp.clicked() && ins.is_some() { + if ui.input(|i| i.modifiers.command) { + let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot})); + } else if !app.ui.plugin_windows.contains(&(id, slot)) { + app.ui.plugin_windows.push((id, slot)); + } } - let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text("Click: open plugin · right-click: change") }; + let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" }; + let tip = format!("{name}: click to open · {cmd}click to bypass · right-click to change"); + let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text(tip) }; let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) }; popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); } @@ -616,67 +716,52 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> { .or_else(|| soundcraft_au_host::plugin_info(id)) } -fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { +pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) { let t = Tokens::current(); let id = track.id; let snd = track.mixer.sends.get(slot).cloned().flatten(); - 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); - let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max); + let compact = r.width() < COMPACT_SLOT; + // As in Pro Tools, only the fader icon opens a send's level; the rest of the slot picks its + // destination. A short track's square has no icon, so all of it is the level. + let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) }; let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y)); let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click()); + let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) }; match &snd { Some(s) => { let name = route_name(app, &s.target); - // Level bar. - let k = soundcraft_model::fader_db_to_pos(s.level_db); - ui.painter().rect_filled( - Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 3.0), pos2(r.min.x + 1.0 + (r.width() - 2.0) * k, r.max.y - 1.0)), - 0.0, - t.counter_text, - ); - ui.painter().with_clip_rect(route_r).text( - pos2(r.min.x + 4.0, r.center().y - 1.0), - Align2::LEFT_CENTER, - name, - regular(9.0), - if s.mute { t.mute } else { t.text }, - ); - let (level_resp, entered) = editable_readout( - ui, - level_r, - ui.id().with(("send-value", id.0, slot)), - &format!("{} dB", db_text(s.level_db)), - &format!("{:.1}", s.level_db), - parse_db, - ); - if let Some(db) = entered { - let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + let level = soundcraft_model::fader_db_to_pos(s.level_db); + let color = if s.mute { t.mute } else { t.text }; + used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level)); + let which = (b'A' + slot as u8) as char; + let shown = app.ui.send_window == Some((id, slot)); + let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag()); + level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level"))); + if shown { + ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } - if level_resp.as_ref().is_some_and(egui::Response::dragged) { + if level_resp.clicked() { + if ui.input(|i| i.modifiers.alt) { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); + } else { + app.ui.send_window = (!shown).then_some((id, slot)); + } + } + if level_resp.dragged() { // Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so // applying it to the already-updated value made trackpad adjustment accelerate. let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); - let db_per_point = if fine { 0.02 } else { 0.15 }; - let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0); + let db = dragged_send_db(s.level_db, dy, fine); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } - if level_resp.as_ref().is_some_and(egui::Response::double_clicked) { - let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0})); - } - if let Some(resp) = level_resp { - let _ = resp.clone().on_hover_text(format!( - "Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options", - (b'A' + slot as u8) as char, - db_text(s.level_db) - )); - resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); - } - } - None => { - ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim); + let _ = level_resp.clone().on_hover_text(format!( + "Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options", + db_text(s.level_db) + )); + level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s)); } + None => empty_slot(ui.painter(), r, ui.rect_contains_pointer(r), mark), } if let Some(send) = &snd { egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { @@ -704,6 +789,82 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec } } +/// The Send window, as in Pro Tools: one send's fader, level, pan, Mute and Pre-fader. Clicking a +/// send's level opens it, moves it to that send, or closes it, and so does Escape. +pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) { + // egui closes menus and drops text focus on Escape before this runs, so whether this Escape + // was theirs comes from the last frame. A dialog or the search on top takes it too. + let busy_id = egui::Id::new("send_window_escape"); + let busy = ctx.data(|d| d.get_temp(busy_id)).unwrap_or(false) || app.dialogs.open.is_some() || app.ui.show_search; + if !busy && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.send_window = None; + } + send_window_contents(app, ctx); + let busy = egui::Popup::is_any_open(ctx) || ctx.text_edit_focused(); + ctx.data_mut(|d| d.insert_temp(busy_id, busy)); +} + +fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { + let Some((id, slot)) = app.ui.send_window else { return }; + let Some((name, send)) = app.engine.session().track(id).and_then(|t| Some((t.name.clone(), t.mixer.sends.get(slot).cloned().flatten()?))) else { + app.ui.send_window = None; + return; + }; + let t = Tokens::current(); + let mut open = true; + egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char)) + .id(egui::Id::new("send_window")) + .open(&mut open) + .auto_sized() + .collapsible(false) + .default_pos(ctx.pointer_latest_pos().map_or(pos2(200.0, 200.0), |p| p + vec2(24.0, -160.0))) + .show(ctx, |ui| { + ui.set_width(150.0); + ui.vertical_centered(|ui| { + ui.label(egui::RichText::new(route_name(app, &send.target)).color(t.counter_text)); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + ui.add_space(((ui.available_width() - 62.0) / 2.0).max(0.0)); + if text_toggle(ui, vec2(36.0, 18.0), "PRE", send.pre_fader, t.accent, "Pre-fader").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": !send.pre_fader})); + } + if text_toggle(ui, vec2(22.0, 18.0), "M", send.mute, t.mute, "Mute send").clicked() { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": !send.mute})); + } + }); + let mut pan = send.pan; + pan_knob(ui, 32.0, &mut pan, "Send pan (drag; double-click centres)"); + if (pan - send.pan).abs() > f32::EPSILON { + let _ = app.engine.execute_merged( + "mix.send_level", + &json!({"track": id.0, "slot": slot, "pan": pan}), + &format!("send-pan:{}:{slot}", id.0), + ); + } + ui.label(egui::RichText::new(pan_text(send.pan)).color(t.counter_text).small()); + let (fr, fresp) = ui.allocate_exact_size(vec2(56.0, 220.0), Sense::hover()); + fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Send level")); + if let Some(db) = fader(ui, fr, send.level_db, ui.id().with("send_fader")) { + let _ = app.engine.execute_merged( + "mix.send_level", + &json!({"track": id.0, "slot": slot, "db": db}), + &format!("send:{}:{slot}", id.0), + ); + } + let (rr, _) = ui.allocate_exact_size(vec2(64.0, 15.0), Sense::hover()); + ui.painter().rect_filled(rr, 1.0, t.counter_bg); + let shown = db_text(send.level_db); + let (_, typed) = editable_readout(ui, rr, ui.id().with("send_level_value"), &shown, &format!("{:.1}", send.level_db), parse_db, 0.0); + if let Some(db) = typed { + let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db})); + } + }); + }); + if !open { + app.ui.send_window = None; + } +} + /// Secondary controls live behind the amount readout, separate from the bus selector like Logic. fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) { ui.set_min_width(230.0); @@ -751,8 +912,16 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, } } +/// A send level after a vertical drag of `dy` points: along the fader's taper, like the level bar, +/// so it runs smoothly down to −∞ and back. About 0.15 dB a point near 0 dB (0.02 when fine). +fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 { + let per_point = if fine { 0.0004 } else { 0.003 }; + soundcraft_model::fader_pos_to_db(soundcraft_model::fader_db_to_pos(db) - dy * per_point) +} + /// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away -/// commits a valid value; Escape cancels. The interaction response remains available for dragging. +/// commits a valid value; Escape cancels, and so does the field losing focus while hidden. +/// Alt-click returns `reset`. The interaction response remains available for dragging. fn editable_readout( ui: &mut Ui, r: Rect, @@ -760,6 +929,7 @@ fn editable_readout( display: &str, edit_initial: &str, parse: fn(&str) -> Option, + reset: f32, ) -> (Option, Option) { let t = Tokens::current(); let active_id = id.with("active"); @@ -767,9 +937,14 @@ fn editable_readout( let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false); let mut started = false; let mut response = None; + let mut value = None; if !active { let resp = ui.interact(r, id, Sense::click_and_drag()); - if resp.clicked() && !resp.double_clicked() { + let alt = ui.input(|i| i.modifiers.alt); + if resp.clicked() && alt { + value = Some(reset); + } + if resp.clicked() && !alt && !resp.double_clicked() { active = true; started = true; ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string())); @@ -778,7 +953,6 @@ fn editable_readout( } response = Some(resp); } - let mut value = None; if active { let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string()); let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r)); @@ -796,13 +970,15 @@ fn editable_readout( let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape))); if escape { active = false; - } else if enter || edit_resp.lost_focus() { + } else if (enter && edit_resp.has_focus()) || edit_resp.lost_focus() { if let Some(parsed) = parse(&text) { value = Some(parsed); active = false; } else if edit_resp.lost_focus() { active = false; } + } else if !started && !edit_resp.has_focus() { + active = false; } ui.data_mut(|d| d.insert_temp(text_id, text)); } @@ -837,7 +1013,7 @@ fn send_level_scale(ui: &mut Ui, db: f32) { let t = Tokens::current(); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover()); let line_y = r.min.y + 4.0; - ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border)); + ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.border)); for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() { let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark); ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim)); @@ -936,7 +1112,8 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { #[cfg(test)] mod tests { - use super::{parse_db, parse_pan}; + use super::{dragged_send_db, editable_readout, parse_db, parse_pan}; + use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; #[test] fn typed_db_accepts_console_notation_and_clamps() { @@ -956,4 +1133,47 @@ mod tests { assert_eq!(parse_pan("0.5"), Some(0.5)); assert_eq!(parse_pan("center"), None); } + + #[test] + fn send_drags_follow_the_fader_taper_down_to_minus_infinity() { + assert!((dragged_send_db(0.0, -10.0, false) - 1.44).abs() < 0.01); + assert!((dragged_send_db(0.0, -10.0, true) - 0.192).abs() < 0.01); + assert_eq!(dragged_send_db(0.0, 400.0, false), -144.0); + let up = dragged_send_db(-144.0, -1.0, false); + assert!(up > -144.0 && up < -100.0, "one point up leaves −∞ gently, got {up}"); + assert_eq!(dragged_send_db(12.0, -50.0, false), 12.0); + } + + #[test] + fn readouts_reset_on_alt_click_and_drop_a_field_that_lost_focus() { + let ctx = egui::Context::default(); + let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(40.0, 14.0)); + let click = |mods: Modifiers| { + let press = |pressed| Event::PointerButton { pos: r.center(), button: PointerButton::Primary, pressed, modifiers: mods }; + vec![Event::ModifiersChanged(mods), Event::PointerMoved(r.center()), press(true), press(false)] + }; + let mut time = 0.0; + let mut frame = |events: Vec, shown: bool| { + time += 0.5; + let mut out = (false, None); + let mut full = ctx.run_ui(egui::RawInput { events, time: Some(time), ..Default::default() }, |ui| { + if shown { + let (resp, value) = editable_readout(ui, r, egui::Id::new("readout"), "-3.0", "-3.0", parse_db, 0.0); + out = (resp.is_some(), value); + } + }); + full.textures_delta.clear(); + out + }; + assert_eq!(frame(vec![], true), (true, None)); + assert_eq!(frame(click(Modifiers::ALT), true), (true, Some(0.0)), "Alt-click resets"); + frame(vec![Event::ModifiersChanged(Modifiers::NONE)], true); + frame(click(Modifiers::NONE), true); + assert_eq!(frame(vec![], true), (false, None), "a click opens the text field"); + for _ in 0..3 { + frame(vec![], false); + } + frame(vec![], true); + assert_eq!(frame(vec![], true), (true, None), "hidden, the field lost focus, so the readout is back"); + } } diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index d2bee38..c4bb0d3 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -5,7 +5,28 @@ use crate::theme::{Tokens, bold, mono, regular, rgb}; use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{ClipId, SourceId}; -use soundcraft_time::format_position; + +/// Drag a Tracks list row to reorder the tracks: a line marks the gap it will land in. +fn drag_track(app: &mut SoundApp, ui: &Ui, row: Rect, id: u64, dropped: bool) { + let s = app.engine.session(); + let (Some(from), Some(p)) = (s.track_index(soundcraft_model::TrackId(id)), ui.ctx().pointer_latest_pos()) else { return }; + let spacing = ui.spacing().item_spacing.y; + let pitch = row.height() + spacing; + let gap = (from as f32 + ((p.y - row.center().y) / pitch).floor() + 1.0).clamp(0.0, s.tracks.len() as f32); + if dropped { + let to = gap as usize; + let to = if to > from { to - 1 } else { to }; + if to != from { + let _ = app.run("track.move", json!({"track": id, "to": to})); + } + } else { + let y = row.min.y + (gap - from as f32) * pitch - spacing / 2.0; + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + ui.ctx() + .layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("track_list_reorder"))) + .line_segment([pos2(row.min.x, y), pos2(row.max.x, y)], Stroke::new(2.0, Tokens::current().accent)); + } +} fn panel_header(ui: &mut Ui, title: &str) { let t = Tokens::current(); @@ -15,17 +36,36 @@ fn panel_header(ui: &mut Ui, title: &str) { ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border)); } +/// The strip down the Tracks panel's right edge: the lists' scroll bars, and a clear line between +/// the panel and the window beside it. +const GUTTER_W: f32 = 10.0; + pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); + let full = ui.max_rect(); + let gutter = full.with_min_x(full.max.x - GUTTER_W); + ui.painter().rect_filled(gutter, 0.0, t.panel_bg.lerp_to_gamma(t.text_dim, 0.15)); + ui.painter().vline(gutter.min.x + 0.5, full.y_range(), Stroke::new(1.0, t.border)); + ui.painter().vline(gutter.max.x - 0.5, full.y_range(), Stroke::new(1.0, t.border)); + let ui = &mut ui.new_child(egui::UiBuilder::new().max_rect(full.with_max_x(gutter.min.x))); let total = ui.available_height(); + let header = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0)); panel_header(ui, "TRACKS"); + let plus = Rect::from_center_size(pos2(header.max.x - 13.0, header.center().y), vec2(18.0, 18.0)); + let tip = format!("New Tracks… ({})", crate::shortcuts::shortcut_label("Cmd+Shift+N", ui.ctx().os().is_mac())); + if crate::widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(plus)), plus.size(), "plus", false, &tip).clicked() { + let _ = app.dialogs.open_for_command(&app.engine, "track.new"); + } let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = { let s = app.engine.session(); s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect() }; - egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).show(ui, |ui| { + let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden; + let lists = + egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).scroll_bar_visibility(hidden); + let out = lists.show(ui, |ui| { for (id, name, shown, color, sel) in tracks { - let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click()); + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag()); if sel { ui.painter().rect_filled(r, 0.0, t.row_selected); } @@ -40,13 +80,17 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { if resp.clicked() { crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true); } + if resp.dragged() || resp.drag_stopped() { + drag_track(app, ui, r, id, resp.drag_stopped()); + } } }); + gutter_scroll(ui, gutter, &out); ui.add_space(4.0); panel_header(ui, "GROUPS"); let groups: Vec<(u64, char, String, bool, [u8; 3])> = app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect(); - egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).show(ui, |ui| { + let out = egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| { let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover()); ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "", regular(11.5).clone(), t.text); let all = ui.interact(r, ui.id().with("group_all"), Sense::click()); @@ -96,6 +140,41 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) { }); } }); + gutter_scroll(ui, gutter, &out); +} + +/// A list's scroll thumb in the gutter beside it, when the list is longer than its space: drag it, +/// or click above or below it to page. +fn gutter_scroll(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOutput) { + let t = Tokens::current(); + let (view, content) = (out.inner_rect.height(), out.content_size.y); + if content <= view + 0.5 { + return; + } + let bar = Rect::from_x_y_ranges(gutter.x_range(), out.inner_rect.y_range()); + let (max, h) = (content - view, (bar.height() * view / content).max(20.0)); + let mut state = out.state; + let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * state.offset.y / max), vec2(bar.width() - 4.0, h)); + let resp = ui.interact(bar, out.id.with("gutter"), Sense::click_and_drag()); + ui.painter().rect_filled(thumb, 3.0, if resp.hovered() || resp.dragged() { t.button_hi.lerp_to_gamma(Color32::WHITE, 0.1) } else { t.button_hi }); + let to = if resp.dragged() { + Some(state.offset.y + resp.drag_delta().y * max / (bar.height() - h).max(1.0)) + } else if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked()) { + let page = if p.y < thumb.min.y { + -view + } else if p.y > thumb.max.y { + view + } else { + 0.0 + }; + Some(state.offset.y + page) + } else { + None + }; + if let Some(y) = to { + state.offset.y = y.clamp(0.0, max); + state.store(ui.ctx(), out.id); + } } pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) { @@ -175,6 +254,7 @@ pub fn floating(app: &mut SoundApp, ctx: &egui::Context) { transport_window(app, ctx); big_counter(app, ctx); plugin_windows(app, ctx); + crate::mix_window::send_window(app, ctx); about(app, ctx); session_info(app, ctx); } @@ -198,15 +278,19 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { ("end", "transport.go_to_end"), ("record", "transport.record"), ] { - if crate::widgets::icon_button( + let resp = crate::widgets::icon_button( ui, vec2(34.0, 26.0), icon, (cmd == "transport.play" && app.is_playing()) || (cmd == "transport.record" && app.engine.transport.recording), cmd, - ) - .clicked() + ); + if cmd == "transport.record" + && let Some(why) = app.record_hint() { + egui::Tooltip::for_widget(&resp).show(|ui| ui.label(why)); + } + if resp.clicked() { let _ = app.run(cmd, json!({})); } } @@ -218,7 +302,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) { } }); let s = app.engine.session(); - let fmt = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let fmt = |x: i64| s.format_position(x, s.edit.main_counter); if flags.contains("view.transport.counters") || expanded || flags.is_empty() { ui.label(egui::RichText::new(fmt(app.position())).font(mono(20.0)).color(t.counter_text)); } @@ -271,7 +355,7 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) { } egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| { 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 txt = s.format_position(app.position(), s.edit.main_counter); let r = ui.available_rect_before_wrap(); let t = Tokens::current(); ui.painter().rect_filled(r, 4.0, t.big_counter_bg); @@ -291,7 +375,7 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) { }); let rows: Vec<(u32, String, String, String, String)> = { let s = app.engine.session(); - let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let f = |x: i64| s.format_position(x, s.edit.main_counter); s.markers .iter() .map(|m| { @@ -563,10 +647,16 @@ fn preset_menu( if let Ok(text) = std::fs::read_to_string(dir.join(format!("{n}.json"))) && let Ok(v) = serde_json::from_str::(&text) { - let _ = app.run( - "mix.insert_params", - json!({"track": tid.0, "slot": slot, "params": v.get("params").cloned().unwrap_or_default(), "preset": n}), - ); + // Old presets may carry parameters the plugin no longer exposes; drop them so recall succeeds. + let params: serde_json::Map = v + .get("params") + .and_then(serde_json::Value::as_object) + .into_iter() + .flatten() + .filter(|(k, val)| info.params.iter().any(|pi| pi.id == k.as_str()) && val.as_f64().is_some_and(f64::is_finite)) + .map(|(k, val)| (k.clone(), val.clone())) + .collect(); + let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": params, "preset": n})); } ui.close(); } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..f5414c4 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -86,6 +86,8 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run(c.0, json!({})); } } + // Tab moves the insertion point here, so egui must not also hand focus to the first widget: that would block every shortcut. + ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None)); } fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> { @@ -120,6 +122,16 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("markers.add", json!({})); true } + // Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +. + Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => { + let step = if key == Key::Minus { -1 } else { 1 }; + let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step})); + true + } + Key::F12 if plain => { + let _ = app.run("transport.record", json!({})); + true + } Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => { let mode = match key { Key::F1 => "shuffle", @@ -147,11 +159,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { let _ = app.run("edit.nudge", json!({"direction": dir})); true } - Key::Tab if plain || m.alt => { + // Alt+Tab goes back; off macOS the desktop takes Alt+Tab, so Ctrl+Tab does. Cmd+Alt+Tab is Tab to Transient. + Key::Tab if plain || (m.alt && !m.command) || (!mac && m.ctrl && !m.alt) => { // Tab to next clip boundary (or transient when enabled). let s = app.engine.session(); let at = s.edit.selection.start; - let back = m.alt; + let back = !plain; if s.edit.tab_to_transient && !back && let Some(t) = s.edit.selected_tracks.first().copied() @@ -175,6 +188,20 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } true } + // The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read. + Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => { + let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 }; + let _ = app.run("view.waveform_zoom", json!({"factor": factor})); + true + } + Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => { + let _ = app.run("clip.rating", json!({"rating": digit(key)})); + true + } + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::CTRL, m, mac) => { + let _ = app.run("view.zoom_preset", json!({"preset": digit(key)})); + true + } Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => { let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" }; let _ = app.run(id, json!({})); @@ -187,7 +214,15 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { true } // Commands Keyboard Focus: single-key editing (Edit window only). - k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true, + k if !m.command + && !m.alt + && !m.ctrl + && app.ui.window == MainWindow::Edit + && app.engine.session().edit.keyboard_focus == "commands" + && commands_focus(app, k, m.shift) => + { + true + } Key::R | Key::T if plain => { // Zoom out / in on the timeline (Commands Focus style). let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" }; @@ -199,18 +234,36 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } /// Single-key commands. Returns true when `key` was handled. -fn commands_focus(app: &mut SoundApp, key: Key) -> bool { +fn commands_focus(app: &mut SoundApp, key: Key, shift: bool) -> bool { let id = match key { + Key::Z if shift => "edit.redo", + Key::P if shift => "edit.extend_selection_up", + Key::Semicolon | Key::Colon if shift => "edit.extend_selection_down", + _ if shift => return false, Key::A => "edit.trim_start_to_insertion", Key::S => "edit.trim_end_to_insertion", Key::D => "edit.fade_to_start", Key::G => "edit.fade_to_end", Key::F => "edit.fades_create", + Key::Comma | Key::Period | Key::M | Key::Slash => { + let direction = if matches!(key, Key::Period | Key::Slash) { 1 } else { -1 }; + let _ = app.run("edit.nudge", json!({"direction": direction, "next": matches!(key, Key::M | Key::Slash)})); + return true; + } + Key::Num0 => "edit.timeline_to_selection", + Key::O => "edit.selection_to_timeline", + Key::OpenBracket => "transport.play_selection", + Key::CloseBracket => "edit.play_timeline", Key::B => "edit.separate", Key::X => "edit.cut", Key::C => "edit.copy", Key::V => "edit.paste", Key::Z => "edit.undo", + Key::N => "options.insertion_follows_playback", + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 => { + let _ = app.run("view.zoom_preset", json!({"preset": digit(key)})); + return true; + } Key::P => { move_selection_track(app, -1); return true; @@ -255,6 +308,10 @@ fn tab_clip(app: &mut SoundApp, back: bool) { } } +fn digit(key: Key) -> u8 { + key.name().parse().unwrap_or(0) +} + /// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd /// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing. pub fn shortcut_label(s: &str, mac: bool) -> String { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c667e6f..8b91c82 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -6,11 +6,64 @@ use std::sync::atomic::{AtomicBool, Ordering}; /// Whether the Light palette is active. Dark is the default (the incumbent is dark-only). static LIGHT_THEME: AtomicBool = AtomicBool::new(false); -/// Unit tests that change the process-wide palette must own it until their assertions finish. +/// Unit tests that change or read the process-wide palette must own it until their assertions +/// finish. The lock is re-entrant per thread, so a test may hold it and still build apps +/// (`SoundApp::new` takes it too) without deadlocking itself. #[cfg(test)] -pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> { - static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(()); - PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +pub(crate) fn lock_test_palette() -> TestPaletteGuard { + TestPaletteGuard::acquire() +} + +/// RAII handle for the re-entrant, per-thread test palette lock; see [`lock_test_palette`]. +#[cfg(test)] +pub(crate) struct TestPaletteGuard; + +#[cfg(test)] +impl TestPaletteGuard { + fn acquire() -> Self { + palette_lock::acquire(); + Self + } +} + +#[cfg(test)] +impl Drop for TestPaletteGuard { + fn drop(&mut self) { + palette_lock::release(); + } +} + +#[cfg(test)] +mod palette_lock { + use std::cell::{Cell, RefCell}; + use std::sync::{Mutex, MutexGuard, PoisonError}; + + static PALETTE: Mutex<()> = Mutex::new(()); + + thread_local! { + /// How many guards this thread holds; the OS lock is taken on 0→1 and released on 1→0. + static DEPTH: Cell = const { Cell::new(0) }; + static GUARD: RefCell>> = const { RefCell::new(None) }; + } + + pub(super) fn acquire() { + DEPTH.with(|depth| { + if depth.get() == 0 { + GUARD.with(|guard| *guard.borrow_mut() = Some(PALETTE.lock().unwrap_or_else(PoisonError::into_inner))); + } + depth.set(depth.get() + 1); + }); + } + + pub(super) fn release() { + DEPTH.with(|depth| { + let left = depth.get().saturating_sub(1); + depth.set(left); + if left == 0 { + GUARD.with(|guard| *guard.borrow_mut() = None); + } + }); + } } /// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in. @@ -57,6 +110,8 @@ pub struct Tokens { pub toolbar_group: Color32, pub border: Color32, pub border_light: Color32, + /// The lit top/left edge of raised panels: track headers and Mix strips. + pub edge_light: Color32, pub text: Color32, pub text_dim: Color32, pub text_dark: Color32, @@ -146,16 +201,15 @@ pub struct Tokens { 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, + /// Plugin slots, as Pro Tools draws them: the well they sit in, an empty slot, a used slot's + /// face (top, bottom), a bypassed one's, and the dots, letters and arrows on them. + pub slot_well: Color32, + pub slot_empty: Color32, + pub slot_face: [Color32; 2], + pub slot_bypassed: [Color32; 2], + pub slot_mark: Color32, } impl Tokens { @@ -167,6 +221,7 @@ impl Tokens { toolbar_group: Color32::from_rgb(24, 24, 25), border: Color32::from_rgb(14, 14, 14), border_light: Color32::from_rgb(64, 64, 66), + edge_light: Color32::from_rgba_premultiplied(20, 20, 20, 20), text: Color32::from_rgb(214, 214, 214), text_dim: Color32::from_rgb(150, 150, 152), text_dark: Color32::from_rgb(16, 16, 16), @@ -233,51 +288,53 @@ impl Tokens { 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_ring: Color32::from_rgb(150, 150, 154), 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), + slot_well: Color32::from_rgb(43, 43, 43), + slot_empty: Color32::from_rgb(39, 39, 39), + slot_face: [Color32::from_rgb(92, 92, 92), Color32::from_rgb(75, 75, 75)], + slot_bypassed: [Color32::from_rgb(66, 96, 146), Color32::from_rgb(50, 76, 120)], + slot_mark: Color32::from_rgb(159, 159, 159), }; 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), + window_bg: Color32::from_rgb(184, 186, 190), + panel_bg: Color32::from_rgb(204, 206, 210), + panel_bg2: Color32::from_rgb(216, 218, 222), + toolbar_bg: Color32::from_rgb(194, 196, 200), + toolbar_group: Color32::from_rgb(208, 210, 214), + border: Color32::from_rgb(100, 103, 109), + border_light: Color32::from_rgb(150, 153, 159), + edge_light: Color32::from_rgba_premultiplied(150, 150, 150, 150), + text: Color32::from_rgb(24, 25, 28), + text_dim: Color32::from_rgb(68, 70, 76), + text_dark: Color32::from_rgb(16, 17, 20), + header_text: Color32::from_rgb(30, 32, 36), + counter_bg: Color32::from_rgb(224, 226, 228), + counter_text: Color32::from_rgb(18, 108, 40), + counter_label: Color32::from_rgb(54, 57, 63), 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), + mode_off_text: Color32::from_rgb(38, 106, 52), + button: Color32::from_rgb(214, 216, 220), + button_hi: Color32::from_rgb(200, 203, 208), + button_border: Color32::from_rgb(108, 111, 117), + name_field: Color32::from_rgb(236, 237, 239), + name_field_sel: Color32::from_rgb(224, 232, 244), + ruler_bg: Color32::from_rgb(198, 200, 204), + ruler_label_bg: Color32::from_rgb(198, 200, 204), + ruler_text: Color32::from_rgb(48, 50, 56), + ruler_tick: Color32::from_rgb(108, 111, 118), + tempo_ruler: Color32::from_rgb(92, 156, 112), + meter_ruler: Color32::from_rgb(80, 144, 172), + marker_ruler: Color32::from_rgb(186, 189, 194), + playlist_bg: Color32::from_rgb(210, 212, 216), + playlist_alt: Color32::from_rgb(202, 204, 208), + grid_line: Color32::from_rgb(188, 191, 196), + bar_line: Color32::from_rgb(162, 166, 172), selection: Color32::from_rgba_premultiplied(100, 145, 205, 90), playhead: Color32::from_rgb(210, 48, 42), insertion: Color32::from_rgb(35, 36, 40), @@ -292,37 +349,38 @@ impl Tokens { 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), + fader_track: Color32::from_rgb(120, 123, 129), + strip_bg: Color32::from_rgb(198, 200, 204), + strip_section: Color32::from_rgb(210, 212, 216), + slot_bg: Color32::from_rgb(194, 196, 201), + automation_line: Color32::from_rgb(30, 32, 38), + menu_bar: Color32::from_rgb(204, 206, 210), + group_border: Color32::from_rgb(118, 121, 127), + mode_off: Color32::from_rgb(202, 212, 204), + counter_off: Color32::from_rgb(192, 212, 198), + counter_border: Color32::from_rgb(118, 121, 127), + counter_rule: Color32::from_rgb(188, 191, 196), + tool_bracket_off: Color32::from_rgb(172, 175, 181), + row_selected: Color32::from_rgb(174, 196, 226), + visible_dot: Color32::from_rgb(86, 89, 96), + big_counter_bg: Color32::from_rgb(224, 226, 228), + header_selected: Color32::from_rgb(184, 200, 224), + strip_selected: Color32::from_rgb(184, 200, 224), + marker_text: Color32::from_rgb(24, 25, 28), + playlist_lane: Color32::from_rgb(200, 202, 206), + universe_bg: Color32::from_rgb(198, 200, 204), 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), + meter_bg: Color32::from_rgb(52, 54, 58), + meter_clip_off: Color32::from_rgb(92, 54, 54), + knob_bg: Color32::from_rgb(232, 233, 236), + knob_ring: Color32::from_rgb(116, 120, 128), + knob_pointer: Color32::from_rgb(30, 32, 38), + section_label: Color32::from_rgb(56, 58, 64), + slot_well: Color32::from_rgb(212, 214, 218), + slot_empty: Color32::from_rgb(198, 200, 205), + slot_face: [Color32::from_rgb(244, 245, 247), Color32::from_rgb(214, 216, 220)], + slot_bypassed: [Color32::from_rgb(190, 210, 240), Color32::from_rgb(160, 186, 228)], + slot_mark: Color32::from_rgb(92, 95, 102), }; pub fn current() -> Tokens { @@ -344,6 +402,20 @@ pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool { } } +/// A track's header and lane backgrounds in the Edit window, tinted with its colour by `tint` (0..1). +pub fn track_backgrounds(color: [u8; 3], selected: bool, tint: f32) -> (Color32, Color32) { + let t = Tokens::current(); + let k = tint.clamp(0.0, 1.0); + if k <= 0.0 { + return if selected { (t.header_selected, t.playlist_alt) } else { (t.panel_bg2, t.playlist_bg) }; + } + let c = rgb(color); + let header = t.panel_bg2.lerp_to_gamma(c, k * 0.55); + let header = if selected { crate::widgets::shade(header, 0.1) } else { header }; + let lane = if selected { t.playlist_alt } else { t.playlist_bg }.lerp_to_gamma(c, k * 0.2); + (header, lane) +} + /// Track colours tinted for clip bodies. pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) { let [r, g, b] = rgb; @@ -415,6 +487,26 @@ mod tests { use crate::{Services, SoundApp, UiState}; use serde_json::json; + #[test] + fn test_palette_lock_is_reentrant_and_excludes_other_threads() { + // Regression for the parallel-run flakes: the process-wide palette must be owned for a + // whole test, re-entrant on the owning thread (so `SoundApp::new` can take it too), and + // exclusive against other threads (so a frame cannot flip it mid-test). + let outer = lock_test_palette(); + let inner = lock_test_palette(); + let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _held = lock_test_palette(); + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "another thread must wait for the palette lock"); + drop(inner); + drop(outer); + drop(app); + assert!(rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "the palette lock must be released when every holder drops it"); + } + #[test] fn dark_is_the_default() { assert_eq!(ThemeMode::default(), ThemeMode::Dark); @@ -424,6 +516,12 @@ mod tests { assert_eq!(old.theme, ThemeMode::Dark); } + #[test] + fn prefs_saved_before_track_tint_get_the_default() { + let ui: UiState = serde_json::from_value(json!({"theme": "light"})).unwrap(); + assert!((ui.track_tint - 0.5).abs() < f32::EPSILON); + } + #[test] fn modes_round_trip_through_prefs() { for mode in ThemeMode::ALL { diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index e47df13..4b53e7d 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,12 +1,12 @@ //! The Edit window toolbar. use crate::theme::{Tokens, bold, mono, regular}; -use crate::widgets::icon_button; +use crate::widgets::{icon_button, key, key_fill}; use crate::{SoundApp, icons}; use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; use serde_json::json; use soundcraft_model::{EditMode, Tool}; -use soundcraft_time::{TimeFormat, format_length, format_position}; +use soundcraft_time::{TimeFormat, format_length}; pub const HEIGHT: f32 = 78.0; @@ -23,38 +23,45 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { let full = ui.max_rect(); ui.painter().rect_filled(full, 0.0, t.toolbar_bg); ui.add_space(4.0); - ui.horizontal(|ui| { - ui.add_space(8.0); - ui.spacing_mut().item_spacing.x = 8.0; - edit_modes(app, ui); - zoom_group(app, ui); - tools(app, ui); - counters(app, ui); - grid_nudge(app, ui); - transport(app, ui); - tempo_meter(app, ui); - output_meter(app, ui); - }); + egui::ScrollArea::horizontal() + .id_salt("edit_toolbar_scroll") + .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.add_space(8.0); + ui.spacing_mut().item_spacing.x = 8.0; + edit_modes(app, ui); + zoom_group(app, ui); + tools(app, ui); + counters(app, ui); + grid_nudge(app, ui); + transport(app, ui); + tempo_meter(app, ui); + output_meter(app, ui); + }); + }); } fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let cur = app.engine.session().edit.edit_mode; - group(ui, 112.0, 52.0, |ui, r| { + let modes = [ + (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), + (EditMode::Spot, 1, 0, "SPOT", "F3"), + (EditMode::Slip, 0, 1, "SLIP", "F2"), + (EditMode::Grid, 1, 1, "GRID", "F4"), + ]; + let widest = + modes.iter().map(|m| m.3).chain(["REL GRID"]).map(|l| ui.painter().layout_no_wrap(l.into(), bold(10.5), t.text).size().x).fold(0.0, f32::max); + group(ui, 2.0 * widest + 44.0, 52.0, |ui, r| { let inner = r.shrink(6.0); let cw = inner.width() / 2.0; let ch = inner.height() / 2.0; - let modes = [ - (EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"), - (EditMode::Spot, 1, 0, "SPOT", "F3"), - (EditMode::Slip, 0, 1, "SLIP", "F2"), - (EditMode::Grid, 1, 1, "GRID", "F4"), - ]; - for (m, cx, cy, label, key) in modes { + for (m, cx, cy, label, fkey) in modes { 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 { t.mode_off }); + key(ui.painter(), cell, &resp, 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() { @@ -67,7 +74,7 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) { }; let _ = app.run("edit.mode", json!({"mode": id})); } - resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase())); + resp.on_hover_text(format!("{} mode ({fkey})", label.to_lowercase())); } }); } @@ -173,7 +180,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String { let s = app.engine.session(); - format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) + s.format_position(at, f) } fn counters(app: &mut SoundApp, ui: &mut Ui) { @@ -228,9 +235,10 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) { 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, 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 { t.counter_off }); + let resp = ui.interact(gl, ui.id().with("grid_lines"), Sense::click()); + key(ui.painter(), gl, &resp, 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() { + if resp.on_hover_text("Show grid lines").clicked() { let _ = app.run("view.grid_lines", json!({})); } let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0)); @@ -275,7 +283,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { let playing = app.is_playing(); let recording = app.engine.transport.recording; let looped = app.engine.session().edit.loop_playback; - group(ui, 176.0, 64.0, |ui, _| { + group(ui, 176.0, 64.0, |ui, g| { ui.vertical(|ui| { ui.spacing_mut().item_spacing = vec2(3.0, 4.0); ui.horizontal(|ui| { @@ -284,51 +292,27 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) { let _ = app.run("options.loop_playback", json!({})); } let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(3), - if resp.hovered() { t.button_hi } else { t.button }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, key_fill(&resp)); icons::draw(ui.painter(), r.shrink(5.0), "stop", if playing { t.text } else { t.accent }); if resp.on_hover_text("Stop (Space)").clicked() { let _ = app.run("transport.stop", json!({})); } let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(3), - if playing { - Color32::from_rgb(40, 90, 50) - } else if resp.hovered() { - t.button_hi - } else { - t.button - }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, if playing { Color32::from_rgb(40, 90, 50) } else { key_fill(&resp) }); icons::draw(ui.painter(), r.shrink(5.0), "play", if playing { Color32::from_rgb(120, 255, 140) } else { t.counter_text }); if resp.on_hover_text("Play (Space)").clicked() { let _ = app.run("transport.play", json!({})); } let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(3), - if recording { - Color32::from_rgb(110, 30, 30) - } else if resp.hovered() { - t.button_hi - } else { - t.button - }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) }); icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec); - if resp.on_hover_text("Record (Cmd+Space)").clicked() { + let resp = resp.on_hover_text("Record (Cmd+Space)"); + if let Some(why) = app.record_hint() { + // Below the whole group, so the hint covers none of its buttons. + let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y)); + egui::Tooltip::always_open(ui.ctx().clone(), resp.layer_id, resp.id, below).show(|ui| ui.label(why)); + } + if resp.clicked() { let _ = app.run("transport.record", json!({})); } }); @@ -360,9 +344,10 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { 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, 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 { t.counter_off }); + let resp = ui.interact(co, ui.id().with("countoff"), Sense::click()); + key(ui.painter(), co, &resp, 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() { + if resp.clicked() { let _ = app.run("options.countoff", json!({})); } ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, format!("{bars} bars"), mono(11.0), t.counter_text); @@ -370,7 +355,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 32.0), Align2::RIGHT_CENTER, meter, mono(11.0), t.counter_text); ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, "Tempo", regular(11.0), t.counter_label); icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 82.0, r.min.y + 48.0), vec2(13.0, 13.0)), "note", t.counter_text); - ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 48.0), Align2::RIGHT_CENTER, format!("{bpm:.4}"), mono(11.0), t.counter_text); + tempo_value(app, ui, r.max.x - 8.0, r.min.y + 48.0, bpm); let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0)); icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim }); if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() { @@ -378,6 +363,36 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) { } } +/// The tempo in effect at the playhead, right-aligned at `right`: drag it (Shift for fine steps), +/// or click and type. +fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) { + let t = Tokens::current(); + let w = ui.painter().layout_no_wrap(format!("{bpm:.4}"), mono(11.0), t.counter_text).size().x; + let rect = Rect::from_min_max(pos2(right - w - 4.0, y - 8.0), pos2(right + 4.0, y + 8.0)); + let mut c = ui.new_child(egui::UiBuilder::new().max_rect(rect)); + c.style_mut().override_font_id = Some(mono(11.0)); + let v = c.visuals_mut(); + v.override_text_color = Some(t.counter_text); + for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active] { + w.weak_bg_fill = Color32::TRANSPARENT; + w.bg_stroke = Stroke::NONE; + } + v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.counter_border); + let mut value = bpm; + let resp = c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4)); + if resp.changed() { + let s = app.engine.session(); + let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate); + let event = s.tempo.tempos().iter().rev().find(|e| e.tick <= tick).map_or(0, |e| e.tick); + let at = s.tempo.tick_to_samples(event, s.sample_rate); + let _ = app.engine.execute_merged("event.tempo", &json!({"bpm": value, "at": at}), "toolbar_tempo"); + } + if resp.drag_stopped() || resp.lost_focus() { + app.engine.end_merge(); + } + resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it"); +} + fn output_meter(app: &mut SoundApp, ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover()); let m = app.main_meter; diff --git a/crates/ui-egui/src/video_window.rs b/crates/ui-egui/src/video_window.rs index d282b95..82648cc 100644 --- a/crates/ui-egui/src/video_window.rs +++ b/crates/ui-egui/src/video_window.rs @@ -7,7 +7,7 @@ use crate::video_track::{MovieKey, movie_secs, online, picture_at}; use egui::{Align2, Color32, Rect, Sense, pos2, vec2}; use serde_json::json; use soundcraft_model::{ClipContent, TrackKind}; -use soundcraft_time::{TimeFormat, format_position}; +use soundcraft_time::TimeFormat; pub fn show(app: &mut SoundApp, ctx: &egui::Context) { if app.ui.show_video { @@ -81,7 +81,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170)); } if burn { - let tc = format_position(pos, TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start); + let tc = s.format_position(pos, TimeFormat::Timecode); let size = (h / 12.0).clamp(10.0, 36.0); let galley = painter.layout_no_wrap(tc, mono(size), Color32::WHITE); let at = pos2(img.center().x - galley.size().x / 2.0, img.max.y - galley.size().y - size * 0.6); @@ -95,6 +95,12 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) { } } +/// Timeline position of strip coordinate `x` (the strip spans `left`..`left + width` and samples +/// 0..`end`), clamped to the start of the clip being drawn. +fn strip_position(x: f32, left: f32, width: f32, end: i64, clip_start: i64) -> i64 { + (((x - left) / width * end as f32) as i64).max(clip_start) +} + fn first_movie_aspect(s: &soundcraft_model::Session) -> Option { s.videos.first().map(|v| v.aspect() as f32) } @@ -131,8 +137,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) { let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32; let mut x = cr.min.x; while x < cr.max.x { - let at = c.start + ((x - r.min.x) / r.width() * end as f32) as i64 - c.start.min(0); - let secs = movie_secs(&s, c.start, offset, at.max(c.start)).max(0.0); + let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0); if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) { let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height())); let vis = tr.intersect(cr); @@ -164,3 +169,33 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) { app.ui.show_video_universe = false; } } + +#[cfg(test)] +mod tests { + use super::strip_position; + + // A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32. + const LEFT: f32 = 100.0; + const WIDTH: f32 = 1024.0; + const END: i64 = 1_024_000; + + #[test] + fn strip_position_is_the_timeline_position_for_clips_after_zero() { + // A clip at 48_000 has its left edge at x = 148. + assert_eq!(strip_position(148.0, LEFT, WIDTH, END, 48_000), 48_000); + assert_eq!(strip_position(248.0, LEFT, WIDTH, END, 48_000), 148_000); + let s = soundcraft_model::Session::default(); + let at = strip_position(148.0, LEFT, WIDTH, END, 48_000); + assert_eq!(crate::video_track::movie_secs(&s, 48_000, 0, at), 0.0, "the clip's left edge shows the movie's first picture"); + } + + #[test] + fn strip_position_is_unchanged_for_clips_at_or_before_zero() { + assert_eq!(strip_position(100.0, LEFT, WIDTH, END, 0), 0); + assert_eq!(strip_position(200.0, LEFT, WIDTH, END, 0), 100_000); + // A clip at -48_000 starts left of the strip; positions before it clamp to its start. + assert_eq!(strip_position(0.0, LEFT, WIDTH, END, -48_000), -48_000); + assert_eq!(strip_position(52.0, LEFT, WIDTH, END, -48_000), -48_000); + assert_eq!(strip_position(200.0, LEFT, WIDTH, END, -48_000), 100_000); + } +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 1eca07c..ad0ec6a 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -4,7 +4,88 @@ use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle}; use crate::icons; use crate::theme::{Tokens, bold, regular}; -use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use egui::{Align2, Color32, CornerRadius, Mesh, Painter, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +/// `c` moved towards white (`k > 0`) or black (`k < 0`). +pub fn shade(c: Color32, k: f32) -> Color32 { + if k >= 0.0 { c.lerp_to_gamma(Color32::WHITE, k) } else { c.lerp_to_gamma(Color32::BLACK, -k) } +} + +fn quad(mesh: &mut Mesh, r: Rect, [tl, tr, bl, br]: [Color32; 4]) { + let i = mesh.vertices.len() as u32; + for (p, c) in [(r.left_top(), tl), (r.right_top(), tr), (r.left_bottom(), bl), (r.right_bottom(), br)] { + mesh.colored_vertex(p, c); + } + mesh.add_triangle(i, i + 1, i + 2); + mesh.add_triangle(i + 1, i + 2, i + 3); +} + +/// Fills `r` with a vertical gradient from `top` to `bottom`. +pub fn vgradient(p: &Painter, r: Rect, top: Color32, bottom: Color32) { + let mut mesh = Mesh::default(); + quad(&mut mesh, r, [top, top, bottom, bottom]); + p.add(mesh); +} + +/// A rounded rectangle shaded from `top` to `bottom`, with egui's anti-aliased edges. +pub fn rounded_vgradient(p: &Painter, r: Rect, radius: f32, top: Color32, bottom: Color32) { + let options = p.ctx().tessellation_options(|o| *o); + let mut tessellator = egui::epaint::Tessellator::new(p.pixels_per_point(), options, [1, 1], Vec::new()); + let mut mesh = Mesh::default(); + tessellator.tessellate_rect(&egui::epaint::RectShape::filled(r, radius, Color32::WHITE), &mut mesh); + for v in &mut mesh.vertices { + let k = ((v.pos.y - r.min.y) / r.height()).clamp(0.0, 1.0); + v.color = top.lerp_to_gamma(bottom, k).gamma_multiply(v.color.a() as f32 / 255.0); + } + p.add(mesh); +} + +/// A raised key as Pro Tools draws them: a dark rim, a face shaded from `faces[0]` to `faces[1]` +/// and a lit top edge. Returns the face's top colour. +pub fn raised_key(p: &Painter, r: Rect, faces: [Color32; 2], hovered: bool) -> Color32 { + let [top, bottom] = if hovered { faces.map(|c| shade(c, 0.06)) } else { faces }; + p.rect_filled(r, 3.0, shade(Tokens::current().slot_well, -0.3)); + rounded_vgradient(p, r.shrink(1.0), 2.0, top, bottom); + p.hline(r.min.x + 2.0..=r.max.x - 2.0, r.min.y + 1.5, Stroke::new(1.0, shade(top, 0.17))); + top +} + +/// A groove across a raised key whose face starts at `top`: a dark line, then a light one. +pub fn groove(p: &Painter, x: f32, ys: impl Into, top: Color32) { + let ys = ys.into(); + p.vline(x, ys, Stroke::new(1.0, shade(top, -0.42))); + p.vline(x + 1.0, ys, Stroke::new(1.0, shade(top, 0.12))); +} + +pub fn inset(p: &Painter, r: Rect, fill: Color32) { + let t = Tokens::current(); + p.rect(r, 2.0, fill, Stroke::new(1.0, t.border), StrokeKind::Inside); + p.hline(r.min.x + 1.0..=r.max.x - 1.0, r.min.y + 1.5, Stroke::new(1.0, Color32::from_black_alpha(60))); + p.hline(r.min.x + 2.0..=r.max.x - 2.0, r.max.y + 0.5, Stroke::new(1.0, t.edge_light)); +} + +pub fn key_fill(resp: &Response) -> Color32 { + let t = Tokens::current(); + if resp.hovered() { t.button_hi } else { t.button } +} + +/// A key in `fill`, softly lit from above; it sinks while pressed. +pub fn key(p: &Painter, r: Rect, resp: &Response, fill: Color32) { + let face = r.shrink(1.0); + if resp.is_pointer_button_down_on() { + vgradient(p, face, shade(fill, -0.14), shade(fill, 0.02)); + } else { + p.rect_filled(face, 0.0, fill); + key_light(p, face); + } + p.rect_stroke(r, 3.0, Stroke::new(1.0, Tokens::current().button_border), StrokeKind::Inside); +} + +/// Light from above over a key face already filled with its colour. +fn key_light(p: &Painter, face: Rect) { + vgradient(p, face, Color32::from_white_alpha(26), Color32::from_black_alpha(31)); + p.hline(face.min.x + 1.0..=face.max.x - 1.0, face.min.y + 0.5, Stroke::new(1.0, Color32::from_white_alpha(30))); +} /// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar. fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle { @@ -13,7 +94,7 @@ fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> Ico IconButtonStyle { normal: visuals(t.button, normal_icon), hovered: visuals(t.button_hi, normal_icon), - pressed: None, + pressed: Some(visuals(shade(t.button, -0.1), normal_icon)), selected: visuals(selected_fill, Color32::WHITE), corner_radius: CornerRadius::same(radius), focus_stroke: Stroke::new(1.0, t.accent), @@ -25,6 +106,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st let t = Tokens::current(); let style = button_style(t.accent, t.text, 3); let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { + key_light(ui.painter(), r.shrink(1.0)); let s = r.height().min(r.width()) * 0.78; icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint); }); @@ -35,14 +117,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); - let fill = if on { - on_color - } else if resp.hovered() { - t.button_hi - } else { - t.button - }; - ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside); + key(ui.painter(), r, &resp, if on { on_color } else { key_fill(&resp) }); let col = if on { t.text_dark } else { t.text }; ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col); resp.on_hover_text(tip) @@ -50,9 +125,23 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color /// Record-enable button (red dot); the caller owns track arming and command dispatch. pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response { - let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2); + let t = Tokens::current(); + let style = button_style(t.rec, Color32::from_rgb(218, 112, 106), 2); let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| { - ui.painter().circle_filled(r.center(), size.y * 0.24, tint); + key_light(ui.painter(), r.shrink(1.0)); + let rad = size.y * 0.24; + if on { + ui.painter().circle_filled(r.center(), rad, tint); + } else { + rounded_vgradient( + ui.painter(), + Rect::from_center_size(r.center(), Vec2::splat(2.0 * rad)), + 2.0 * rad, + tint, + Color32::from_rgb(176, 76, 72), + ); + } + ui.painter().circle_stroke(r.center(), rad, Stroke::new(1.0, Color32::from_black_alpha(90))); }); resp.on_hover_text(tip) } @@ -66,13 +155,7 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response { let t = Tokens::current(); let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click()); - ui.painter().rect( - r, - CornerRadius::same(2), - if resp.hovered() { t.button_hi } else { t.button }, - Stroke::new(1.0, t.button_border), - StrokeKind::Inside, - ); + key(ui.painter(), r, &resp, key_fill(&resp)); let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max); icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim); let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0)); @@ -97,9 +180,9 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response if resp.double_clicked() { *value = 0.0; } + let p = ui.painter(); let c = r.center(); let rad = size * 0.42; - 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; @@ -114,9 +197,13 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response let mid = start + sweep * 0.5; let at = start + sweep * (*value * 0.5 + 0.5); 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, t.knob_pointer)); + p.add(egui::Shape::line(arc(start, start + sweep), Stroke::new(2.0, t.meter_bg))); + p.add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text))); + p.circle_filled(c + vec2(0.0, 1.0), rad, Color32::from_black_alpha(90)); + rounded_vgradient(p, Rect::from_center_size(c, Vec2::splat(2.0 * rad)), 2.0 * rad, shade(t.knob_bg, 0.14), shade(t.knob_bg, -0.2)); + p.circle_stroke(c, rad - 0.75, Stroke::new(1.5, t.knob_ring)); + let d = vec2(at.cos(), at.sin()); + p.line_segment([c + d * rad * 0.15, c + d * (rad - 2.5)], Stroke::new(2.0, t.knob_pointer)); resp.on_hover_text(tip) } @@ -130,40 +217,42 @@ pub fn pan_text(v: f32) -> String { } } -/// Vertical peak meter with green/yellow/red zones; values are linear peaks. +/// Vertical LED 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::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) - }; + let p = ui.painter(); + p.rect_filled(r, 0.0, t.meter_bg); let h = r.height() - 4.0; let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0)); - let lv = pos(level); - if lv > 0.0 { - let top = inner.max.y - h * lv; - // Draw in zones. - let zones = - [(0.0, meter_pos(-12.0), t.meter_green), (meter_pos(-12.0), meter_pos(-3.0), t.meter_yellow), (meter_pos(-3.0), 1.0, t.meter_red)]; - for (a, b, col) in zones { - let y0 = inner.max.y - h * a; - let y1 = (inner.max.y - h * b).max(top); - if y1 < y0 { - ui.painter().rect_filled(Rect::from_min_max(pos2(inner.min.x, y1), pos2(inner.max.x, y0)), 0.0, col); - } - } + let lv = level_pos(level); + let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0)); + let mut leds = Mesh::default(); + for i in 0..(h / 3.0).clamp(0.0, 1000.0) as usize { + let y = inner.max.y - i as f32 * 3.0; + let f = (i as f32 * 3.0 + 1.0) / h; + let zone = if f > red { + t.meter_red + } else if f > amber { + t.meter_yellow + } else { + t.meter_green + }; + let col = if f <= lv { zone } else { zone.gamma_multiply(0.1) }; + quad(&mut leds, Rect::from_x_y_ranges(inner.x_range(), y - 2.0..=y), [col; 4]); } - let hv = pos(hold); + p.add(leds); + let hv = level_pos(hold); if hv > 0.01 { let y = inner.max.y - h * hv; - ui.painter().line_segment( - [pos2(inner.min.x, y), pos2(inner.max.x, y)], - Stroke::new(1.0, if hv > meter_pos(-3.0) { t.meter_red } else { t.meter_yellow }), - ); + p.line_segment([pos2(inner.min.x, y), pos2(inner.max.x, y)], Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow })); } 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 { t.meter_clip_off }); + p.rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off }); +} + +/// A linear peak → 0..1 meter position. +pub fn level_pos(v: f32) -> f32 { + meter_pos(if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() }) } /// dB → 0..1 meter position (logarithmic-ish scale like hardware meters). @@ -199,28 +288,32 @@ pub fn clip_gain_to_pos(db: f32) -> f32 { pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { 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); - // Scale marks. + let p = ui.painter(); + let cap_size = vec2((r.width() * 0.44).clamp(16.0, 24.0), 34.0); + let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - cap_size.y)); + p.rect_filled(slot, 2.0, t.fader_track); + p.vline(slot.min.x + 0.5, slot.min.y + 1.0..=slot.max.y - 1.0, Stroke::new(1.0, Color32::from_black_alpha(120))); + p.vline(slot.max.x + 0.5, slot.min.y + 2.0..=slot.max.y - 2.0, Stroke::new(1.0, Color32::from_white_alpha(18))); for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] { let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark); - ui.painter().line_segment([pos2(r.min.x + 2.0, y), pos2(slot.min.x - 3.0, y)], Stroke::new(1.0, Color32::from_rgb(110, 110, 110))); - ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), Color32::from_rgb(150, 150, 150)); + let x = p.text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim).max.x + 2.0; + if x < slot.min.x - 3.0 { + p.hline(x..=slot.min.x - 3.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7))); + } } let pos = soundcraft_model::fader_db_to_pos(db); let y = slot.max.y - slot.height() * pos; - let cap = Rect::from_center_size(pos2(slot.center().x, y), vec2(r.width() * 0.62, 22.0)); - ui.painter().rect(cap, 2.0, Color32::from_rgb(70, 70, 72), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside); - ui.painter().line_segment( - [pos2(cap.min.x + 2.0, cap.center().y), pos2(cap.max.x - 2.0, cap.center().y)], - Stroke::new(2.0, Color32::from_rgb(230, 230, 230)), - ); - for dy in [-6.0, -3.0, 3.0, 6.0] { - ui.painter().line_segment( - [pos2(cap.min.x + 3.0, cap.center().y + dy), pos2(cap.max.x - 3.0, cap.center().y + dy)], - Stroke::new(1.0, Color32::from_rgb(40, 40, 40)), - ); + let cap = Rect::from_center_size(pos2(slot.center().x, y), cap_size); + p.rect_filled(cap.translate(vec2(0.0, 2.0)).expand(1.0), 4.0, Color32::from_black_alpha(80)); + let g = Color32::from_gray; + vgradient(p, cap.shrink(1.0), g(226), g(146)); + p.rect_stroke(cap, 3.0, Stroke::new(1.0, g(34)), StrokeKind::Inside); + for dy in [-10.0, -7.0, 7.0, 10.0] { + let x = cap.min.x + 3.0..=cap.max.x - 3.0; + p.hline(x.clone(), cap.center().y + dy, Stroke::new(1.0, Color32::from_black_alpha(50))); + p.hline(x, cap.center().y + dy + 1.0, Stroke::new(1.0, Color32::from_white_alpha(90))); } + p.hline(cap.min.x + 1.0..=cap.max.x - 1.0, cap.center().y, Stroke::new(2.0, g(24))); if resp.double_clicked() || (resp.clicked() && ui.input(|i| i.modifiers.alt)) { return Some(0.0); } diff --git a/crates/ui-egui/src/windows.rs b/crates/ui-egui/src/windows.rs index 12b3540..b44c1b1 100644 --- a/crates/ui-egui/src/windows.rs +++ b/crates/ui-egui/src/windows.rs @@ -143,6 +143,8 @@ fn color_palette(app: &mut SoundApp, ctx: &egui::Context) { } } }); + ui.separator(); + ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window")); }); app.ui.show_color_palette = open; } @@ -202,10 +204,7 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) { ("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::().to_string()), ("Audio files", s.sources.len().to_string()), ("Markers", s.markers.len().to_string()), - ( - "Length", - soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0), - ), + ("Length", s.format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs)), ] { ui.label(k); ui.label(v); @@ -334,7 +333,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) { } let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase(); - (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())) + (is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str())) .then_some((name, is_dir)) }) .collect() diff --git a/crates/ui-egui/tests/command_reachability.rs b/crates/ui-egui/tests/command_reachability.rs new file mode 100644 index 0000000..384401e --- /dev/null +++ b/crates/ui-egui/tests/command_reachability.rs @@ -0,0 +1,108 @@ +//! Every user-facing engine command must be reachable from the GUI: through a menu, a shortcut, +//! or a widget that names it in `src/`. Commands that only work from the control channel / CLI / +//! MCP left users stuck (#282: `midi.new_clip` had no UI path, so an empty instrument track could +//! never get a MIDI clip). + +use soundcraft_engine::{catalog, cmd::command_specs}; +use soundcraft_ui_egui::menus::{self, MenuNode}; +use std::collections::HashSet; +use std::path::Path; + +/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today. +/// Give each one a UI path and remove it from this list; don't add to it. +const KNOWN_UNREACHED: &[&str] = &[ + "automation.clear", + "edit.paste_clip_effects", + "edit.paste_clip_gain", + "event.midi_note", + "file.bounce_stems", + "markers.next", + "markers.previous", + "mix.clear_mutes", + "mix.clear_solos", + "mix.insert_move", + "mix.instrument_param", + "mix.phase_invert", + "mix.sampler_clear", + "mix.sampler_load", + "mix.send_surround_pan", + "mix.solo_safe", + "options.post_roll", + "options.pre_roll", + "setup.hardware", // menu opens the Playback Engine window, which doesn't run it + "setup.playback_engine", // same + "setup.reset_edit_state", + "track.load_preset", + "track.pin", + "track.trim_automation_write", + "track.vca", + "transport.pause", + "transport.status", + "video.offset", + "view.sub_counter", +]; + +fn ui_source() -> String { + fn walk(dir: &Path, out: &mut String) { + for entry in std::fs::read_dir(dir).unwrap() { + let p = entry.unwrap().path(); + if p.is_dir() { + walk(&p, out); + } else if p.extension().is_some_and(|e| e == "rs") { + out.push_str(&std::fs::read_to_string(&p).unwrap()); + } + } + } + let mut s = String::new(); + walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut s); + s +} + +/// Command ids behind the leaves of the menu bar actually built by the app. A spec's own `menu` +/// field is not enough: the bar is built from the catalog, so an off-catalog path never shows. +fn menu_commands() -> HashSet { + fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet) { + for n in nodes { + if n.children.is_empty() { + out.extend(catalog::implemented_by(&n.path, extra)); + } + walk(&n.children, extra, out); + } + } + let mut out = HashSet::new(); + walk(&menus::tree(), &menus::ui_aliases(), &mut out); + out +} + +#[test] +fn every_command_has_a_ui_path() { + let src = ui_source(); + let in_menus = menu_commands(); + let mut unreached = Vec::new(); + let mut stale = Vec::new(); + for c in command_specs() { + // Queries and view-only commands are for agents / the UI's own reads. + if !c.journal || c.view { + continue; + } + let reached = in_menus.contains(c.id) || c.shortcut.is_some() || src.contains(&format!("\"{}\"", c.id)); + let known = KNOWN_UNREACHED.contains(&c.id); + if !reached && !known { + unreached.push(c.id); + } else if reached && known { + stale.push(c.id); + } + } + assert!(unreached.is_empty(), "commands with no menu, shortcut or UI reference: {unreached:?}"); + assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}"); +} + +#[test] +fn new_midi_clip_is_in_the_clip_menu() { + let extra = menus::ui_aliases(); + assert_eq!(catalog::implemented_by("Clip > New MIDI Clip", &extra).as_deref(), Some("midi.new_clip")); + assert!(menu_commands().contains("midi.new_clip")); + let tree = menus::tree(); + let clip = tree.iter().find(|n| n.label == "Clip").unwrap(); + assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first"); +} diff --git a/crates/ui-egui/tests/commands_filter.rs b/crates/ui-egui/tests/commands_filter.rs new file mode 100644 index 0000000..c0486f4 --- /dev/null +++ b/crates/ui-egui/tests/commands_filter.rs @@ -0,0 +1,38 @@ +use serde_json::{Value, json}; +use soundcraft_ui_egui::{ControlRequest, Services, SoundApp, control, menus}; + +fn commands(params: Value) -> Vec { + let (tx, rx) = std::sync::mpsc::channel(); + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()).with_control(rx); + let (req, reply) = ControlRequest::new("engine.commands", params); + tx.send(req).unwrap(); + control::drain(&mut app, &egui::Context::default()); + let response = reply.try_recv().unwrap(); + assert_eq!(response["ok"], true, "{response}"); + response["result"].as_array().unwrap().clone() +} + +fn ids(rows: &[Value]) -> Vec<&str> { + rows.iter().map(|r| r["id"].as_str().unwrap()).collect() +} + +#[test] +fn filter_applies_to_ui_commands() { + assert_eq!(ids(&commands(json!({"filter": "window.search"}))), ["window.search"]); + assert_eq!(ids(&commands(json!({"filter": "WINDOW.Search"}))), ["window.search"]); + // "Arrange Panel" matches only by label, and only for ui.dock. + assert_eq!(ids(&commands(json!({"filter": "arrange PANEL"}))), ["ui.dock"]); + assert!(commands(json!({"filter": "zzzznomatch"})).is_empty()); +} + +#[test] +fn absent_or_empty_filter_lists_every_ui_command() { + for params in [json!({}), json!({"filter": ""})] { + let rows = commands(params); + let ids = ids(&rows); + for (id, ..) in menus::UI_COMMANDS { + assert!(ids.contains(id), "{id} missing"); + } + assert!(ids.contains(&"track.new")); + } +} diff --git a/crates/ui-egui/tests/edit_io_readout.rs b/crates/ui-egui/tests/edit_io_readout.rs new file mode 100644 index 0000000..fe7b106 --- /dev/null +++ b/crates/ui-egui/tests/edit_io_readout.rs @@ -0,0 +1,61 @@ +use egui::{Color32, Shape, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; + +fn contrast(a: Color32, b: Color32) -> f64 { + let luminance = |color: Color32| { + let linear = |channel: u8| { + let s = f64::from(channel) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +#[test] +fn the_io_columns_vol_and_pan_readout_is_readable_in_both_themes() { + for mode in [ThemeMode::Dark, ThemeMode::Light] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("view.edit_io", json!({})).unwrap(); + app.run("track.height", json!({"tracks": ["Kick"], "height": "medium"})).unwrap(); + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + let mut harness = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + harness.step(); + } + let output = harness.output(); + let labels: Vec<_> = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(text) if matches!(text.galley.job.text.as_str(), "vol" | "pan") => Some(text), + _ => None, + }) + .collect(); + assert!(!labels.is_empty(), "{mode:?}: no vol/pan readout painted"); + for label in labels { + let center = label.visual_bounding_rect().center(); + let well = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.contains(center) && rect.fill.a() == 255 => Some(rect), + _ => None, + }) + .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) + .unwrap_or_else(|| panic!("{mode:?}: no readout behind {:?}", label.galley.job.text)); + let ink = label.override_text_color.unwrap_or(label.galley.job.sections[0].format.color); + let ratio = contrast(ink, well.fill); + assert!(ratio >= 4.5, "{mode:?}: {:?} reads at {ratio:.2}:1", label.galley.job.text); + } + } +} diff --git a/crates/ui-egui/tests/edit_window_headers.rs b/crates/ui-egui/tests/edit_window_headers.rs new file mode 100644 index 0000000..a9a2052 --- /dev/null +++ b/crates/ui-egui/tests/edit_window_headers.rs @@ -0,0 +1,178 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_model::{Track, TrackId}; +use soundcraft_ui_egui::edit_window::HEADER_W; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +const CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; + +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>, pos: Pos2, button: PointerButton, modifiers: Modifiers) { + h.state_mut().synthetic.extend([ + Event::ModifiersChanged(modifiers), + Event::PointerMoved(pos), + Event::PointerButton { pos, button, pressed: true, modifiers }, + Event::PointerButton { pos, button, pressed: false, modifiers }, + Event::ModifiersChanged(Modifiers::NONE), + ]); + for _ in 0..10 { + step(h); + } +} + +fn click(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + press(h, pos, PointerButton::Primary, Modifiers::NONE); +} + +fn kick(h: &Harness<'_, SoundApp>) -> Track { + h.state().engine.session().track_by_name("Kick").cloned().unwrap() +} + +fn insert(h: &Harness<'_, SoundApp>, slot: usize) -> Option { + kick(h).mixer.inserts.get(slot).cloned().flatten().map(|i| i.plugin) +} + +fn bypassed(h: &Harness<'_, SoundApp>, slot: usize) -> bool { + kick(h).mixer.inserts.get(slot).cloned().flatten().is_some_and(|i| i.bypass) +} + +fn harness(track: &str, height: &str, view: &str) -> (Harness<'static, SoundApp>, TrackId, [f32; 4]) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run(view, json!({})).unwrap(); + app.run("track.height", json!({"tracks": [track], "height": height})).unwrap(); + let id = app.engine.session().track_by_name(track).unwrap().id; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.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 row = h.state().edit_layout.rows.iter().find(|(t, _)| *t == id.0).map(|(_, r)| *r).unwrap(); + (h, id, row) +} + +#[test] +fn header_keys_freeze_silence_and_show_automation() { + let (mut h, _, row) = harness("Kick", "medium", "view.edit_io"); + let at = |x: f32, y: f32| pos2(row[0] + x, row[1] + y); + + click(&mut h, at(23.5, 65.5)); + assert!(kick(&h).frozen); + + click(&mut h, at(46.5, 65.5)); + let off = h.get_by_label("off").rect().center(); + click(&mut h, off); + assert!(kick(&h).inactive); + + click(&mut h, at(7.5, row[3] - row[1] - 7.5)); + assert_eq!(kick(&h).view, "volume"); + + click(&mut h, at(HEADER_W + 81.5, 29.5)); + assert_eq!(h.state().ui.window, MainWindow::Mix); +} + +#[test] +fn edit_window_insert_slots_open_change_and_add_plugins() { + let (mut h, kick, row) = harness("Kick", "medium", "view.edit_inserts_ae"); + let slot = |i: usize| pos2(row[0] + HEADER_W + 37.5, row[1] + 8.5 + i as f32 * 18.0); + + click(&mut h, slot(0)); + assert!(h.state().ui.plugin_windows.contains(&(kick, 0))); + h.state_mut().ui.plugin_windows.clear(); + + press(&mut h, slot(0), PointerButton::Primary, CMD); + assert!(bypassed(&h, 0)); + assert!(h.state().ui.plugin_windows.is_empty()); + + press(&mut h, slot(1), PointerButton::Secondary, Modifiers::NONE); + let remove = h.get_by_label("no insert").rect().center(); + click(&mut h, remove); + assert_eq!(insert(&h, 1), None); + + let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + click(&mut h, slot(2)); + let category = h.get_by_label(&format!("{:?} ⏵", plugin.category)).rect().center(); + h.state_mut().synthetic.push(Event::PointerMoved(category)); + for _ in 0..10 { + step(&mut h); + } + let pick = h.get_by_label(plugin.name).rect().center(); + click(&mut h, pick); + assert_eq!(insert(&h, 2).as_deref(), Some(plugin.id)); +} + +#[test] +fn short_tracks_show_insert_slots_as_squares() { + let (mut h, kick, row) = harness("Kick", "small", "view.edit_inserts_ae"); + let square = |i: usize| pos2(row[0] + HEADER_W + 9.0 + i as f32 * 14.0, row[1] + 8.5); + + click(&mut h, square(1)); + assert!(h.state().ui.plugin_windows.contains(&(kick, 1))); + + press(&mut h, square(0), PointerButton::Primary, CMD); + assert!(bypassed(&h, 0)); + + click(&mut h, square(4)); + let plugin = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + assert!(h.query_by_label(&format!("{:?} ⏵", plugin.category)).is_some()); +} + +#[test] +fn the_views_menu_stays_open_until_a_click_elsewhere() { + let (mut h, _, _) = harness("Kick", "medium", "view.edit_inserts_ae"); + let views = h.get_by_label("Views").rect().center(); + click(&mut h, views); + for column in ["Sends A-E", "I/O"] { + let item = h.get_by_label(column).rect().center(); + click(&mut h, item); + } + let edit = &h.state().engine.session().edit; + assert!(edit.flag("edit_view.sends_ae") && edit.flag("edit_view.io")); + click(&mut h, pos2(800.0, 990.0)); + assert!(h.query_by_label("Sends A-E").is_none()); +} + +#[test] +fn the_io_columns_route_menus_stay_open_while_searching_buses() { + let (mut h, _, row) = harness("Kick", "medium", "view.edit_io"); + click(&mut h, pos2(row[0] + HEADER_W + 40.0, row[1] + 29.5)); + let bus = h.get_by_label("Bus").rect(); + let search = h + .query_all_by_role(egui::accesskit::Role::TextInput) + .map(|node| node.rect()) + .filter(|r| r.min.y >= bus.max.y) + .min_by(|a, b| a.min.y.total_cmp(&b.min.y)) + .unwrap() + .center(); + click(&mut h, search); + assert!(h.query_by_label("no output").is_some(), "a click in the bus search closed the Output menu"); +} + +#[test] +fn only_a_sends_fader_icon_opens_its_level() { + let (mut h, snare, row) = harness("Snare", "medium", "view.edit_sends_ae"); + let send_a = |x: f32| pos2(row[0] + HEADER_W + x, row[1] + 8.5); + + // The right of the name, where the level used to start, picks the destination. + click(&mut h, send_a(50.0)); + assert!(h.query_by_label("DESTINATION").is_some()); + assert_eq!(h.state().ui.send_window, None); + click(&mut h, pos2(800.0, 990.0)); + + click(&mut h, send_a(65.5)); + assert_eq!(h.state().ui.send_window, Some((snare, 0))); + assert!(h.query_by_label("DESTINATION").is_none()); +} diff --git a/crates/ui-egui/tests/grid_lines.rs b/crates/ui-egui/tests/grid_lines.rs new file mode 100644 index 0000000..cac269f --- /dev/null +++ b/crates/ui-egui/tests/grid_lines.rs @@ -0,0 +1,70 @@ +use egui::{Color32, FullOutput, Pos2, RawInput, Rect, Shape, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::theme::Tokens; +use soundcraft_ui_egui::{Services, SoundApp, edit_window}; + +fn frame(ctx: &egui::Context, app: &mut SoundApp) -> FullOutput { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out +} + +fn over(top: Color32, bg: Color32) -> [i32; 3] { + let keep = 255 - i32::from(top.a()); + [(top.r(), bg.r()), (top.g(), bg.g()), (top.b(), bg.b())].map(|(t, b)| i32::from(t) + i32::from(b) * keep / 255) +} + +fn near(a: [i32; 3], b: Color32) -> bool { + a.iter().zip([b.r(), b.g(), b.b()]).all(|(x, y)| (x - i32::from(y)).abs() <= 6) +} + +#[test] +fn grid_lines_are_drawn_over_clips_while_shown() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + for theme in ["dark", "light"] { + for shown in [true, false] { + app.run("ui.theme", json!({"mode": theme})).unwrap(); + app.run("view.grid_lines", json!({"value": shown})).unwrap(); + frame(&ctx, &mut app); + let out = frame(&ctx, &mut app); + let t = Tokens::current(); + let s = app.engine.session().clone(); + let kick = s.track_by_name("Kick").unwrap(); + let clip = kick.clips()[0].clone(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let tl = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1; + let lane = Rect::from_min_max(pos2(tl.min.x, row[1]), pos2(tl.max.x, row[1] + kick.height.points())); + let (cx0, cx1) = (edit_window::x_of(&s, tl, clip.start), edit_window::x_of(&s, tl, clip.end())); + let body = out + .shapes + .iter() + .position(|c| matches!(&c.shape, Shape::Rect(r) if r.rect.min.x == cx0 && r.rect.max.x == cx1 && r.rect.min.y == lane.min.y + 1.0)) + .unwrap(); + let lines: Vec<(usize, Color32)> = out + .shapes + .iter() + .enumerate() + .filter_map(|(i, c)| match &c.shape { + Shape::LineSegment { points: [a, b], stroke } + if a.x == b.x && a.x > cx0 + 1.0 && a.x < cx1 - 1.0 && a.y == lane.min.y && b.y == lane.max.y => + { + Some((i, stroke.color)) + } + _ => None, + }) + .collect(); + assert!(lines.len() > 4, "{theme} {shown}: {} lines", lines.len()); + assert!(lines.iter().all(|(i, _)| (*i > body) == shown), "{theme} {shown}"); + for (_, col) in &lines { + let look = over(*col, t.playlist_bg); + assert!(near(look, t.grid_line) || near(look, t.bar_line), "{theme} {shown}: {look:?}"); + } + } + } +} diff --git a/crates/ui-egui/tests/hide_floating.rs b/crates/ui-egui/tests/hide_floating.rs new file mode 100644 index 0000000..7e9c2bd --- /dev/null +++ b/crates/ui-egui/tests/hide_floating.rs @@ -0,0 +1,19 @@ +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn hide_floating_closes_automation() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + let main_window = app.ui.window; + app.run("window.automation", json!({"value": true})).unwrap(); + app.run("window.transport", json!({"value": true})).unwrap(); + assert!(app.ui.show_automation && app.ui.show_transport); + + app.run("window.hide_floating", json!({})).unwrap(); + assert!(!app.ui.show_transport); + assert!(!app.ui.show_automation, "Automation must be hidden"); + assert_eq!(app.ui.window, main_window); + + app.run("window.automation", json!({"value": true})).unwrap(); + assert!(app.ui.show_automation); +} diff --git a/crates/ui-egui/tests/key_families.rs b/crates/ui-egui/tests/key_families.rs new file mode 100644 index 0000000..b3d728b --- /dev/null +++ b/crates/ui-egui/tests/key_families.rs @@ -0,0 +1,63 @@ +//! Shortcuts written as key families ("Cmd+Ctrl+1-5", "Cmd+Alt+0-5", "Cmd+Alt+[ / ]") fire, and Commands Focus +//! has Pro Tools' 1–5 zoom presets and N for Insertion Follows Playback. + +use egui::os::OperatingSystem; +use egui::{Key, Modifiers}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; +const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; +const OSES: [(OperatingSystem, Modifiers); 3] = + [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, PC_CTRL), (OperatingSystem::Nix, PC_CTRL)]; + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +fn rig(os: OperatingSystem) -> (egui::Context, SoundApp) { + let ctx = egui::Context::default(); + ctx.set_os(os); + (ctx, SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default())) +} + +#[test] +fn cmd_alt_brackets_zoom_waveforms_and_cmd_alt_digits_rate_clips() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + let cmd_alt = Modifiers { alt: true, ..cmd }; + press(&ctx, &mut app, Key::CloseBracket, cmd_alt); + press(&ctx, &mut app, Key::CloseBracket, cmd_alt); + press(&ctx, &mut app, Key::OpenBracket, cmd_alt); + assert_eq!(app.engine.session().edit.zoom.waveform_zoom, 2.0, "{os:?}"); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + press(&ctx, &mut app, Key::Num4, cmd_alt); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.rating, 4, "{os:?}"); + press(&ctx, &mut app, Key::Num9, cmd_alt); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.rating, 4, "{os:?}: 9 is outside 0-5"); + } +} + +#[test] +fn cmd_ctrl_digits_recall_zoom_presets_on_macos() { + let (ctx, mut app) = rig(OperatingSystem::Mac); + press(&ctx, &mut app, Key::Num2, Modifiers { ctrl: true, ..MAC_CMD }); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, 256.0); + press(&ctx, &mut app, Key::Num6, Modifiers { ctrl: true, ..MAC_CMD }); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, 256.0, "6 is outside 1-5"); +} + +#[test] +fn commands_focus_digits_recall_zoom_presets_and_n_toggles_insertion_follows_playback() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.engine.execute("options.keyboard_focus", &json!({"focus": "commands"})).unwrap(); + let follows = app.engine.session().edit.insertion_follows_playback; + press(&ctx, &mut app, Key::Num5, Modifiers::NONE); + press(&ctx, &mut app, Key::N, Modifiers::NONE); + let edit = &app.engine.session().edit; + assert_eq!((edit.zoom.samples_per_px, edit.insertion_follows_playback), (16384.0, !follows), "{os:?}"); + } +} diff --git a/crates/ui-egui/tests/marker_rename.rs b/crates/ui-egui/tests/marker_rename.rs new file mode 100644 index 0000000..10ec422 --- /dev/null +++ b/crates/ui-egui/tests/marker_rename.rs @@ -0,0 +1,84 @@ +//! Double-clicking a marker in the ruler renames it; elsewhere it still adds one (#186). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({})).unwrap(); + engine.execute("markers.add", &json!({"name": "Verse", "at": 96_000})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + r.frames(4); + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + /// The flag of the ruler marker named `name` (its label starts 8pt to the right). + fn flag(&self, name: &str) -> Pos2 { + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == name => Some(pos2(t.pos.x - 8.0, t.galley.rect.translate(t.pos.to_vec2()).center().y)), + _ => None, + }) + .expect("marker not on the ruler") + } + + fn double_click(&mut self, at: Pos2) { + for pressed in [true, false, true, false] { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + self.frames(2); + } + + fn names(&self) -> Vec { + self.app.engine.session().markers.iter().map(|m| m.name.clone()).collect() + } +} + +#[test] +fn double_clicking_a_ruler_marker_renames_it() { + let mut r = Rig::new(); + let flag = r.flag("Verse"); + r.double_click(flag); + assert_eq!(r.app.dialogs.open_name(), Some("rename_marker")); + r.frame(vec![Event::Text("Chorus".into())]); + r.frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + r.frames(2); + assert_eq!(r.names(), vec!["Chorus".to_string()]); +} + +#[test] +fn double_clicking_the_empty_ruler_still_adds_a_marker() { + let mut r = Rig::new(); + let flag = r.flag("Verse"); + r.double_click(flag + vec2(200.0, 0.0)); + assert_eq!(r.app.dialogs.open_name(), None); + assert_eq!(r.names().len(), 2); +} diff --git a/crates/ui-egui/tests/midi_editor_keys.rs b/crates/ui-egui/tests/midi_editor_keys.rs new file mode 100644 index 0000000..78906d0 --- /dev/null +++ b/crates/ui-egui/tests/midi_editor_keys.rs @@ -0,0 +1,57 @@ +use egui::{Event, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp}; + +/// The Keys track's MIDI clip in the editor with its first two notes selected, the Rename Track dialog open or not. +fn harness(rename: bool) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let keys = app.engine.session().track_by_name("Keys").unwrap().id; + if rename { + app.dialogs.open_rename_track(keys, "Keys"); + } + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..3 { + h.step(); + } + h.state_mut().midi.selected = vec![0, 1]; + h +} + +fn press(h: &mut Harness<'_, SoundApp>, key: Key) { + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + h.step(); +} + +#[test] +fn deleting_text_in_the_rename_field_keeps_the_selected_notes() { + for key in [Key::Backspace, Key::Delete] { + let mut h = harness(true); + let before = h.state().engine.session().clone(); + press(&mut h, key); + // The field opens with the old name selected, so the key clears it. + assert!(matches!(&h.state().dialogs.open, Some(Dialog::RenameTrack { name, .. }) if name.is_empty()), "{key:?}"); + assert!(*h.state().engine.session() == before, "{key:?} edited the session"); + } +} + +#[test] +fn deleting_in_the_editor_still_removes_the_selected_notes() { + for key in [Key::Backspace, Key::Delete] { + let mut h = harness(false); + let before = h.state().engine.session().clone(); + press(&mut h, key); + assert!(*h.state().engine.session() != before, "{key:?} left the notes"); + assert!(h.state().midi.selected.is_empty()); + } +} diff --git a/crates/ui-egui/tests/midi_front_visibility.rs b/crates/ui-egui/tests/midi_front_visibility.rs new file mode 100644 index 0000000..9fa1c73 --- /dev/null +++ b/crates/ui-egui/tests/midi_front_visibility.rs @@ -0,0 +1,28 @@ +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, menus}; + +#[test] +fn bring_to_front_keeps_an_open_midi_editor_open() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("window.midi_editor", json!({"value": true})).unwrap(); + let path = "Window > MIDI Editors > Bring to Front"; + let id = menus::command_for_path(path, &menus::ui_aliases()).unwrap(); + assert_eq!(id, "window.midi_front"); + for _ in 0..2 { + menus::invoke_menu(&mut app, &id, path); + assert!(app.ui.show_midi_editor, "Bring to Front hid the editor"); + } + + app.run("window.midi_editor", json!({"value": false})).unwrap(); + app.run("window.midi_front", json!({})).unwrap(); + assert!(app.ui.show_midi_editor, "Bring to Front did not show a hidden editor"); +} + +#[test] +fn the_midi_editor_command_still_toggles() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("window.midi_editor", json!({})).unwrap(); + assert!(app.ui.show_midi_editor); + app.run("window.midi_editor", json!({})).unwrap(); + assert!(!app.ui.show_midi_editor); +} diff --git a/crates/ui-egui/tests/midi_panel.rs b/crates/ui-egui/tests/midi_panel.rs index 56cb12d..f9042fa 100644 --- a/crates/ui-egui/tests/midi_panel.rs +++ b/crates/ui-egui/tests/midi_panel.rs @@ -150,3 +150,23 @@ fn check_held_replacement(operation: &str) { assert_eq!(serde_json::to_value(&app.ui.docking).unwrap(), reset); assert_eq!(*app.engine.session(), session); } + +#[test] +fn a_selected_video_clip_is_not_a_midi_target() { + use soundcraft_model::{ChannelFormat, Clip, ClipContent, SourceId, Track, TrackKind}; + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let midi = midi_editor::target_clip(&app).unwrap(); + let s = app.engine.session_mut(); + let track = s.add_track(TrackKind::Video, ChannelFormat::Mono, None); + let video = s.new_clip_id(); + let source = SourceId(s.alloc()); + s.track_mut(track).and_then(Track::playlist_mut).unwrap().clips.push(Clip::video(video, "picture", source, 0, 0, 48_000)); + + app.run("edit.select", json!({"clips": [video.0]})).unwrap(); + assert_eq!(midi_editor::target_clip(&app), None, "a video clip alone leaves the MIDI editors without a target"); + app.run("edit.select", json!({"clips": [video.0, midi.0]})).unwrap(); + assert_eq!(midi_editor::target_clip(&app), Some(midi), "the MIDI clip after a video clip is the target"); + let (_, clip) = app.engine.session().find_clip(midi).unwrap(); + assert!(matches!(clip.content, ClipContent::Midi { .. })); +} diff --git a/crates/ui-egui/tests/midi_quantize.rs b/crates/ui-egui/tests/midi_quantize.rs new file mode 100644 index 0000000..de9b0b3 --- /dev/null +++ b/crates/ui-egui/tests/midi_quantize.rs @@ -0,0 +1,38 @@ +//! The MIDI Editor's Quantize button snaps to the grid the editor shows. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +fn quantize_on(grid: &str) -> i64 { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.run("track.new", json!({"name": "Grid Check", "kind": "midi"})).unwrap(); + let clip = app.run("midi.new_clip", json!({"track": "Grid Check", "start": 0, "end": 96_000})).unwrap()["clip"].clone(); + app.run("midi.note_add", json!({"clip": clip, "pitch": 60, "start_ticks": 300, "length_ticks": 240})).unwrap(); + app.run("edit.select", json!({"clips": [clip]})).unwrap(); + app.run("view.grid", json!({"value": grid})).unwrap(); + app.ui.show_midi_editor = true; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h.get_by_label("Quantize").click(); + h.run_steps(3); + let notes = h.state_mut().run("midi.notes", json!({"clip": clip})).unwrap(); + assert_eq!(notes["notes"][0]["length"], 240, "{notes}"); + notes["notes"][0]["start"].as_i64().unwrap() +} + +#[test] +fn quantize_follows_a_quarter_note_grid() { + assert_eq!(quantize_on("1/4"), 0); +} + +#[test] +fn quantize_follows_a_sixteenth_note_grid() { + assert_eq!(quantize_on("1/16"), 240); +} diff --git a/crates/ui-egui/tests/mix_instrument_contrast.rs b/crates/ui-egui/tests/mix_instrument_contrast.rs index fb33610..663cab3 100644 --- a/crates/ui-egui/tests/mix_instrument_contrast.rs +++ b/crates/ui-egui/tests/mix_instrument_contrast.rs @@ -6,12 +6,15 @@ use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode}; #[derive(Debug)] struct Slot { rect: Rect, + /// The face behind the label's centre line. fill: Color32, + /// The face's top and bottom colours. + faces: [Color32; 2], text: Color32, border: Color32, } -// Read the rectangle and foreground emitted by the real Mix drawing path, rather than +// Read the face and foreground emitted by the real Mix drawing path, rather than // testing a palette token that a custom-painted control might never use. fn slot(output: &FullOutput, label: &str) -> Slot { let text = output @@ -23,19 +26,38 @@ fn slot(output: &FullOutput, label: &str) -> Slot { }) .unwrap_or_else(|| panic!("missing painted label {label}")); let center = text.visual_bounding_rect().center(); - let rect = output + // A used slot is a raised key: a rim, then a face shaded from top to bottom. + let face = output .shapes .iter() - .find_map(|shape| match &shape.shape { - Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect), + .filter_map(|shape| match &shape.shape { + Shape::Mesh(mesh) if mesh.calc_bounds().contains(center) => Some(mesh), _ => None, }) + .min_by(|a, b| a.calc_bounds().area().total_cmp(&b.calc_bounds().area())) .unwrap_or_else(|| panic!("missing painted slot for {label}")); + let opaque = || face.vertices.iter().filter(|v| v.color.a() == 255); + let top = opaque().min_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap_or_else(|| panic!("{label}: face has no opaque vertices")); + let bottom = opaque().max_by(|a, b| a.pos.y.total_cmp(&b.pos.y)).unwrap_or(top); + let k = ((center.y - top.pos.y) / (bottom.pos.y - top.pos.y).max(1.0)).clamp(0.0, 1.0); + let rim = output + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.rect.height() <= 17.0 && rect.rect.contains(center) => Some(rect), + _ => None, + }) + .min_by(|a, b| a.rect.area().total_cmp(&b.rect.area())) + .unwrap_or_else(|| panic!("missing painted rim for {label}")); let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color); assert_eq!(text.opacity_factor, 1.0); assert_eq!(foreground.a(), 255); - assert_eq!(rect.fill.a(), 255); - Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color } + Slot { rect: rim.rect, fill: top.color.lerp_to_gamma(bottom.color, k), faces: [top.color, bottom.color], text: foreground, border: rim.fill } +} + +/// Face colours as painted: the anti-aliased edge sits half a pixel inside the gradient's ends. +fn near(a: [Color32; 2], b: [Color32; 2]) -> bool { + a.iter().zip(b).all(|(x, y)| x.to_array().iter().zip(y.to_array()).all(|(p, q)| p.abs_diff(q) <= 2)) } fn contrast(a: Color32, b: Color32) -> f64 { @@ -101,12 +123,19 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { } let ordinary = slot(harness.output(), "SubSynth"); let insert = slot(harness.output(), "EQ7"); - let empty = slot(harness.output(), "no instrument"); let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK }; - assert_eq!(insert.fill, palette.insert_on); - assert_eq!(empty.fill, palette.slot_bg); + let face = palette.slot_face; + assert!(near(insert.faces, face), "{name}: insert faces {:?}", insert.faces); + // The empty instrument track's slot sits beside the populated one, drawn as an empty cell. + let row = ordinary.rect.center().y; + let empty = harness.output().shapes.iter().any(|shape| { + matches!(&shape.shape, Shape::Rect(rect) if rect.fill == palette.slot_empty + && rect.rect.y_range().contains(row) + && rect.rect.min.x > ordinary.rect.max.x) + }); + assert!(empty, "{name}: missing the empty instrument slot"); assert_eq!(ordinary.text, palette.text); - if ordinary.fill != insert.fill || ordinary.border != insert.border { + if !near(ordinary.faces, insert.faces) || ordinary.border != insert.border { failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}")); } for hovered in [false, true] { @@ -126,11 +155,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() { if ratio < 4.5 { failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1")); } - if !light { - let original = Color32::from_rgb(52, 62, 80); - assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original }); - assert_eq!(painted.border, Color32::from_rgb(16, 16, 16)); - } + assert!(near(painted.faces, face), "{name} hover={hovered}: faces {:?}", painted.faces); if let Some(dir) = &proof_dir { harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap(); } diff --git a/crates/ui-egui/tests/mix_narrow_headers.rs b/crates/ui-egui/tests/mix_narrow_headers.rs new file mode 100644 index 0000000..5b19c91 --- /dev/null +++ b/crates/ui-egui/tests/mix_narrow_headers.rs @@ -0,0 +1,57 @@ +use egui::{Shape, vec2}; +use serde_json::json; +use soundcraft_ui_egui::mix_window::strip_width; +use soundcraft_ui_egui::{Services, SoundApp}; + +const FULL: [&str; 7] = ["INSERTS A-E", "INSERTS F-J", "SENDS A-E", "SENDS F-J", "INSTRUMENT", "comments", "no group"]; +const SHORT: [&str; 6] = ["I A-E", "I F-J", "S A-E", "S F-J", "INST", "nogrp"]; + +fn painted_labels(narrow: bool) -> Vec<(String, f32, bool)> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("window.mix", json!({})).unwrap(); + app.run("view.mix_section", json!({"section": "all"})).unwrap(); + app.run("view.mix_section", json!({"section": "instruments"})).unwrap(); + app.ui.narrow_mix = narrow; + let mut harness = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + harness.step(); + } + harness + .output() + .shapes + .iter() + .filter_map(|s| match &s.shape { + Shape::Text(t) if FULL.contains(&t.galley.job.text.as_str()) || SHORT.contains(&t.galley.job.text.as_str()) => { + Some((t.galley.job.text.clone(), t.visual_bounding_rect().width(), t.galley.elided)) + } + _ => None, + }) + .collect() +} + +#[test] +fn narrow_strips_use_short_section_names_that_fit() { + let labels = painted_labels(true); + for name in SHORT { + assert!(labels.iter().any(|(text, ..)| text == name), "{name} missing in {labels:?}"); + } + for (text, width, elided) in &labels { + assert!(SHORT.contains(&text.as_str()), "{text} painted in a narrow strip, where it doesn't fit"); + assert!(*width <= strip_width(true) - 16.0 && !elided, "{text} is {width} pt wide, elided: {elided}"); + } +} + +#[test] +fn normal_strips_keep_the_full_section_names() { + let labels = painted_labels(false); + for name in FULL { + assert!(labels.iter().any(|(text, ..)| text == name), "{name} missing in {labels:?}"); + } + assert!(labels.iter().all(|(text, ..)| FULL.contains(&text.as_str())), "{labels:?}"); +} diff --git a/crates/ui-egui/tests/mix_strip_layout.rs b/crates/ui-egui/tests/mix_strip_layout.rs new file mode 100644 index 0000000..aa6bad1 --- /dev/null +++ b/crates/ui-egui/tests/mix_strip_layout.rs @@ -0,0 +1,87 @@ +use egui::{Color32, FullOutput, Shape, epaint::TextShape, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{ + Services, SoundApp, + theme::{ThemeMode, Tokens}, +}; + +const SCALE: [&str; 10] = ["12", "6", "0", "5", "10", "15", "20", "30", "40", "60"]; + +// The palette is process-wide, so tests in this file take turns. +static PALETTE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn mix(mode: ThemeMode) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.session_mut().edit.selected_tracks.clear(); + // Fader caps at the bottom, clear of the scale. + for track in &mut app.engine.session_mut().tracks { + track.mixer.volume_db = -144.0; + } + app.run("window.mix", json!({})).unwrap(); + app.run("ui.theme", json!({"mode": mode.id()})).unwrap(); + let mut harness = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + harness.step(); + } + harness +} + +fn texts<'a>(output: &'a FullOutput, keep: impl Fn(&TextShape) -> bool + 'a) -> impl Iterator + 'a { + output.shapes.iter().filter_map(move |shape| match &shape.shape { + Shape::Text(text) if keep(text) => Some(text), + _ => None, + }) +} + +fn contrast(a: Color32, b: Color32) -> f64 { + let luminance = |color: Color32| { + let linear = |channel: u8| { + let s = f64::from(channel) / 255.0; + if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b()) + }; + let (a, b) = (luminance(a), luminance(b)); + (a.max(b) + 0.05) / (a.min(b) + 0.05) +} + +#[test] +fn fader_scale_numbers_are_readable_and_clear_of_their_ticks() { + let _palette = PALETTE.lock().unwrap_or_else(|e| e.into_inner()); + for (mode, palette) in [(ThemeMode::Dark, Tokens::DARK), (ThemeMode::Light, Tokens::LIGHT)] { + let harness = mix(mode); + let output = harness.output(); + let numbers: Vec<_> = + texts(output, |t| t.galley.job.sections[0].format.font_id.size == 8.0 && SCALE.contains(&t.galley.job.text.as_str())).collect(); + assert!(numbers.len() >= SCALE.len() * 2, "{mode:?}: found {} scale numbers", numbers.len()); + for number in numbers { + let color = number.override_text_color.unwrap_or(number.galley.job.sections[0].format.color); + let ratio = contrast(color, palette.strip_bg); + assert!(ratio >= 4.5, "{mode:?}: scale number {:?} contrast {ratio:.2}:1", number.galley.job.text); + let glyphs = number.visual_bounding_rect(); + for shape in &output.shapes { + if let Shape::LineSegment { points: [a, b], .. } = &shape.shape { + let crosses = a.y == b.y && glyphs.y_range().contains(a.y) && a.x.min(b.x) < glyphs.max.x && a.x.max(b.x) > glyphs.min.x; + assert!(!crosses, "{mode:?}: a tick runs through {:?}", number.galley.job.text); + } + } + } + } +} + +#[test] +fn stereo_strips_line_up_with_mono_strips() { + let _palette = PALETTE.lock().unwrap_or_else(|e| e.into_inner()); + let harness = mix(ThemeMode::Dark); + let solo_keys: Vec = texts(harness.output(), |t| t.galley.job.text == "S").map(|t| t.visual_bounding_rect().center().y).collect(); + assert!(solo_keys.len() >= 6, "found {} solo keys", solo_keys.len()); + let (top, bottom) = solo_keys.iter().fold((f32::MAX, f32::MIN), |(lo, hi), &y| (lo.min(y), hi.max(y))); + assert!(bottom - top < 0.5, "solo keys span {top}..{bottom}"); +} diff --git a/crates/ui-egui/tests/new_tracks.rs b/crates/ui-egui/tests/new_tracks.rs new file mode 100644 index 0000000..86f9132 --- /dev/null +++ b/crates/ui-egui/tests/new_tracks.rs @@ -0,0 +1,67 @@ +//! New Tracks opens from the Tracks list's + button, and from a double-click below the last +//! track (Edit) or right of the last strip (Mix). + +use egui::{Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness(tracks: usize) -> Harness<'static, SoundApp> { + let mut engine = soundcraft_engine::Engine::default(); + if tracks > 0 { + engine.execute("track.new", &serde_json::json!({"count": tracks})).unwrap(); + } + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.step(); +} + +#[test] +fn the_tracks_list_plus_button_opens_new_tracks() { + let mut h = harness(0); + h.get_by_label_contains("New Tracks").click(); + h.run_steps(2); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); +} + +#[test] +fn double_click_below_the_last_track_opens_new_tracks() { + let mut h = harness(2); + let rows = h.state().edit_layout.rows.clone(); + let below = rows[1].1[3] + 60.0; + for x in [h.state().edit_layout.timeline[0] + 100.0, rows[0].1[0] + 60.0] { + click(&mut h, pos2(x, below)); + assert_eq!(h.state().dialogs.open_name(), None, "one click only clears the selection"); + assert!(h.state().engine.session().edit.selected_tracks.is_empty()); + click(&mut h, pos2(x, below)); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); + h.state_mut().dialogs.open = None; + h.run_steps(30); + } +} + +#[test] +fn double_click_right_of_the_last_strip_opens_new_tracks() { + let mut h = harness(1); + h.state_mut().ui.window = MainWindow::Mix; + h.run_steps(4); + let strip = h.get_by_label("Record enable").rect(); + let at = pos2(strip.max.x + 150.0, strip.center().y); + click(&mut h, at); + click(&mut h, at); + assert_eq!(h.state().dialogs.open_name(), Some("new_tracks")); +} diff --git a/crates/ui-egui/tests/pro_tools_keys.rs b/crates/ui-egui/tests/pro_tools_keys.rs new file mode 100644 index 0000000..a6af215 --- /dev/null +++ b/crates/ui-egui/tests/pro_tools_keys.rs @@ -0,0 +1,234 @@ +//! Pro Tools keys for commands that had none: Grid and Nudge values, Commands Focus nudging and selection +//! keys, playlists, F12, Ctrl+Tab off macOS and the window keys. + +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; +use serde_json::json; +use soundcraft_engine::TransportRequest; +use soundcraft_model::ClipContent; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; +const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; +const OSES: [(OperatingSystem, Modifiers); 3] = + [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, PC_CTRL), (OperatingSystem::Nix, PC_CTRL)]; +const ALT_SHIFT: Modifiers = Modifiers { alt: true, shift: true, ..Modifiers::NONE }; + +fn keys(key: Key, modifiers: Modifiers) -> Vec { + let presses = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + [Event::ModifiersChanged(modifiers)].into_iter().chain(presses).collect() +} + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + ctx.run_ui(RawInput { events: keys(key, modifiers), ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +fn rig(os: OperatingSystem) -> (egui::Context, SoundApp) { + let ctx = egui::Context::default(); + ctx.set_os(os); + (ctx, SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default())) +} + +fn start(app: &SoundApp) -> i64 { + app.engine.session().edit.selection.start +} + +#[test] +fn shift_equals_and_minus_step_the_grid_value_and_with_alt_the_nudge_value() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.run("view.grid", json!({"value": "1/16"})).unwrap(); + app.run("view.nudge", json!({"value": "1/16"})).unwrap(); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 48_000, "end": 96_000})).unwrap(); + press(&ctx, &mut app, Key::Plus, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Minus, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Minus, Modifiers::SHIFT); + let up = if os == OperatingSystem::Mac { Key::Equals } else { Key::Plus }; + press(&ctx, &mut app, up, ALT_SHIFT); + press(&ctx, &mut app, up, ALT_SHIFT); + press(&ctx, &mut app, Key::Minus, ALT_SHIFT); + let edit = &app.engine.session().edit; + assert_eq!((edit.grid.label(), edit.nudge.label()), ("1/32 note".to_string(), "1/8 note".to_string()), "{os:?}"); + assert_eq!(start(&app), 48_000, "{os:?}"); + press(&ctx, &mut app, Key::Plus, Modifiers::NONE); + assert!(start(&app) > 48_000, "{os:?}"); + } +} + +#[test] +fn commands_focus_nudges_matches_the_selections_plays_and_redoes() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.run("options.keyboard_focus", json!({"focus": "commands"})).unwrap(); + app.run("view.nudge", json!({"value": {"samples": 100}})).unwrap(); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 48_000, "end": 96_000})).unwrap(); + let mut at = vec![]; + for key in [Key::Period, Key::Slash, Key::Comma, Key::M] { + press(&ctx, &mut app, key, Modifiers::NONE); + at.push(start(&app)); + } + assert_eq!(at, [48_100, 49_100, 49_000, 48_000], "{os:?}"); + press(&ctx, &mut app, Key::Z, Modifiers::NONE); + assert_eq!(start(&app), 49_000, "{os:?}"); + press(&ctx, &mut app, Key::Z, Modifiers::SHIFT); + assert_eq!(start(&app), 48_000, "{os:?}"); + + app.run("options.link_timeline_edit", json!({"value": false})).unwrap(); + press(&ctx, &mut app, Key::O, Modifiers::NONE); + let edit_selection = app.engine.session().edit.selection; + assert_eq!(app.engine.session().edit.timeline_selection, edit_selection, "{os:?}"); + app.run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 10})).unwrap(); + press(&ctx, &mut app, Key::Num0, Modifiers::NONE); + assert_eq!(app.engine.session().edit.selection, edit_selection, "{os:?}"); + + app.engine.transport_requests.clear(); + press(&ctx, &mut app, Key::OpenBracket, Modifiers::NONE); + press(&ctx, &mut app, Key::CloseBracket, Modifiers::NONE); + assert_eq!( + app.engine.transport_requests, + [TransportRequest::PlaySelection, TransportRequest::Locate(48_000), TransportRequest::Play], + "{os:?}" + ); + } +} + +#[test] +fn commands_focus_shift_p_and_semicolon_extend_the_selection_onto_shown_tracks() { + for (os, _) in OSES { + let (ctx, mut app) = rig(os); + app.run("track.move_to_new_folder", json!({"tracks": ["Bass"], "name": "Low"})).unwrap(); + app.run("track.folder_toggle", json!({"track": "Low", "open": false})).unwrap(); + app.run("track.hide", json!({"tracks": ["Hats"], "hidden": true})).unwrap(); + app.run("options.keyboard_focus", json!({"focus": "commands"})).unwrap(); + app.run("edit.select", json!({"tracks": ["Snare"], "start": 48_000, "end": 96_000})).unwrap(); + press(&ctx, &mut app, Key::P, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Colon, Modifiers::SHIFT); + press(&ctx, &mut app, Key::Semicolon, Modifiers::SHIFT); + let s = app.engine.session(); + let names: Vec<&str> = s.edit.selected_tracks.iter().map(|t| s.track(*t).unwrap().name.as_str()).collect(); + assert_eq!(names, ["Kick", "Snare", "Low", "Pad"], "{os:?}"); + assert_eq!((s.edit.selection.start, s.edit.selection.end), (48_000, 96_000), "{os:?}"); + } +} + +#[test] +fn alt_shift_and_shift_right_keys_work_the_playlists() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + let kick = app.engine.session().track_by_name("Kick").unwrap().id; + let first = app.engine.session().track(kick).unwrap().clips()[0].range(); + let third = first.len() / 3; + let select = |app: &mut SoundApp, part: i64| { + let start = first.start + part * third; + app.run("edit.select", json!({"tracks": ["Kick"], "start": start, "end": start + third})).unwrap(); + }; + select(&mut app, 0); + let playlists = |app: &SoundApp| { + let t = app.engine.session().track(kick).unwrap(); + (t.playlists.iter().map(|p| p.clips.len()).collect::>(), t.active_playlist) + }; + let main = playlists(&app).0[0]; + + press(&ctx, &mut app, Key::ArrowDown, ALT_SHIFT); + assert_eq!(playlists(&app), (vec![main, 1], 1), "{os:?}: copied to a new playlist, now shown"); + press(&ctx, &mut app, Key::ArrowRight, Modifiers { shift: true, ..cmd }); + app.run("track.playlist_select", json!({"index": 0})).unwrap(); + select(&mut app, 1); + press(&ctx, &mut app, Key::ArrowUp, ALT_SHIFT); + assert_eq!(playlists(&app).0[1], 2, "{os:?}: copied to the target playlist"); + select(&mut app, 2); + press(&ctx, &mut app, Key::T, ALT_SHIFT); + assert_eq!(playlists(&app).0[1], 3, "{os:?}: moved to the target playlist"); + select(&mut app, 0); + press(&ctx, &mut app, Key::N, ALT_SHIFT); + assert_eq!(playlists(&app).0.len(), 3, "{os:?}: moved to a new playlist"); + press(&ctx, &mut app, Key::ArrowRight, Modifiers::SHIFT); + assert_eq!(playlists(&app).1, 1, "{os:?}: the target playlist is shown"); + } +} + +#[test] +fn f12_records_ctrl_tab_goes_back_off_macos_and_cmd_alt_tab_toggles_tab_to_transient() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + press(&ctx, &mut app, Key::F12, Modifiers::NONE); + assert_eq!(app.engine.transport_requests.last(), Some(&TransportRequest::Record), "{os:?}"); + + let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].range(); + let mid = clip.start + clip.len() / 2; + app.run("edit.select", json!({"tracks": ["Kick"], "start": mid, "end": mid})).unwrap(); + press(&ctx, &mut app, Key::Tab, Modifiers::NONE); + assert_eq!(start(&app), clip.end, "{os:?}"); + let mac = os == OperatingSystem::Mac; + press(&ctx, &mut app, Key::Tab, if mac { Modifiers::CTRL } else { cmd }); + assert_eq!(start(&app), if mac { clip.end } else { clip.start }, "{os:?}: Ctrl+Tab"); + app.run("edit.select", json!({"tracks": ["Kick"], "start": mid, "end": mid})).unwrap(); + press(&ctx, &mut app, Key::Tab, Modifiers::ALT); + assert_eq!(start(&app), clip.start, "{os:?}: Alt+Tab"); + + let transient = app.engine.session().edit.tab_to_transient; + let at = start(&app); + press(&ctx, &mut app, Key::Tab, Modifiers { alt: true, ..cmd }); + assert_eq!((app.engine.session().edit.tab_to_transient, start(&app)), (!transient, at), "{os:?}"); + } +} + +#[test] +fn window_keys_open_their_windows() { + for (os, cmd) in OSES { + let (ctx, mut app) = rig(os); + for key in [Key::Num7, Key::Num6, Key::Equals, Key::Quote] { + press(&ctx, &mut app, key, Modifiers::ALT); + } + press(&ctx, &mut app, Key::K, Modifiers::SHIFT); + press(&ctx, &mut app, Key::J, Modifiers { alt: true, ..cmd }); + let ui = &app.ui; + assert!( + ui.show_universe && ui.show_clip_effects && ui.show_event_list && ui.show_task_manager && ui.show_midi_keyboard && ui.show_configurations, + "{os:?}" + ); + for key in [Key::I, Key::J] { + app.run("window.workspace_close", json!({})).unwrap(); + press(&ctx, &mut app, key, Modifiers::ALT); + assert!(app.ui.show_workspace, "{os:?}: Alt+{key:?}"); + } + } +} + +#[test] +fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.ui.show_midi_editor = true; + app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap(); + let ctx = egui::Context::default(); + let frame = |app: &mut SoundApp, events: Vec| { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); + }; + for _ in 0..6 { + frame(&mut app, vec![]); + } + let body = ctx.data(|d| d.get_temp::(egui::Id::new("soundcraft-midi-body"))).unwrap(); + frame(&mut app, vec![Event::PointerMoved(body.center())]); + app.midi.selected = vec![0]; + let pitch = |app: &SoundApp| { + let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap(); + match &app.engine.session().find_clip(clip).unwrap().1.content { + ClipContent::Midi { sequence } => sequence.notes[0].pitch, + _ => panic!("not a MIDI clip"), + } + }; + let before = pitch(&app); + for modifiers in [ALT_SHIFT, Modifiers::ALT, MAC_CMD, Modifiers::CTRL] { + frame(&mut app, keys(Key::ArrowUp, modifiers)); + } + frame(&mut app, keys(Key::ArrowRight, Modifiers::SHIFT)); + assert_eq!(pitch(&app), before); + frame(&mut app, keys(Key::ArrowUp, Modifiers::NONE)); + assert_eq!(pitch(&app), before + 1); +} diff --git a/crates/ui-egui/tests/record_hint.rs b/crates/ui-egui/tests/record_hint.rs new file mode 100644 index 0000000..afcd11a --- /dev/null +++ b/crates/ui-egui/tests/record_hint.rs @@ -0,0 +1,25 @@ +//! Record with nothing to record on explains itself at the Record buttons. + +use egui::vec2; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn record_in_an_empty_session_shows_why_at_both_record_buttons() { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.ui.show_transport = true; + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + assert_eq!(h.query_all_by_label_contains("Nothing to record on").count(), 0); + h.get_by_label("transport.record").click(); + h.run_steps(3); + assert!(!h.state().engine.transport.recording); + assert_eq!(h.query_all_by_label_contains("Nothing to record on").count(), 2, "the toolbar's and the Transport window's"); +} diff --git a/crates/ui-egui/tests/send_window.rs b/crates/ui-egui/tests/send_window.rs new file mode 100644 index 0000000..908885d --- /dev/null +++ b/crates/ui-egui/tests/send_window.rs @@ -0,0 +1,110 @@ +//! A send's level opens its Send window, as in Pro Tools: a fader with Alt-click for 0 dB, moved to +//! another send by clicking that send's level, closed by clicking the same one again. + +use egui::{Event, Modifiers, PointerButton, Pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness() -> (Harness<'static, SoundApp>, soundcraft_model::TrackId) { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); + let vox = engine.session().tracks[0].id; + for (slot, bus) in [(0, "Verb"), (1, "Delay")] { + engine.execute("mix.send", &json!({"track": vox.0, "slot": slot, "bus": bus, "level_db": -10.0})).unwrap(); + } + let mut app = SoundApp::new(engine, None, Services::default()); + app.ui.window = MainWindow::Mix; + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + (h, vox) +} + +fn level(h: &Harness, slot: usize) -> f32 { + h.state().engine.session().tracks[0].mixer.sends[slot].as_ref().unwrap().level_db +} + +fn press(h: &mut Harness, at: Pos2, pressed: bool, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers }); + h.step(); +} + +#[test] +fn a_sends_level_opens_its_send_window() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 0))); + assert!(h.query_by_label_contains("Vox · Send A").is_some()); + + let fader = h.get_by_label("Send level").rect(); + h.hover_at(fader.center()); + press(&mut h, fader.center(), true, Modifiers::ALT); + press(&mut h, fader.center(), false, Modifiers::ALT); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + h.run_steps(2); + assert_eq!(level(&h, 0), 0.0, "Alt-click on the fader gives 0 dB"); + + press(&mut h, fader.center(), true, Modifiers::NONE); + for dy in [10.0, 60.0, 150.0, 300.0] { + h.hover_at(fader.center() + vec2(0.0, dy)); + h.step(); + } + press(&mut h, fader.center() + vec2(0.0, 300.0), false, Modifiers::NONE); + assert_eq!(level(&h, 0), -144.0, "the fader goes down to −∞"); + assert_eq!(level(&h, 1), -10.0); + + h.get_by_label("Send B level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 1)), "another send's level moves the window there"); + assert!(h.query_by_label_contains("Vox · Send B").is_some()); + h.get_by_label("Send B level").click(); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, None, "clicking the same send again closes it"); + assert!(h.query_by_label_contains("Vox · Send").is_none()); +} + +#[test] +fn escape_closes_the_send_window_after_menus_and_typing() { + let (mut h, vox) = harness(); + h.get_by_label("Send A level").click(); + h.run_steps(3); + + // A right-click menu on top takes the first Escape. + let at = h.get_by_label("Send A level").rect().center(); + h.hover_at(at); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); + assert!(h.query_by_label("Pre-fader").is_some(), "the send's options menu is open"); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert!(h.query_by_label("Pre-fader").is_none()); + assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only closed the menu"); + + // So does typing a level in the window. + let fader = h.get_by_label("Send level").rect(); + let field = egui::pos2(fader.center().x, fader.max.y + 10.0); + h.hover_at(field); + press(&mut h, field, true, Modifiers::NONE); + press(&mut h, field, false, Modifiers::NONE); + h.run_steps(3); + assert!(h.state().ui.send_window.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, Some((vox, 0)), "that Escape only cancelled the typing"); + + h.key_press(egui::Key::Escape); + h.run_steps(3); + assert_eq!(h.state().ui.send_window, None); + assert_eq!(level(&h, 0), -10.0); +} diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs index 5d04d47..190cd40 100644 --- a/crates/ui-egui/tests/shortcuts.rs +++ b/crates/ui-egui/tests/shortcuts.rs @@ -25,6 +25,37 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { } } +fn type_in_app(ctx: &egui::Context, app: &mut SoundApp, keys: &[(Key, Modifiers)]) { + let events = keys + .iter() + .flat_map(|&(key, modifiers)| [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers })) + .collect(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +#[test] +fn tab_leaves_the_keys_to_the_shortcuts() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + for _ in 0..3 { + type_in_app(&ctx, &mut app, &[]); + } + for tab in [Modifiers::NONE, Modifiers::SHIFT] { + type_in_app(&ctx, &mut app, &[(Key::Tab, tab)]); + assert_eq!(ctx.memory(|m| m.focused()), None, "{tab:?}"); + let zoom = app.engine.session().edit.zoom.samples_per_px; + type_in_app(&ctx, &mut app, &[(Key::T, Modifiers::NONE)]); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, zoom / 2.0, "{tab:?}"); + type_in_app(&ctx, &mut app, &[(Key::R, Modifiers::NONE)]); + assert_eq!(app.engine.session().edit.zoom.samples_per_px, zoom, "{tab:?}"); + } +} + #[test] fn cmd_equals_toggles_mix_and_edit_without_zooming() { let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE }; diff --git a/crates/ui-egui/tests/toolbar_tempo.rs b/crates/ui-egui/tests/toolbar_tempo.rs new file mode 100644 index 0000000..67ad3f5 --- /dev/null +++ b/crates/ui-egui/tests/toolbar_tempo.rs @@ -0,0 +1,105 @@ +//! The toolbar's tempo can be dragged or typed (#189). + +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, vec2}; +use serde_json::json; +use soundcraft_time::BarBeat; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + r.frames(4); + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + fn bpm(&self, tick: i64) -> f64 { + self.app.engine.session().tempo.tempo_at_tick(tick) + } + + /// Centre of the toolbar's tempo, which shows `bpm`. + fn field(&self, bpm: f64) -> Pos2 { + let text = format!("{bpm:.4}"); + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2()).center()), + _ => None, + }) + .expect("tempo not in the toolbar") + } + + fn button(&mut self, at: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }, + ]); + } + + fn type_into(&mut self, at: Pos2, text: &str) { + self.button(at, true); + self.button(at, false); + self.frames(2); + self.frame(vec![Event::Text(text.into())]); + self.frame(vec![Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]); + self.frames(2); + } +} + +#[test] +fn dragging_the_toolbar_tempo_changes_it_in_one_undo_step() { + let mut r = Rig::new(); + let at = r.field(120.0); + r.button(at, true); + for k in 1..=10 { + r.frame(vec![Event::PointerMoved(at - vec2(0.0, 6.0 * k as f32))]); + } + r.button(at - vec2(0.0, 60.0), false); + assert!(r.bpm(0) > 123.0 && r.bpm(0) < 127.0, "bpm {}", r.bpm(0)); + r.app.run("edit.undo", json!({})).unwrap(); + assert_eq!(r.bpm(0), 120.0); +} + +#[test] +fn clicking_the_toolbar_tempo_lets_you_type_it() { + let mut r = Rig::new(); + let at = r.field(120.0); + r.type_into(at, "98.5"); + assert_eq!(r.bpm(0), 98.5); +} + +#[test] +fn the_toolbar_tempo_edits_the_tempo_change_in_effect_at_the_playhead() { + let mut r = Rig::new(); + let bar5 = r.app.engine.session().tempo.tick_at_bar_beat(BarBeat { bar: 5, beat: 1, tick: 0 }); + let at = r.app.engine.session().tempo.tick_to_samples(bar5, r.app.engine.session().sample_rate); + r.app.run("event.tempo", json!({"bpm": 140.0, "at": at})).unwrap(); + r.app.run("transport.locate", json!({"at": at + 48_000})).unwrap(); + r.frames(2); + let field = r.field(140.0); + r.type_into(field, "150"); + assert_eq!((r.bpm(0), r.bpm(bar5), r.app.engine.session().tempo.tempos().len()), (120.0, 150.0, 2)); +} diff --git a/crates/ui-egui/tests/track_list.rs b/crates/ui-egui/tests/track_list.rs new file mode 100644 index 0000000..cd8f758 --- /dev/null +++ b/crates/ui-egui/tests/track_list.rs @@ -0,0 +1,92 @@ +//! Dragging rows in the Tracks list reorders the tracks (#170); a click still selects. + +use egui::{Event, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, pos2, vec2}; +use serde_json::json; +use soundcraft_model::TrackId; +use soundcraft_ui_egui::{Services, SoundApp}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, + shapes: Vec, +} + +impl Rig { + fn new() -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"count": 6})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()), shapes: Vec::new() }; + for _ in 0..4 { + r.frame(vec![]); + } + r + } + + fn frame(&mut self, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + let mut out = self.ctx.run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }); + out.textures_delta.clear(); + self.shapes = out.shapes; + } + + fn order(&self) -> Vec { + self.app.engine.session().tracks.iter().map(|t| t.id).collect() + } + + /// Centre of the Tracks list row naming the track at session index `i`. + fn row(&self, i: usize) -> Pos2 { + let name = &self.app.engine.session().tracks[i].name; + self.shapes + .iter() + .find_map(|c| match &c.shape { + Shape::Text(t) if t.galley.text() == name && t.pos.x < 150.0 => { + Some(pos2(t.pos.x + 20.0, t.galley.rect.translate(t.pos.to_vec2()).center().y)) + } + _ => None, + }) + .expect("row not in the Tracks list") + } + + fn press(&mut self, at: Pos2, pressed: bool) { + self.frame(vec![ + Event::PointerMoved(at), + Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Default::default() }, + ]); + } + + fn drag(&mut self, from: Pos2, to: Pos2) { + self.press(from, true); + for k in 1..=6 { + self.frame(vec![Event::PointerMoved(from.lerp(to, k as f32 / 6.0))]); + } + self.press(to, false); + self.frame(vec![]); + } +} + +#[test] +fn dragging_a_track_list_row_moves_the_track_to_the_gap_it_is_dropped_in() { + let mut r = Rig::new(); + let ids = r.order(); + let (from, lower_half_of_fifth) = (r.row(0), r.row(4) + vec2(0.0, 5.0)); + r.drag(from, lower_half_of_fifth); + assert_eq!(r.order(), vec![ids[1], ids[2], ids[3], ids[4], ids[0], ids[5]]); + let (from, upper_half_of_second) = (r.row(5), r.row(1) - vec2(0.0, 5.0)); + r.drag(from, upper_half_of_second); + assert_eq!(r.order(), vec![ids[1], ids[5], ids[2], ids[3], ids[4], ids[0]]); + let (from, own_lower_half) = (r.row(2), r.row(2) + vec2(0.0, 8.0)); + r.drag(from, own_lower_half); + assert_eq!(r.order(), vec![ids[1], ids[5], ids[2], ids[3], ids[4], ids[0]]); +} + +#[test] +fn clicking_a_track_list_row_still_selects_the_track() { + let mut r = Rig::new(); + let at = r.row(2); + r.press(at, true); + r.press(at, false); + assert_eq!(r.app.engine.session().edit.selected_tracks, vec![r.order()[2]]); +} diff --git a/crates/ui-egui/tests/track_name_menu.rs b/crates/ui-egui/tests/track_name_menu.rs new file mode 100644 index 0000000..e11ed78 --- /dev/null +++ b/crates/ui-egui/tests/track_name_menu.rs @@ -0,0 +1,40 @@ +//! Right-clicking a track's name opens the track menu, in the Edit and in the Mix window. + +use egui::{Event, Modifiers, PointerButton, Pos2, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn right_click(h: &mut Harness, at: Pos2) { + h.hover_at(at); + for pressed in [true, false] { + h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }); + } + h.run_steps(3); +} + +#[test] +fn right_click_on_a_track_name_opens_the_track_menu() { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &serde_json::json!({"name": "Voice"})).unwrap(); + let app = SoundApp::new(engine, None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + let row = h.state().edit_layout.rows[0].1; + right_click(&mut h, pos2(row[0] + 60.0, row[1] + 10.0)); + assert!(h.query_by_label("Make Inactive").is_some(), "the Edit window's name plate"); + + h.key_press(egui::Key::Escape); + h.state_mut().ui.window = MainWindow::Mix; + h.run_steps(4); + assert!(h.query_by_label("Make Inactive").is_none()); + let strip = h.get_by_label("Record enable").rect(); + right_click(&mut h, pos2(strip.center().x, 900.0 - 17.0)); + assert!(h.query_by_label("Make Inactive").is_some(), "the Mix strip's name plate"); +} diff --git a/crates/ui-egui/tests/tracks_scroll_bar.rs b/crates/ui-egui/tests/tracks_scroll_bar.rs new file mode 100644 index 0000000..d596cde --- /dev/null +++ b/crates/ui-egui/tests/tracks_scroll_bar.rs @@ -0,0 +1,64 @@ +use egui::{Event, Modifiers, PointerButton, Pos2, Rect, Shape, pos2, vec2}; +use egui_kittest::Harness; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, theme::Tokens}; + +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 click(h: &mut Harness<'_, SoundApp>, pos: Pos2) { + h.state_mut().synthetic.extend([ + Event::PointerMoved(pos), + Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }, + Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }, + ]); + for _ in 0..6 { + step(h); + } +} + +/// The Tracks list's first row, the leftmost "Kick" painted. +fn first_row(h: &Harness<'_, SoundApp>) -> Rect { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Text(text) if text.galley.job.text == "Kick" => Some(text.visual_bounding_rect()), + _ => None, + }) + .min_by(|a, b| a.min.x.total_cmp(&b.min.x)) + .unwrap() +} + +#[test] +fn the_tracks_gutter_pages_a_long_list() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.run("track.new", json!({"count": 60})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + for _ in 0..6 { + step(&mut h); + } + let thumb = h + .output() + .shapes + .iter() + .find_map(|shape| match &shape.shape { + Shape::Rect(rect) if rect.fill == Tokens::DARK.button_hi && rect.rect.width() == 6.0 => Some(rect.rect), + _ => None, + }) + .expect("a list longer than its space shows a thumb"); + let before = first_row(&h); + click(&mut h, pos2(thumb.center().x, thumb.max.y + 20.0)); + let after = first_row(&h); + assert!(after.min.y < before.min.y - 100.0, "a click below the thumb didn't page the list: {before:?} -> {after:?}"); +} diff --git a/crates/video/src/h264/params.rs b/crates/video/src/h264/params.rs index b99404a..583c96b 100644 --- a/crates/video/src/h264/params.rs +++ b/crates/video/src/h264/params.rs @@ -1,9 +1,14 @@ //! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists. use crate::bitstream::BitReader; -use crate::h264::error::{Result, ensure, unsupported}; +use crate::h264::error::{Error, Result, ensure, unsupported}; use crate::h264::tables::*; +/// `v + k` for a coded `*_minus*` field, or the error its range check gives. +fn plus(v: u32, k: u32, err: &str) -> Result { + v.checked_add(k).ok_or_else(|| Error::Invalid(err.to_string())) +} + /// Scaling matrices after applying fall-back rules, stored in raster order (weightScale). #[derive(Clone, Debug, PartialEq, Eq)] pub struct ScalingMatrices { @@ -150,7 +155,7 @@ pub struct HrdParameters { } fn parse_hrd(r: &mut BitReader) -> Result { - let cpb_cnt = r.read_ue()? + 1; + let cpb_cnt = plus(r.read_ue()?, 1, "cpb_cnt_minus1 out of range")?; ensure!(cpb_cnt <= 32, "cpb_cnt_minus1 out of range"); let mut h = HrdParameters { cpb_cnt, bit_rate_scale: r.read_bits(4)? as u8, cpb_size_scale: r.read_bits(4)? as u8, ..Default::default() }; for _ in 0..cpb_cnt { @@ -353,8 +358,8 @@ impl Sps { if chroma_format_idc == 3 { separate_colour_plane = r.read_flag()?; } - bit_depth_luma = r.read_ue()? + 8; - bit_depth_chroma = r.read_ue()? + 8; + bit_depth_luma = plus(r.read_ue()?, 8, "bit depth out of range")?; + bit_depth_chroma = plus(r.read_ue()?, 8, "bit depth out of range")?; ensure!(bit_depth_luma <= 14 && bit_depth_chroma <= 14, "bit depth out of range"); bypass = r.read_flag()?; seq_scaling_matrix_present = r.read_flag()?; @@ -363,7 +368,7 @@ impl Sps { scaling_lists = parse_scaling_lists(&mut r, count, None)?; } } - let log2_max_frame_num = r.read_ue()? + 4; + let log2_max_frame_num = plus(r.read_ue()?, 4, "log2_max_frame_num out of range")?; ensure!(log2_max_frame_num <= 16, "log2_max_frame_num out of range"); let pic_order_cnt_type = r.read_ue()?; ensure!(pic_order_cnt_type <= 2, "pic_order_cnt_type out of range"); @@ -373,7 +378,7 @@ impl Sps { let mut offset_for_top_to_bottom_field = 0; let mut offset_for_ref_frame = Vec::new(); if pic_order_cnt_type == 0 { - log2_max_poc_lsb = r.read_ue()? + 4; + log2_max_poc_lsb = plus(r.read_ue()?, 4, "log2_max_pic_order_cnt_lsb out of range")?; ensure!(log2_max_poc_lsb <= 16, "log2_max_pic_order_cnt_lsb out of range"); } else if pic_order_cnt_type == 1 { delta_pic_order_always_zero = r.read_flag()?; @@ -388,8 +393,8 @@ impl Sps { let max_num_ref_frames = r.read_ue()?; ensure!(max_num_ref_frames <= 16, "max_num_ref_frames out of range"); let gaps_in_frame_num_allowed = r.read_flag()?; - let pic_width_in_mbs = r.read_ue()? + 1; - let pic_height_in_map_units = r.read_ue()? + 1; + let pic_width_in_mbs = plus(r.read_ue()?, 1, "picture too large")?; + let pic_height_in_map_units = plus(r.read_ue()?, 1, "picture too large")?; ensure!(pic_width_in_mbs <= 1024 && pic_height_in_map_units <= 1024, "picture too large"); let frame_mbs_only = r.read_flag()?; let mb_adaptive_frame_field = if !frame_mbs_only { r.read_flag()? } else { false }; @@ -429,7 +434,8 @@ impl Sps { }; if let Some((l, rr, t, b)) = sps.frame_crop { let (cx, cy) = sps.crop_unit(); - ensure!((l + rr) * cx < sps.width() && (t + b) * cy < sps.height(), "frame cropping exceeds picture size"); + let fits = |a: u32, b: u32, unit: u32, size: u32| a.checked_add(b).and_then(|v| v.checked_mul(unit)).is_some_and(|v| v < size); + ensure!(fits(l, rr, cx, sps.width()) && fits(t, b, cy, sps.height()), "frame cropping exceeds picture size"); } Ok(sps) } @@ -466,7 +472,8 @@ impl Sps { None => (0, 0, self.width(), self.height()), Some((l, r, t, b)) => { let (cx, cy) = self.crop_unit(); - (l * cx, t * cy, self.width() - (l + r) * cx, self.height() - (t + b) * cy) + let cut = |a: u32, b: u32, unit: u32| a.saturating_add(b).saturating_mul(unit); + (l.saturating_mul(cx), t.saturating_mul(cy), self.width().saturating_sub(cut(l, r, cx)), self.height().saturating_sub(cut(t, b, cy))) } } } @@ -572,7 +579,7 @@ impl Pps { .ok_or_else(|| crate::h264::Error::MissingParameterSet(format!("SPS {sps_id} for PPS {id}")))?; let entropy_coding_mode = r.read_flag()?; let bottom_field_pic_order_in_frame_present = r.read_flag()?; - let num_slice_groups = r.read_ue()? + 1; + let num_slice_groups = plus(r.read_ue()?, 1, "num_slice_groups out of range")?; ensure!(num_slice_groups <= 8, "num_slice_groups out of range"); let mut slice_group_map_type = 0; if num_slice_groups > 1 { @@ -595,7 +602,7 @@ impl Pps { r.read_ue()?; } 6 => { - let n = r.read_ue()? + 1; + let n = plus(r.read_ue()?, 1, "pic_size_in_map_units_minus1 out of range")?; let bits = 32 - (num_slice_groups - 1).leading_zeros(); for _ in 0..n { r.read_bits(bits)?; @@ -604,13 +611,13 @@ impl Pps { _ => {} } } - let num_ref_idx_l0_default_active = r.read_ue()? + 1; - let num_ref_idx_l1_default_active = r.read_ue()? + 1; + let num_ref_idx_l0_default_active = plus(r.read_ue()?, 1, "num_ref_idx_default_active out of range")?; + let num_ref_idx_l1_default_active = plus(r.read_ue()?, 1, "num_ref_idx_default_active out of range")?; ensure!(num_ref_idx_l0_default_active <= 32 && num_ref_idx_l1_default_active <= 32, "num_ref_idx_default_active out of range"); let weighted_pred = r.read_flag()?; let weighted_bipred_idc = r.read_bits(2)?; - let pic_init_qp = 26 + r.read_se()?; - let pic_init_qs = 26 + r.read_se()?; + let pic_init_qp = r.read_se()?.checked_add(26).ok_or_else(|| Error::Invalid("pic_init_qp_minus26 out of range".into()))?; + let pic_init_qs = r.read_se()?.checked_add(26).ok_or_else(|| Error::Invalid("pic_init_qs_minus26 out of range".into()))?; let chroma_qp_index_offset = r.read_se()?; ensure!((-12..=12).contains(&chroma_qp_index_offset), "chroma_qp_index_offset out of range"); let deblocking_filter_control_present = r.read_flag()?; @@ -716,4 +723,190 @@ mod tests { assert_eq!(sps.pic_order_cnt_type, 2); assert_eq!(sps.crop_rect(), (0, 0, 176, 144)); } + + /// Coded SPS fields; the default is the minimal baseline SPS above. + #[derive(Clone, Copy)] + struct SpsFields { + high_profile: bool, + bit_depth_luma_minus8: u32, + log2_max_frame_num_minus4: u32, + poc_type: u32, + log2_max_poc_lsb_minus4: u32, + width_minus1: u32, + height_minus1: u32, + crop: Option<(u32, u32, u32, u32)>, + cpb_cnt_minus1: Option, + } + + impl Default for SpsFields { + fn default() -> Self { + Self { + high_profile: false, + bit_depth_luma_minus8: 0, + log2_max_frame_num_minus4: 0, + poc_type: 2, + log2_max_poc_lsb_minus4: 0, + width_minus1: 10, + height_minus1: 8, + crop: None, + cpb_cnt_minus1: None, + } + } + } + + fn sps(f: SpsFields) -> Result { + let mut w = BitWriter::new(); + w.write_bits(if f.high_profile { 100 } else { 66 }, 8); + w.write_bits(0, 8); + w.write_bits(30, 8); + w.write_ue(0); + if f.high_profile { + w.write_ue(1); // chroma_format_idc + w.write_ue(f.bit_depth_luma_minus8); + w.write_ue(0); // bit_depth_chroma_minus8 + w.write_bit(false); // qpprime_y_zero_transform_bypass + w.write_bit(false); // seq_scaling_matrix_present + } + w.write_ue(f.log2_max_frame_num_minus4); + w.write_ue(f.poc_type); + if f.poc_type == 0 { + w.write_ue(f.log2_max_poc_lsb_minus4); + } + w.write_ue(1); + w.write_bit(false); + w.write_ue(f.width_minus1); + w.write_ue(f.height_minus1); + w.write_bit(true); // frame_mbs_only + w.write_bit(true); // direct_8x8 + w.write_bit(f.crop.is_some()); + if let Some((l, r, t, b)) = f.crop { + for v in [l, r, t, b] { + w.write_ue(v); + } + } + w.write_bit(f.cpb_cnt_minus1.is_some()); // vui + if let Some(n) = f.cpb_cnt_minus1 { + for _ in 0..5 { + w.write_bit(false); // aspect ratio, overscan, video signal, chroma loc, timing + } + w.write_bit(true); // nal_hrd + w.write_ue(n); + w.write_bits(0, 8); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 20); + w.write_bit(false); // vcl_hrd + w.write_bit(false); // low_delay_hrd + w.write_bit(false); // pic_struct_present + w.write_bit(false); // bitstream_restriction + } + w.rbsp_trailing(); + Sps::parse(&w.finish()) + } + + fn invalid_sps(f: SpsFields) -> String { + match sps(f) { + Err(crate::h264::Error::Invalid(m)) => m, + other => panic!("expected an invalid SPS, got {other:?}"), + } + } + + #[test] + fn sps_fields_of_the_test_writer_parse() { + let s = sps(SpsFields { high_profile: true, poc_type: 0, crop: Some((0, 2, 0, 2)), cpb_cnt_minus1: Some(0), ..Default::default() }).unwrap(); + assert_eq!((s.bit_depth_luma, s.log2_max_poc_lsb, s.width(), s.height()), (8, 4, 176, 144)); + assert_eq!(s.crop_rect(), (0, 0, 172, 140)); + assert_eq!(s.vui.and_then(|v| v.nal_hrd).map(|h| h.cpb_cnt), Some(1)); + } + + #[test] + fn huge_bit_depth_is_an_error() { + assert_eq!(invalid_sps(SpsFields { high_profile: true, bit_depth_luma_minus8: u32::MAX, ..Default::default() }), "bit depth out of range"); + } + + #[test] + fn huge_log2_max_frame_num_is_an_error() { + assert_eq!(invalid_sps(SpsFields { log2_max_frame_num_minus4: u32::MAX, ..Default::default() }), "log2_max_frame_num out of range"); + } + + #[test] + fn huge_log2_max_poc_lsb_is_an_error() { + let f = SpsFields { poc_type: 0, log2_max_poc_lsb_minus4: u32::MAX, ..Default::default() }; + assert_eq!(invalid_sps(f), "log2_max_pic_order_cnt_lsb out of range"); + } + + #[test] + fn huge_picture_size_is_an_error() { + assert_eq!(invalid_sps(SpsFields { width_minus1: u32::MAX, ..Default::default() }), "picture too large"); + assert_eq!(invalid_sps(SpsFields { height_minus1: u32::MAX, ..Default::default() }), "picture too large"); + } + + #[test] + fn crop_offsets_that_overflow_are_an_error() { + let msg = "frame cropping exceeds picture size"; + // The sum of two offsets overflows, then the scaling by the crop unit does. + assert_eq!(invalid_sps(SpsFields { crop: Some((u32::MAX, 1, 0, 0)), ..Default::default() }), msg); + assert_eq!(invalid_sps(SpsFields { crop: Some((0, 0, 1 << 31, 0)), ..Default::default() }), msg); + } + + #[test] + fn huge_cpb_count_is_an_error() { + assert_eq!(invalid_sps(SpsFields { cpb_cnt_minus1: Some(u32::MAX), ..Default::default() }), "cpb_cnt_minus1 out of range"); + } + + /// A PPS for the SPS above; `slice_groups` is (num_slice_groups_minus1, pic_size_in_map_units_minus1) + /// coded with slice_group_map_type 6. + fn pps(slice_groups: Option<(u32, u32)>, num_ref_idx_l0_minus1: u32, pic_init_qp_minus26: i32) -> Result { + let mut w = BitWriter::new(); + w.write_ue(0); + w.write_ue(0); + w.write_bit(false); + w.write_bit(false); + match slice_groups { + Some((groups, units)) => { + w.write_ue(groups); + w.write_ue(6); + w.write_ue(units); + } + None => w.write_ue(0), + } + w.write_ue(num_ref_idx_l0_minus1); + w.write_ue(0); + w.write_bit(false); + w.write_bits(0, 2); + w.write_se(pic_init_qp_minus26); + w.write_se(0); + w.write_se(0); + w.write_bit(false); + w.write_bit(false); + w.write_bit(false); + w.rbsp_trailing(); + Pps::parse(&w.finish(), &[Some(sps(SpsFields::default()).unwrap())]) + } + + fn invalid_pps(p: Result) -> String { + match p { + Err(crate::h264::Error::Invalid(m)) => m, + other => panic!("expected an invalid PPS, got {other:?}"), + } + } + + #[test] + fn pps_fields_of_the_test_writer_parse() { + let p = pps(None, 2, -4).unwrap(); + assert_eq!((p.num_slice_groups, p.num_ref_idx_l0_default_active, p.pic_init_qp), (1, 3, 22)); + } + + #[test] + fn huge_pps_counts_are_an_error() { + assert_eq!(invalid_pps(pps(Some((u32::MAX, 0)), 0, 0)), "num_slice_groups out of range"); + assert_eq!(invalid_pps(pps(Some((1, u32::MAX)), 0, 0)), "pic_size_in_map_units_minus1 out of range"); + assert_eq!(invalid_pps(pps(None, u32::MAX, 0)), "num_ref_idx_default_active out of range"); + } + + #[test] + fn huge_pic_init_qp_is_an_error() { + assert_eq!(invalid_pps(pps(None, 0, i32::MAX)), "pic_init_qp_minus26 out of range"); + } } diff --git a/crates/video/src/isobmff/codec.rs b/crates/video/src/isobmff/codec.rs index 0edb8d5..45f08bd 100644 --- a/crates/video/src/isobmff/codec.rs +++ b/crates/video/src/isobmff/codec.rs @@ -1392,10 +1392,8 @@ impl SampleEntry { CodecConfig::Aac(c) => { b.leaf(b"esds", &esds_payload(0x40, &c.asc, c.max_bitrate, c.avg_bitrate, c.buffer_size)); } - CodecConfig::Mp3 => { - if format == *b"mp4a" { - b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); - } + CodecConfig::Mp3 if format == *b"mp4a" => { + b.leaf(b"esds", &esds_payload(0x6B, &[], 0, 0, 0)); } CodecConfig::Alac { cookie } => { let m = b.start_full(b"alac", 0, 0); diff --git a/crates/vst3-host/src/ffi.rs b/crates/vst3-host/src/ffi.rs index 341ad53..884eb52 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -1239,6 +1239,15 @@ impl Instance { v.is_finite().then(|| v.clamp(0.0, 1.0)) } + /// Tells the controller about a value the host set (`setParamNormalized`). + pub fn set_normalized(&self, id: u32, n: f64) { + let Some(c) = &self.controller else { return }; + if n.is_finite() { + // SAFETY: valid controller. + unsafe { c.setParamNormalized(id, n.clamp(0.0, 1.0)) }; + } + } + pub fn value_text(&self, id: u32, normalized: f64) -> Option { let c = self.controller.as_ref()?; let mut s: sv::String128 = [0; 128]; diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 0cfd3ab..b872dec 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -294,11 +294,13 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + // Unix only: the test points a ".vst3" at a system library through a symlink. + #[cfg(unix)] #[test] fn a_real_library_without_the_vst3_export_is_rejected() { // A system library loads fine but has no `GetPluginFactory`. // (macOS system libraries live only in the dyld shared cache, not as files.) - let lib = if cfg!(target_os = "macos") || cfg!(windows) { + let lib = if cfg!(target_os = "macos") { None } else { ["/lib/x86_64-linux-gnu/libz.so.1", "/usr/lib/libz.so.1", "/lib64/libz.so.1"].iter().map(PathBuf::from).find(|p| p.exists()) @@ -308,12 +310,9 @@ mod tests { let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); // Point a single-file ".vst3" at it via a symlink (no copying of binaries). - #[cfg(unix)] - { - std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap(); - let e = load_bundle(&d.join("z.vst3")).unwrap_err(); - assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); - } + std::os::unix::fs::symlink(&lib, d.join("z.vst3")).unwrap(); + let e = load_bundle(&d.join("z.vst3")).unwrap_err(); + assert!(e.to_string().contains("not a VST3 plugin"), "{e}"); let _ = std::fs::remove_dir_all(&d); } } diff --git a/crates/vst3-host/src/plugin.rs b/crates/vst3-host/src/plugin.rs index 254671f..ba30610 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -33,6 +33,10 @@ pub struct Vst3Plugin { values: Vec, /// Normalized values waiting for the next block (coalesced per id; bounded). pending: Vec<(u32, f64)>, + /// Host-set values the controller has not seen yet (same order as `ids`). `set_param` may + /// run on the audio thread, so it never calls the controller; `save_state`, which already + /// does, hands them over first. + unsynced: Vec, /// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`. notes: Vec, inputs: BusBuffers, @@ -76,6 +80,7 @@ impl Vst3Plugin { info, values: Vec::with_capacity(ids.len()), pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), + unsynced: vec![false; ids.len()], notes: Vec::with_capacity(EVENT_CAP), inputs: BusBuffers::new(&[], 0), outputs: BusBuffers::new(&[], 0), @@ -145,6 +150,7 @@ impl Vst3Plugin { } } self.pending.clear(); + self.unsynced.fill(false); } /// Inserts keeping the queue time-sorted; drops the event when the queue is full (never @@ -205,6 +211,9 @@ impl Plugin for Vst3Plugin { if let Some(slot) = self.values.get_mut(i) { *slot = v; } + if let Some(u) = self.unsynced.get_mut(i) { + *u = true; + } let Some(n) = self.normalized_from_plain(i, v) else { return true }; if let Some(e) = self.pending.iter_mut().find(|(pid, _)| *pid == vid) { e.1 = n; @@ -319,6 +328,17 @@ impl Plugin for Vst3Plugin { } fn save_state(&mut self) -> Option> { + // A parameter only the controller stores (no processor state) would otherwise be saved + // with the controller's stale value. + for i in 0..self.ids.len() { + if !self.unsynced.get(i).copied().unwrap_or(false) { + continue; + } + if let (Some(&id), Some(n)) = (self.ids.get(i), self.values.get(i).and_then(|&v| self.normalized_from_plain(i, v))) { + self.inst.set_normalized(id, n); + } + } + self.unsynced.fill(false); self.inst.save_state() } diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c6..a537ef6 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -52,7 +52,7 @@ fn audio_bus(bus: *mut BusInfo, dir: BusDirection, name: &str) { b.channelCount = 2; copy_c16(name, &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as u32; } unsafe fn channels<'a>(bus: &AudioBusBuffers, frames: usize) -> Vec<&'a mut [f32]> { @@ -461,7 +461,7 @@ impl IComponentTrait for Synth { b.channelCount = 16; copy_c16("Notes", &mut b.name); b.busType = BusTypes_::kMain as BusType; - b.flags = BusInfo_::BusFlags_::kDefaultActive; + b.flags = BusInfo_::BusFlags_::kDefaultActive as u32; return kResultOk; } kInvalidArgument diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7..b36e9bb 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -208,6 +208,31 @@ fn state_saves_component_and_controller_and_restores_them() { assert_eq!(q.param("7"), Some(1.0)); } +#[test] +fn host_set_controller_param_survives_state_round_trip() { + // Freq lives only in the controller (the processor ignores it), so processing never carries + // the host's value there: the state save has to. + let id = id_of("Fixture Gain"); + let mut p = soundcraft_vst3_host::create(&id).unwrap(); + p.prepare(48_000.0, 64, 2); + assert!(p.set_param("7", 1.5)); + assert!(p.set_param("12", 200.0)); + let mut p = std::thread::spawn(move || { + let mut io = vec![vec![0.5f32; 64]; 2]; + p.process(&mut io, 64); + assert!(io.iter().flatten().all(|&x| (x - 0.75).abs() < 1e-4)); + p + }) + .join() + .unwrap(); + assert!((p.param("12").unwrap() - 200.0).abs() < 0.01); + let blob = p.save_state().expect("state"); + let mut q = soundcraft_vst3_host::create(&id).unwrap(); + assert!(q.load_state(&blob)); + assert!((q.param("7").unwrap() - 1.5).abs() < 1e-4, "{:?}", q.param("7")); + assert!((q.param("12").unwrap() - 200.0).abs() < 0.01, "{:?}", q.param("12")); +} + #[test] fn editor_view_is_probed_and_edits_come_back_as_plain_values() { let id = id_of("Fixture Gain"); diff --git a/crates/vst3-host/tests/module_exit_shutdown.rs b/crates/vst3-host/tests/module_exit_shutdown.rs index cc337c9..274823a 100644 --- a/crates/vst3-host/tests/module_exit_shutdown.rs +++ b/crates/vst3-host/tests/module_exit_shutdown.rs @@ -38,7 +38,13 @@ fn main() { 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}"); + if cfg!(windows) { + // Windows runs the executable's exit handlers before a DLL's own, so the fixture's late + // state is still there when the host's exit handler runs its module exit. + assert!(too_late.status.success(), "the exit handler alone: {:?}\n{err}", too_late.status); + } else { + assert_eq!(too_late.status.code(), Some(3), "the exit handler alone comes too late: {err}"); + assert!(err.contains("module exit after the process exit tore down state"), "{err}"); + } println!("module_exit_shutdown: ok"); } diff --git a/docs/mcp.md b/docs/mcp.md index 7102a23..d31712b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6,6 +6,7 @@ Claude and other agents can make music with it. ```sh soundcraft-cli mcp # headless engine (no window, no audio device) soundcraft-cli mcp --demo # headless, starting from the demo session +soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # headless, confined to folders soundcraft --control 7801 & # the desktop app… soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks ``` @@ -16,6 +17,73 @@ Claude Code: claude mcp add soundcraft -- soundcraft-cli mcp --demo ``` +## Confining file access + +```sh +soundcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out +``` + +```json +{"mcpServers": {"soundcraft": {"command": "soundcraft-cli", + "args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}} +``` + +The two flags (VectorCraft and the other Craft apps take the same ones, so one client config +works across the suite) keep the agent to the folders you give it: + +- Every file a command reads must lie inside the **read root**: `open_session`, `session.open`, + `import_audio` and `file.import_audio`, `file.import_midi`, `file.import_video`, + `file.import_clip_groups`, `file.import_session_data`, `track.load_preset` and + `mix.sampler_load`. The media a session file names is read too, so an opened session whose + audio paths lead outside the read root loads without that audio and lists it under `missing`. +- Every file a command writes must lie inside the **write root**: `save_session`, `session.save`, + `session.save_as`, `session.save_copy`, `session.save_template`, `bounce_mix` and + `file.bounce_mix`, `file.bounce_stems`, `file.export_clips`, `file.export_midi`, + `file.export_session_text`, `file.export_selected_tracks_as_session`, `file.export_sibelius`, + `file.send_to_sibelius`, `file.print_score`, `file.export_clip_groups` and + `track.save_preset`, together with what a session save writes beside the session file + (`Audio Files/`, `Backups/`) and its temporary file. A score command given no `path` writes to + the temp folder, which is outside the write root unless you made it the root, so it is refused. +- Read and write access are separate. Give the same folder twice for both; a root left out grants + none of its access (with only `--automation-read-root`, nothing can be written to disk). Without + either flag nothing changes. +- A path is made absolute against the server's working directory, then its links are followed: a + file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet, + its nearest existing folder's links are followed and the rest must be plain names, so `..` below a + folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would + create its target). The result is compared with the root by whole folder names: `/work2/x` is not + inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like + drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused. +- A refused path is an ordinary tool error the agent can read and correct, for example + `automation path rejected: outside the write root /work/project/out: /etc/x.wav` or + `automation filesystem access is not granted: write authority is absent: /work/project/out/x.wav`. + A refused save path is reported as refused, never as "already exists". +- The roots must be existing folders; a missing one stops the server with an error. Each flag is + given once (`--flag ` or `--flag=`), and a misspelled `--automation-...` flag is an + error rather than an unconfined server. + +The flags confine the in-process server, so `--connect` together with them is an error: the server +would otherwise talk to an app that isn't confined. SoundCraft's desktop app has no equivalent flags +yet, so a running app (`soundcraft --control`) is not confined. `--demo` is the operator's own +choice and is built before the roots apply. + +Playback and bounce need no check of their own. SoundCraft decodes audio into the session when it +enters it (import, open, sampler load), so rendering reads nothing from disk afterwards. Movies are +the exception: a session only references them by path and the desktop app decodes pictures from +that file, but the headless server never does, and the movie's path is checked when it is imported +and when a session naming it is opened. + +What the roots do not cover: commands that take no path still read and change the open session, +and `inspect_session` returns it. Plug-in discovery scans the system's plug-in folders and loads +plug-in binaries, which are fixed locations and native code, not agent paths. Recording is only +reachable in the desktop app. Anything that reads or writes a path goes through one place in the +engine (`io::read_file`, `io::write_file` and the helpers beside them in `crates/engine/src/io.rs`); +a test keeps the rest of the engine from touching the file system on its own. + +The checks hold for every thread of the server process. A path is checked, then opened: another +program that swaps a link in between can still win that race, so the roots keep an agent to the +files it was given rather than guarding against other software on the computer. + ## Tools | Tool | Purpose | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 77d7be9..dfdd89a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -42,7 +42,7 @@ at 0.5–1.5 h each, features at about 200–300 tested lines per agent-hour, fr 15–25, onboarding and user guide 15–25, save fixes 5–10. The previous figure in ROADMAP.md (2026-10-07: "~65 % overall feature parity", "110–150 h to 100 %") -measured feature *presence*. This pass measures readiness, and found new evidence that pulls the +measured feature *presence*. This pass measures readiness, and finds new evidence that pulls the numbers down: 37 open GitHub issues from the first public week (about 25 of them correctness bugs in features counted as done), no MIDI hardware input or output at all, no audio device or buffer size selection, no localization, and a macOS release that cannot load signed third-party VST3s @@ -83,7 +83,7 @@ post-production, tracking) can switch. "Ready" is depth, correctness and fidelit | UI/UX fidelity | 10 % | **~55 %** | estimated | 60–100 | [ui-parity.md](ui-parity.md) | Edit/Mix windows, every menu, floating windows; open UX bugs #3 #5 #6 #7 #140 #144; shortcuts only recently right on Windows/Linux (#15, #41); generic plugin UIs | | File formats | 10 % | **~40 %** | estimated | 70–120 | [file-format-parity.md](file-format-parity.md) | WAV/BWF/RF64/AIFF/FLAC read+write, compressed formats read; no `.ptx`, AAF, OMF, ADM BWF, MP3/AAC export, video export | | Hardware | 10 % | **~12 %** | estimated | 110–180 | [hardware-parity.md](hardware-parity.md) | Default output and default input device only, fixed 512-sample block, no ASIO, no MIDI devices, no control surfaces, no sync | -| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), X11 llvmpipe failure (#107) | +| Stability | 10 % | **~45 %** | estimated | 50–80 | [gaps.md](gaps.md) | Never-crash rules and proptests (random edits, mutated sessions, hostile params); but ~25 open correctness bugs, a launch failure (#142), an X11 llvmpipe failure (#107) | | Performance | 5 % | **~35 %** | estimated (unmeasured) | 40–70 | [hardware-parity.md](hardware-parity.md) | Parallel strip rendering and PDC exist; whole files decoded into RAM, recording into RAM (#84 open), no track-count or latency benchmarks | | Ecosystem / plugins | 4 % | **~30 %** | estimated | in Features + Hardware | [plugin-parity.md](plugin-parity.md) | CLAP/VST3/AU hosting; no AAX (Pro Tools' only format), no ARA, no sidechain, no control-surface ecosystem, no content library | | Platforms | 3 % | **~65 %** | estimated | 20–40 | [ROADMAP.md](../ROADMAP.md) | Builds for macOS, Windows x64/x86/arm64, Linux, FreeBSD, Web (Pro Tools: macOS, Windows), but only macOS is exercised at runtime; Web lacks file pickers (#63, #64) |