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..80de7e1 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 { @@ -150,6 +151,9 @@ impl WebShell { } fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) { + if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) { + return; + } let e = &mut self.app.engine; let at = e.session().edit.selection.start.max(0); let result = match purpose { diff --git a/apps/soundcraft/src/control_server.rs b/apps/soundcraft/src/control_server.rs index 51758b9..abff7bb 100644 --- a/apps/soundcraft/src/control_server.rs +++ b/apps/soundcraft/src/control_server.rs @@ -1,13 +1,28 @@ //! Loopback JSON-lines control server: one request per line, one reply per line. //! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps. +//! +//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without +//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]). use serde_json::{Value, json}; use soundcraft_ui_egui::ControlRequest; -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpListener, TcpStream}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::mpsc::{Receiver, Sender, channel}; use std::time::Duration; +/// Longest accepted request line (bytes, without the newline). The largest payload a request +/// carries is a plugin state for `mix.insert_state`: base64 of at most `b64::MAX_DECODED_BYTES` +/// (64 MiB, so about 85 MiB of text). Room for that and 1 MiB of JSON around it; replies +/// (screenshots) aren't bounded by this. +const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4 + (1 << 20); +// With MAX_CONNECTIONS, at most 1.5 GiB of request lines are buffered at once. +const _: () = assert!(MAX_LINE <= 96 << 20); +/// Connections served at once; further ones get an error line and are closed. +const MAX_CONNECTIONS: usize = 16; + pub fn start(port: u16, ctx: egui::Context) -> Receiver { let (tx, rx) = channel::(); let listener = match TcpListener::bind(("127.0.0.1", port)) { @@ -20,28 +35,85 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver { let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port); log::info!("control server listening on 127.0.0.1:{actual}"); println!("SOUNDCRAFT_CONTROL_PORT={actual}"); - let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || { - for stream in listener.incoming().flatten() { - let tx = tx.clone(); - let ctx = ctx.clone(); - let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx)); - } - }); + let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx)); if let Err(e) = spawned { log::error!("control thread failed: {e}"); } rx } +/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when +/// its thread can't be started, or panics). +struct Slot(Arc); + +impl Drop for Slot { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +fn accept_loop(listener: TcpListener, tx: Sender, ctx: egui::Context) { + let open = Arc::new(AtomicUsize::new(0)); + for mut stream in listener.incoming().flatten() { + if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS { + open.fetch_sub(1, Ordering::SeqCst); + let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"})); + continue; + } + let slot = Slot(Arc::clone(&open)); + let tx = tx.clone(); + let ctx = ctx.clone(); + let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || { + let _slot = slot; + serve(stream, tx, ctx); + }); + } +} + +/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped). +/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails. +fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec, max: usize) -> Result { + buf.clear(); + // `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`. + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?; + if n == 0 { + return Ok(false); + } + if buf.last() == Some(&b'\n') { + buf.pop(); + if buf.last() == Some(&b'\r') { + buf.pop(); + } + return Ok(true); + } + if buf.len() > max { + return Err(format!("request line longer than {max} bytes; closing the connection")); + } + // Last line without a newline, then end of stream. + Ok(true) +} + fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { let Ok(read) = stream.try_clone() else { return }; let mut out = stream; - for line in BufReader::new(read).lines() { - let Ok(line) = line else { break }; + let mut reader = BufReader::new(read); + let mut buf = Vec::new(); + loop { + match read_bounded_line(&mut reader, &mut buf, MAX_LINE) { + Ok(true) => {} + Ok(false) => break, + Err(e) => { + let _ = writeln!(out, "{}", json!({"ok": false, "error": e})); + break; + } + } + // Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did. + let Ok(line) = std::str::from_utf8(&buf) else { break }; if line.trim().is_empty() { continue; } - let reply = match serde_json::from_str::(&line) { + let reply = match serde_json::from_str::(line) { Ok(msg) => { let id = msg.get("id").cloned().unwrap_or(Value::Null); let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string(); @@ -64,3 +136,80 @@ fn serve(stream: TcpStream, tx: Sender, ctx: egui::Context) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lines_are_read_up_to_the_limit() { + let mut buf = Vec::new(); + let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc"; + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(buf, b"bb", "CRLF is stripped"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true)); + assert_eq!(buf, b"cc", "a last line without a newline is still read"); + assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream"); + } + + #[test] + fn an_overlong_line_is_refused_without_reading_the_rest() { + let mut buf = Vec::new(); + let long = [b'x'; 100]; + let mut input: &[u8] = &long; + assert!(read_bounded_line(&mut input, &mut buf, 10).is_err()); + assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len()); + assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread"); + } + + #[test] + fn the_largest_plugin_state_fits_in_a_line() { + let state = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4; + let request = json!({"id": 1, "method": "engine.execute", "params": {"command": "mix.insert_state", "params": {"track": "Audio 1", "slot": 0, "state": ""}}}); + assert!(state + request.to_string().len() <= MAX_LINE); + } + + /// The control server on a loopback port, with a stand-in UI that echoes each method. + fn serve_echo() -> std::net::SocketAddr { + let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = channel::(); + std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default())); + std::thread::spawn(move || { + for req in rx { + let _ = req.reply.send(json!({"ok": true, "method": req.method})); + } + }); + addr + } + + #[test] + fn requests_are_answered_with_their_ids() { + let addr = serve_echo(); + let mut c = TcpStream::connect(addr).unwrap(); + c.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap(); + let mut r = BufReader::new(c); + for (id, method) in [(3, "ping"), (4, "pong")] { + let mut line = String::new(); + r.read_line(&mut line).unwrap(); + let v: Value = serde_json::from_str(&line).unwrap(); + assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}"); + } + } + + #[test] + fn extra_connections_are_refused() { + let addr = serve_echo(); + // Idle connections are accepted in order and held open. + let held: Vec = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect(); + let mut extra = TcpStream::connect(addr).unwrap(); + extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap(); + let mut line = String::new(); + BufReader::new(&mut extra).read_line(&mut line).unwrap(); + assert!(line.contains("too many control connections"), "{line}"); + drop(held); + } +} diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index f64ef8a..1d23325 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -44,7 +44,7 @@ impl eframe::App for App { if let Some(menu) = &mut self.2 { menu.process(&mut self.0); } - self.0.logic(ctx); + self.0.guarded(|app| app.logic(ctx)); #[cfg(target_os = "macos")] if let Some(menu) = &mut self.2 { menu.sync(&self.0); @@ -89,9 +89,10 @@ impl eframe::App for App { self.0.raw_input_hook(raw); } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { - self.0.ui(ui); + self.0.guarded(|app| app.ui(ui)); } fn on_exit(&mut self) { + self.0.close_autosave(); save_prefs(&self.0.ui); if let Some(logger) = self.1 { logger.report_audio_thread(); @@ -166,14 +167,20 @@ 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()) })), - pick_save: Some(Box::new(|_purpose: &str, default: &str| { + pick_save: Some(Box::new(|purpose: &str, default: &str| { let path = std::path::Path::new(default); - let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]); + let mut d = match purpose { + "file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]), + _ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]), + }; + if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { + d = d.set_directory(dir); + } if let Some(n) = path.file_name().and_then(|n| n.to_str()) { d = d.set_file_name(n); } @@ -262,6 +269,7 @@ fn main() -> eframe::Result { let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) }; let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); + app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default(); app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); app.extra.ui_scale = prefs.ui_scale; app.ui = prefs; 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/au-host/src/ffi.rs b/crates/au-host/src/ffi.rs index a115244..47698a5 100644 --- a/crates/au-host/src/ffi.rs +++ b/crates/au-host/src/ffi.rs @@ -2,11 +2,14 @@ //! thin safe wrapper over one Audio Unit instance. //! //! Every pointer handed to the Audio Unit points into memory this module owns for at least as -//! long as the unit can use it: the input state the render callback reads is a leaked `Box` -//! freed only after the unit is disposed, and output buffers are re-pointed before each render. +//! long as the unit can use it: the input state the render callback reads and the host time the +//! host callbacks read are leaked `Box`es freed only after the unit is disposed, and output buffers +//! are re-pointed before each render. +use soundcraft_dsp::Transport; use std::ffi::{CStr, c_char, c_void}; use std::ptr; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering}; type OSStatus = i32; type AudioComponent = *mut c_void; @@ -40,6 +43,7 @@ const PROP_MAX_FRAMES: u32 = 14; const PROP_PARAMETER_VALUE_STRINGS: u32 = 16; const PROP_TAIL_TIME: u32 = 20; const PROP_SET_RENDER_CALLBACK: u32 = 23; +const PROP_HOST_CALLBACKS: u32 = 27; const SCOPE_GLOBAL: u32 = 0; const SCOPE_INPUT: u32 = 1; @@ -140,6 +144,153 @@ struct AudioBufferListHeader { first: AudioBuffer, } +type BeatAndTempoProc = unsafe extern "C" fn(*mut c_void, *mut f64, *mut f64) -> OSStatus; +type MusicalTimeProc = unsafe extern "C" fn(*mut c_void, *mut u32, *mut f32, *mut u32, *mut f64) -> OSStatus; +type TransportStateProc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus; +type TransportState2Proc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus; + +#[repr(C)] +struct HostCallbackInfo { + host_user_data: *mut c_void, + beat_and_tempo: Option, + musical_time_location: Option, + transport_state: Option, + transport_state2: Option, +} + +/// What the host callbacks report: the transport of the block being rendered. Atomics, because a +/// unit may also ask from another thread. +struct HostTime { + playing: AtomicBool, + recording: AtomicBool, + changed: AtomicBool, + samples: AtomicI64, + tempo: AtomicU64, + quarters: AtomicU64, + bar_start: AtomicU64, + numerator: AtomicU32, + denominator: AtomicU32, + sample_rate: AtomicU64, +} + +impl HostTime { + fn new() -> HostTime { + HostTime { + playing: AtomicBool::new(false), + recording: AtomicBool::new(false), + changed: AtomicBool::new(false), + samples: AtomicI64::new(0), + tempo: AtomicU64::new(120f64.to_bits()), + quarters: AtomicU64::new(0), + bar_start: AtomicU64::new(0), + numerator: AtomicU32::new(4), + denominator: AtomicU32::new(4), + sample_rate: AtomicU64::new(48_000f64.to_bits()), + } + } + + fn set(&self, t: &Transport) { + if self.playing.swap(t.playing, Ordering::Relaxed) != t.playing { + self.changed.store(true, Ordering::Relaxed); + } + self.recording.store(t.recording, Ordering::Relaxed); + self.samples.store(t.samples, Ordering::Relaxed); + self.tempo.store(t.tempo.to_bits(), Ordering::Relaxed); + self.quarters.store(t.quarters.to_bits(), Ordering::Relaxed); + self.bar_start.store(t.bar_start.to_bits(), Ordering::Relaxed); + self.numerator.store(t.numerator, Ordering::Relaxed); + self.denominator.store(t.denominator, Ordering::Relaxed); + } +} + +fn load_f64(a: &AtomicU64) -> f64 { + f64::from_bits(a.load(Ordering::Relaxed)) +} + +/// Writes through an out-pointer the unit may leave null. +/// +/// # Safety +/// `p` is null or valid for writing a `T`. +unsafe fn put(p: *mut T, v: T) { + // SAFETY: per the contract above. + if let Some(p) = unsafe { p.as_mut() } { + *p = v; + } +} + +unsafe extern "C" fn beat_and_tempo(user: *mut c_void, beat: *mut f64, tempo: *mut f64) -> OSStatus { + // SAFETY: `user` is the `HostTime` leaked by `Instance::new`, alive until after the unit is + // disposed. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(beat, load_f64(&t.quarters)); + put(tempo, load_f64(&t.tempo)); + } + 0 +} + +unsafe extern "C" fn musical_time( + user: *mut c_void, + to_next_beat: *mut u32, + numerator: *mut f32, + denominator: *mut u32, + downbeat: *mut f64, +) -> OSStatus { + // SAFETY: as in `beat_and_tempo`. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + let q = load_f64(&t.quarters); + let per_quarter = load_f64(&t.sample_rate) * 60.0 / load_f64(&t.tempo).max(1.0); + let next = ((q.ceil() - q) * per_quarter).round(); + let next = if next.is_finite() { next.clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(to_next_beat, next); + put(numerator, t.numerator.load(Ordering::Relaxed) as f32); + put(denominator, t.denominator.load(Ordering::Relaxed)); + put(downbeat, load_f64(&t.bar_start)); + } + 0 +} + +unsafe extern "C" fn transport_state2( + user: *mut c_void, + playing: *mut u8, + recording: *mut u8, + changed: *mut u8, + sample: *mut f64, + cycling: *mut u8, + cycle_start: *mut f64, + cycle_end: *mut f64, +) -> OSStatus { + // SAFETY: as in `beat_and_tempo`. + let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 }; + // SAFETY: the unit's out-pointers (or null). + unsafe { + put(playing, u8::from(t.playing.load(Ordering::Relaxed))); + put(recording, u8::from(t.recording.load(Ordering::Relaxed))); + put(changed, u8::from(t.changed.swap(false, Ordering::Relaxed))); + put(sample, t.samples.load(Ordering::Relaxed) as f64); + put(cycling, 0); + put(cycle_start, 0.0); + put(cycle_end, 0.0); + } + 0 +} + +unsafe extern "C" fn transport_state( + user: *mut c_void, + playing: *mut u8, + changed: *mut u8, + sample: *mut f64, + cycling: *mut u8, + cycle_start: *mut f64, + cycle_end: *mut f64, +) -> OSStatus { + // SAFETY: the same pointers as `transport_state2`, without the recording flag. + unsafe { transport_state2(user, playing, ptr::null_mut(), changed, sample, cycling, cycle_start, cycle_end) } +} + type AuRenderCallback = unsafe extern "C" fn( ref_con: *mut c_void, action_flags: *mut u32, @@ -361,6 +512,8 @@ pub struct Instance { unit: AudioUnit, /// Leaked `Box` read by `input_callback`; freed in `Drop` after disposal. input: *mut InputState, + /// Leaked `Box` read by the host callbacks; freed in `Drop` after disposal. + time: *mut HostTime, outputs: Vec>, abl: AudioBufferList, has_input: bool, @@ -369,11 +522,13 @@ pub struct Instance { max_frames: usize, sample_rate: f64, sample_time: f64, + link: std::sync::Arc, } // SAFETY: an Audio Unit instance may be used from any thread as long as calls are not // concurrent; `Instance` is only reachable through `&mut`/`&` of its owner, which the `Plugin` -// contract keeps on one thread at a time. The input state is owned exclusively by it. +// contract keeps on one thread at a time. The input state and host time are owned exclusively by +// it (the host time is all atomics). unsafe impl Send for Instance {} impl Instance { @@ -398,19 +553,42 @@ impl Instance { return Err(format!("AudioComponentInstanceNew failed ({st})")); } let input = Box::into_raw(Box::new(InputState { bufs: Vec::new(), frames: 0 })); + let time = Box::into_raw(Box::new(HostTime::new())); let empty = AudioBuffer { number_channels: 1, data_byte_size: 0, data: ptr::null_mut() }; - Ok(Instance { + let inst = Instance { unit, input, + time, outputs: Vec::new(), abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] }, has_input, + link: std::sync::Arc::new(StateLink(std::sync::Mutex::new(LinkedUnit(Some(unit))))), initialized: false, channels: 0, max_frames: 0, sample_rate: 48_000.0, sample_time: 0.0, - }) + }; + let callbacks = HostCallbackInfo { + host_user_data: time.cast(), + beat_and_tempo: Some(beat_and_tempo), + musical_time_location: Some(musical_time), + transport_state: Some(transport_state), + transport_state2: Some(transport_state2), + }; + // Units that never ask for host time may refuse it. + let _ = inst.set_property(PROP_HOST_CALLBACKS, SCOPE_GLOBAL, 0, &callbacks); + Ok(inst) + } + + fn time(&self) -> &HostTime { + // SAFETY: `time` is the `HostTime` leaked in `new`, freed only in `Drop`. + unsafe { &*self.time } + } + + /// The transport the host callbacks report during the next renders. + pub fn set_transport(&self, t: &Transport) { + self.time().set(t); } fn set_property(&self, id: u32, scope: u32, element: u32, value: &T) -> OSStatus { @@ -490,6 +668,7 @@ impl Instance { self.initialized = true; self.channels = ch; self.sample_rate = sample_rate; + self.time().sample_rate.store(sample_rate.to_bits(), Ordering::Relaxed); self.outputs = (0..ch).map(|_| vec![0.0; mf]).collect(); // SAFETY: the callback only runs inside `AudioUnitRender`, which is not running now // (we hold `&mut self`), so this exclusive access does not alias. @@ -678,35 +857,12 @@ impl Instance { out } - /// The unit's `ClassInfo` as a binary property list. - pub fn save_state(&self) -> Option> { - let mut plist: CFTypeRef = ptr::null(); - let mut size = std::mem::size_of::() as u32; - // SAFETY: the property is a `CFPropertyListRef` returned +1; `plist` is a local pointer. - let st = unsafe { AudioUnitGetProperty(self.unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) }; - if st != 0 || plist.is_null() { - release(plist); - return None; - } - let mut err: CFTypeRef = ptr::null(); - // SAFETY: `plist` is a live property list; `err` is a local out pointer. - let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) }; - release(err); - release(plist); - if data.is_null() { - return None; - } - // SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length. - let out = unsafe { - let len = CFDataGetLength(data); - let p = CFDataGetBytePtr(data); - if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) } - }; - release(data); - out + /// The link for state handles. + pub fn state_link(&self) -> std::sync::Arc { + std::sync::Arc::clone(&self.link) } - /// Restores a blob from [`Instance::save_state`] (hostile input: parsed by CoreFoundation, + /// Restores a blob from [`StateLink::save_state`] (hostile input: parsed by CoreFoundation, /// must be a dictionary, and the unit validates it). pub fn load_state(&self, bytes: &[u8]) -> bool { if bytes.is_empty() || bytes.len() > MAX_STATE { @@ -733,16 +889,71 @@ impl Instance { } } +struct LinkedUnit(Option); + +// SAFETY: the pointer is only used under the link's mutex, while the instance keeps the unit alive. +unsafe impl Send for LinkedUnit {} + +/// The link between an instance and its state handles: the unit's state is read on the main +/// thread while the instance renders on the audio thread (hosts make property calls from other +/// threads during rendering). The instance clears the link under its lock before it disposes of +/// the unit, so a handle never reads a dead unit. +pub(crate) struct StateLink(std::sync::Mutex); + +impl StateLink { + fn lock(&self) -> std::sync::MutexGuard<'_, LinkedUnit> { + self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + } + + /// The unit's `ClassInfo` as a binary property list (`None` once the instance is gone). + pub fn save_state(&self) -> Option> { + let g = self.lock(); + let unit = g.0?; + let mut plist: CFTypeRef = ptr::null(); + let mut size = std::mem::size_of::() as u32; + // SAFETY: the unit is live while the link holds it (see the type); the property is a + // `CFPropertyListRef` returned +1; `plist` is a local pointer. + let st = unsafe { AudioUnitGetProperty(unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) }; + if st != 0 || plist.is_null() { + release(plist); + return None; + } + let mut err: CFTypeRef = ptr::null(); + // SAFETY: `plist` is a live property list; `err` is a local out pointer. + let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) }; + release(err); + release(plist); + if data.is_null() { + return None; + } + // SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length. + let out = unsafe { + let len = CFDataGetLength(data); + let p = CFDataGetBytePtr(data); + if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) } + }; + release(data); + out + } + + fn kill(&self) { + self.lock().0 = None; + } +} + impl Drop for Instance { fn drop(&mut self) { + self.link.kill(); // SAFETY: the unit is live (disposed only here); after disposal nothing can call the - // input callback, so the leaked input state can be freed. + // input callback or the host callbacks, so the leaked input state and host time can be + // freed. unsafe { if self.initialized { AudioUnitUninitialize(self.unit); } AudioComponentInstanceDispose(self.unit); drop(Box::from_raw(self.input)); + drop(Box::from_raw(self.time)); } } } @@ -751,6 +962,31 @@ impl Drop for Instance { mod tests { use super::*; + #[test] + fn host_callbacks_report_the_transport() { + let time = HostTime::new(); + time.set(&Transport { playing: true, tempo: 120.0, numerator: 3, denominator: 4, samples: 48_000, quarters: 2.25, ..Transport::default() }); + let user = ptr::from_ref(&time).cast_mut().cast::(); + let (mut beat, mut tempo) = (0.0, 0.0); + // SAFETY: `user` points at a live `HostTime`; the out-pointers are locals. + assert_eq!(unsafe { beat_and_tempo(user, &mut beat, &mut tempo) }, 0); + assert_eq!((beat, tempo), (2.25, 120.0)); + let (mut next, mut num, mut den, mut down) = (0u32, 0f32, 0u32, -1.0); + // SAFETY: as above. + assert_eq!(unsafe { musical_time(user, &mut next, &mut num, &mut den, &mut down) }, 0); + assert_eq!((next, num, den, down), (18_000, 3.0, 4, 0.0)); + let (mut playing, mut recording, mut changed, mut sample) = (0u8, 1u8, 0u8, 0.0); + let (no_flag, no_beat) = (ptr::null_mut::(), ptr::null_mut::()); + // SAFETY: as above; the unit may pass null for what it doesn't want. + let st = unsafe { transport_state2(user, &mut playing, &mut recording, &mut changed, &mut sample, no_flag, no_beat, no_beat) }; + assert_eq!((st, playing, recording, changed, sample), (0, 1, 0, 1, 48_000.0)); + // SAFETY: as above. + unsafe { transport_state(user, &mut playing, &mut changed, no_beat, no_flag, no_beat, no_beat) }; + assert_eq!(changed, 0, "a change is reported once"); + // SAFETY: a null user pointer is refused before anything is read. + assert_eq!(unsafe { beat_and_tempo(ptr::null_mut(), &mut beat, &mut tempo) }, -50); + } + #[test] fn struct_layouts_match_the_c_headers() { assert_eq!(std::mem::size_of::(), 20); @@ -761,6 +997,7 @@ mod tests { assert_eq!(std::mem::offset_of!(AudioBufferList, buffers), 8); assert_eq!(std::mem::offset_of!(AudioBufferListHeader, first), 8); assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::size_of::(), 40); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, unit_name), 56); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, cf_name_string), 72); assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, flags), 96); diff --git a/crates/au-host/src/lib.rs b/crates/au-host/src/lib.rs index 7d07e15..39172c0 100644 --- a/crates/au-host/src/lib.rs +++ b/crates/au-host/src/lib.rs @@ -23,8 +23,10 @@ //! (`kAudioUnitProperty_ClassInfo`) as a binary plist; [`Plugin::load_state`] parses it back and //! sets it, then re-reads the parameter values. //! -//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, -//! parameters outside the global scope, and host callbacks (tempo, transport). +//! Host callbacks report the session's tempo, meter, position and play state. +//! +//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, and +//! parameters outside the global scope. //! //! Off macOS (and on `wasm32`) the crate compiles to stubs: scans are empty and nothing can be //! created. diff --git a/crates/au-host/src/plugin.rs b/crates/au-host/src/plugin.rs index c76aab0..cc61fbf 100644 --- a/crates/au-host/src/plugin.rs +++ b/crates/au-host/src/plugin.rs @@ -1,7 +1,7 @@ //! `AuPlugin`: a loaded Audio Unit behind the safe `soundcraft_dsp::Plugin` trait. use crate::ffi::Instance; -use soundcraft_dsp::{Plugin, PluginInfo}; +use soundcraft_dsp::{Plugin, PluginInfo, Transport}; /// Most MIDI events queued between two blocks (extra events are dropped). const EVENT_CAP: usize = 1024; @@ -170,13 +170,12 @@ impl Plugin for AuPlugin { if ch == 1 && au_ch >= 2 { if let (Some(l), Some(r)) = (self.inst.output(0, n), self.inst.output(1, n)) { for ((d, a), b) in dst.iter_mut().zip(l).zip(r) { - let v = 0.5 * (a + b); - *d = if v.is_finite() { v } else { 0.0 }; + *d = 0.5 * (a + b); } } } else if let Some(src) = self.inst.output(c.min(au_ch.saturating_sub(1)), n) { for (d, &s) in dst.iter_mut().zip(src) { - *d = if s.is_finite() { s } else { 0.0 }; + *d = s; } } } @@ -200,6 +199,10 @@ impl Plugin for AuPlugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.inst.set_transport(transport); + } + fn tail_samples(&self) -> usize { self.tail } @@ -227,6 +230,13 @@ impl Plugin for AuPlugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let [status, data1, data2] = msg; + if status & 0xF0 >= 0xA0 && status & 0xF0 < 0xF0 { + self.push_event(MidiEvent { time: u32::try_from(offset).unwrap_or(u32::MAX), status, data1: data1 & 0x7F, data2: data2 & 0x7F }); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { @@ -237,10 +247,6 @@ impl Plugin for AuPlugin { self.push_event(MidiEvent { time: 0, status: 0xB0, data1: 123, data2: 0 }); } - fn save_state(&mut self) -> Option> { - self.inst.save_state() - } - fn load_state(&mut self, data: &[u8]) -> bool { let ok = self.inst.load_state(data); if ok { @@ -248,4 +254,9 @@ impl Plugin for AuPlugin { } ok } + + fn state_reader(&mut self) -> Option> { + let link = self.inst.state_link(); + Some(Box::new(move || link.save_state())) + } } diff --git a/crates/au-host/tests/system_units.rs b/crates/au-host/tests/system_units.rs index 2e24675..8f4e692 100644 --- a/crates/au-host/tests/system_units.rs +++ b/crates/au-host/tests/system_units.rs @@ -100,6 +100,35 @@ fn lowpass_attenuates_highs() { assert!(run_tone(p.as_mut(), 100.0).is_finite()); } +#[test] +fn state_reader_reads_while_another_thread_renders() { + if !available(LOWPASS) { + return; + } + let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap(); + let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap(); + let mut p = soundcraft_au_host::create(LOWPASS).unwrap(); + p.prepare(48_000.0, 512, 2); + assert!(p.set_param(cutoff.id, 500.0)); + let mut reader = p.state_reader().expect("every unit has a ClassInfo"); + let audio = std::thread::spawn(move || { + for _ in 0..4 { + run_tone(p.as_mut(), 100.0); + } + p + }); + while !audio.is_finished() { + assert!(reader.save_state().is_some()); + } + let p = audio.join().unwrap(); + let state = reader.save_state().expect("ClassInfo"); + drop(p); + let mut q = soundcraft_au_host::create(LOWPASS).unwrap(); + assert_eq!(reader.save_state(), None); + assert!(q.load_state(&state)); + assert!((q.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", q.param(cutoff.id)); +} + #[test] fn delay_runs_mono_stereo_and_odd_blocks() { if !available(DELAY) { @@ -167,4 +196,23 @@ fn dls_synth_plays_a_note() { assert!(e > 1e-3, "{e}"); p.note_off(0, 64); p.reset(); + // Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating + // two DLS instances at once on two threads crashes inside the unit.) + p.prepare(48_000.0, 512, 2); + let loudest = |p: &mut Box, volume: u8| { + p.midi(0, [0xB0, 7, volume]); + p.note_on(0, 60, 110); + let mut energy = 0.0f32; + for _ in 0..20 { + let mut io = vec![vec![0.0f32; 512]; 2]; + p.process(&mut io, 512); + energy = energy.max(rms(&io[0])); + } + p.note_off(0, 60); + p.reset(); + energy + }; + let full = loudest(&mut p, 127); + let muted = loudest(&mut p, 0); + assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}"); } diff --git a/crates/audio-io/src/aiff.rs b/crates/audio-io/src/aiff.rs index dfa7623..d8462c0 100644 --- a/crates/audio-io/src/aiff.rs +++ b/crates/audio-io/src/aiff.rs @@ -1,8 +1,9 @@ //! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the //! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate). -use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer}; +use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved}; use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat}; +use std::io::{Cursor, Seek, SeekFrom, Write}; /// AIFF-C version 1 timestamp for the FVER chunk. const AIFC_VERSION_1: u32 = 0xA280_5140; @@ -162,25 +163,82 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { /// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32). pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { validate_buffer(buf)?; - let channels = buf.channels.len(); - let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; let frames = buf.frames(); + let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len(); + let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames); + let total = head_len.saturating_add(data_len).saturating_add(1); + let mut out = Vec::new(); + out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) +} + +/// An AIFF written block by block: a placeholder header first, then the real one (the same +/// length) written over it by [`Writer::finish`]. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + depth: BitDepth, + quantizers: Vec, + frames: u64, + bytes: Vec, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + validate_layout(channels, sample_rate)?; + let start = out.stream_position()?; + out.write_all(&header(channels, sample_rate, depth, 0)?)?; + let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels); + Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() }) + } + + /// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + header(self.channels, self.sample_rate, self.depth, total)?; + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?; + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames); + if data_len % 2 == 1 { + self.out.write_all(&[0])?; + } + let head = header(self.channels, self.sample_rate, self.depth, self.frames)?; + self.out.seek(SeekFrom::Start(self.start))?; + self.out.write_all(&head)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't +/// depend on `frames`. +fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result> { + let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?; let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?; - let (bits, is_float): (u16, bool) = match depth { - BitDepth::Int16 => (16, false), - BitDepth::Int24 => (24, false), - BitDepth::Int32 => (32, false), - BitDepth::Float32 => (32, true), - }; - let bytes = usize::from(bits / 8); - let data_len = - bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; + let (bits, is_float) = sample_format(depth); + let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames); let mut comm = Vec::with_capacity(40); comm.extend_from_slice(&channels_i16.to_be_bytes()); comm.extend_from_slice(&frames_u32.to_be_bytes()); comm.extend_from_slice(&bits.to_be_bytes()); - comm.extend_from_slice(&u32_to_extended(buf.sample_rate)); + comm.extend_from_slice(&u32_to_extended(sample_rate)); if is_float { comm.extend_from_slice(b"fl32"); let name = b"32-bit float"; @@ -204,29 +262,10 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result head.extend_from_slice(&ssnd_len.to_be_bytes()); head.extend_from_slice(&[0u8; 8]); // offset, blockSize - let total = head.len().saturating_add(data_len).saturating_add(data_len & 1); + let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1); let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?; if let Some(slot) = head.get_mut(4..8) { slot.copy_from_slice(&form_len.to_be_bytes()); } - - let mut out = Vec::new(); - out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?; - out.extend_from_slice(&head); - let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); - for f in 0..frames { - for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { - let s = ch.get(f).copied().unwrap_or(0.0); - if is_float { - out.extend_from_slice(&clamp_float(s).to_be_bytes()); - } else { - let v = q.quantize(s).to_be_bytes(); - out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default()); - } - } - } - if data_len % 2 == 1 { - out.push(0); - } - Ok(out) + Ok(head) } 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..8a8c0ce 100644 --- a/crates/audio-io/src/flac.rs +++ b/crates/audio-io/src/flac.rs @@ -1,9 +1,10 @@ //! 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}; +use std::io::{Cursor, Seek, SeekFrom, Write}; const BLOCK_SIZE: usize = 4096; const MAX_PARTITION_ORDER: u32 = 6; @@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) { } } -pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { - validate_buffer(buf)?; - let channels = buf.channels.len(); +/// Checks that FLAC can hold the layout; returns the bits per sample. +fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result { + crate::pcm::validate_layout(channels, sample_rate)?; if channels > 8 { return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}"))); } - if buf.sample_rate > 655_350 { - return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate))); + if sample_rate > 655_350 { + return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz"))); } - let bps: u32 = match depth { - BitDepth::Int16 => 16, - BitDepth::Int24 => 24, - BitDepth::Int32 => 32, - BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), - }; - let frames = buf.frames(); - let total = frames as u64; - if total >= 1u64 << 36 { - return Err(AudioError::TooLarge("too many frames for FLAC".into())); + match depth { + BitDepth::Int16 => Ok(16), + BitDepth::Int24 => Ok(24), + BitDepth::Int32 => Ok(32), + BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())), } +} - let mut out = Vec::new(); - out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2) - .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; - out.extend_from_slice(b"fLaC"); +fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec { let mut si = BitWriter::new(); si.write(1, 1); // last metadata block si.write(0, 7); // STREAMINFO @@ -302,7 +296,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result si.write(BLOCK_SIZE as u64, 16); si.write(0, 24); si.write(0, 24); - si.write(u64::from(buf.sample_rate), 20); + si.write(u64::from(sample_rate), 20); si.write(channels as u64 - 1, 3); si.write(u64::from(bps - 1), 5); si.write(total >> 32, 4); @@ -310,25 +304,87 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result for _ in 0..4 { si.write(0, 32); // MD5 unknown } - out.extend_from_slice(&si.into_bytes()); + si.into_bytes() +} - let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate); - let mut quantizers = Quantizer::per_channel(bps, dither, channels); - let mut block: Vec = Vec::with_capacity(BLOCK_SIZE); - let mut frame_no: u64 = 0; - let mut start = 0usize; - while start < frames { - let len = (frames - start).min(BLOCK_SIZE); +/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + bps: u32, + quantizers: Vec, + pending: Vec>, + frames: u64, + frame_no: u64, +} + +impl Writer { + pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result { + let bps = check(channels, sample_rate, depth)?; + let start = out.stream_position()?; + out.write_all(b"fLaC")?; + out.write_all(&stream_info(channels, sample_rate, bps, 0))?; + Ok(Writer { + out, + start, + channels, + sample_rate, + bps, + quantizers: Quantizer::per_channel(bps, dither, channels), + pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(), + frames: 0, + frame_no: 0, + }) + } + + /// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + if total >= 1u64 << 36 { + return Err(AudioError::TooLarge("too many frames for FLAC".into())); + } + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence), unless they don't + /// fit (see [`Writer::check_room`]). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + let mut done = 0; + while done < frames { + let held = self.pending.first().map_or(0, Vec::len); + let n = (BLOCK_SIZE - held).min(frames - done); + for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() { + let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default(); + p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0))))); + } + done += n; + if held + n == BLOCK_SIZE { + self.write_frame()?; + } + } + self.frames = total; + Ok(()) + } + + fn write_frame(&mut self) -> Result<()> { + let len = self.pending.first().map_or(0, Vec::len); + if len == 0 { + return Ok(()); + } + let (sr_code, sr_extra) = sample_rate_code(self.sample_rate); let mut w = BitWriter::new(); w.write(0x3FFE, 14); w.write(0, 1); w.write(0, 1); // fixed blocksize stream w.write(7, 4); // 16-bit (blocksize-1) follows w.write(sr_code, 4); - w.write(channels as u64 - 1, 4); - w.write(bps_code(bps), 3); + w.write(self.channels as u64 - 1, 4); + w.write(bps_code(self.bps), 3); w.write(0, 1); - write_utf8_number(&mut w, frame_no); + write_utf8_number(&mut w, self.frame_no); w.write(len as u64 - 1, 16); if let Some((v, n)) = sr_extra { w.write(v, n); @@ -339,19 +395,87 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result w.write(u64::from(b), 8); } w.write(u64::from(crc8(&header)), 8); - for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) { - block.clear(); - block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s)))); - write_subframe(&mut w, &block, bps); + for p in &mut self.pending { + write_subframe(&mut w, p, self.bps); + p.clear(); } let mut frame = w.into_bytes(); let crc = crc16(&frame); frame.extend_from_slice(&crc.to_be_bytes()); - out.extend_from_slice(&frame); - start += len; - frame_no += 1; + self.out.write_all(&frame)?; + self.frame_no += 1; + Ok(()) } - Ok(out) + + pub(crate) fn finish(mut self) -> Result { + self.write_frame()?; + let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames); + self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?; + self.out.write_all(&info)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + +pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result> { + validate_buffer(buf)?; + let bps = check(buf.channels.len(), buf.sample_rate, depth)?; + let frames = buf.frames(); + let mut out = Vec::new(); + out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2) + .map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?; + let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) +} + +/// 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)] diff --git a/crates/audio-io/src/lib.rs b/crates/audio-io/src/lib.rs index 87241c6..1b2b43f 100644 --- a/crates/audio-io/src/lib.rs +++ b/crates/audio-io/src/lib.rs @@ -10,17 +10,22 @@ //! encoder), with optional TPDF dither. //! * [`wav_float_header`] heads a float WAV written while it grows (recording). //! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing. +//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file +//! of any length never has to fit in memory. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod aiff; mod buffer; +mod caf; mod flac; +mod mp4; mod pcm; pub mod peaks; mod symph; mod wav; pub use buffer::AudioBuffer; +use std::io::{Seek, Write}; /// Errors from probing, decoding and encoding. #[derive(Debug, thiserror::Error)] @@ -37,6 +42,9 @@ pub enum AudioError { /// Encoding failed (e.g. an empty buffer). #[error("audio encode failed: {0}")] Encode(String), + /// Writing the output failed. + #[error("audio write failed: {0}")] + Io(#[from] std::io::Error), } /// Result alias for this crate. @@ -187,7 +195,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 +213,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 +238,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 +250,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), } } @@ -286,6 +294,60 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u } } +/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing +/// the same bytes [`encode_with_channel_mask`] would for the whole buffer. +pub struct FileEncoder(Writer); + +enum Writer { + Wav(wav::Writer), + Aiff(aiff::Writer), + Flac(flac::Writer), +} + +impl FileEncoder { + /// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask + /// derived from the channel count. + pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option) -> Result { + let (depth, dither) = (opts.bit_depth, opts.dither); + Ok(FileEncoder(match opts.format { + FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?), + FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?), + FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?), + other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))), + })) + } + + /// Fails, without writing anything, when `frames` more frames would not fit the format (an + /// AIFF holds 4 GiB, a FLAC 2^36 frames). + pub fn check_room(&self, frames: u64) -> Result<()> { + match &self.0 { + Writer::Wav(w) => w.check_room(frames), + Writer::Aiff(w) => w.check_room(frames), + Writer::Flac(w) => w.check_room(frames), + } + .map(drop) + } + + /// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are + /// silence), unless they don't fit (see [`FileEncoder::check_room`]). + pub fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + match &mut self.0 { + Writer::Wav(w) => w.write(block, frames), + Writer::Aiff(w) => w.write(block, frames), + Writer::Flac(w) => w.write(block, frames), + } + } + + /// Completes the headers and hands back `out`, positioned at the end of the file. + pub fn finish(self) -> Result { + match self.0 { + Writer::Wav(w) => w.finish(), + Writer::Aiff(w) => w.finish(), + Writer::Flac(w) => w.finish(), + } + } +} + /// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one. pub fn wav_channel_mask(bytes: &[u8]) -> Option { wav::read_channel_mask(bytes) 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..35c761a 100644 --- a/crates/audio-io/src/pcm.rs +++ b/crates/audio-io/src/pcm.rs @@ -1,7 +1,8 @@ //! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample //! decoding to `f32`, and quantization (with optional TPDF dither) for encoding. -use crate::{AudioError, Result}; +use crate::{AudioError, BitDepth, Result}; +use std::io::Write; /// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output. pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4; @@ -36,6 +37,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() @@ -156,6 +163,49 @@ impl Quantizer { } } +/// Bits per sample at `depth`, and whether they are float. +pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) { + match depth { + BitDepth::Int16 => (16, false), + BitDepth::Int24 => (24, false), + BitDepth::Int32 => (32, false), + BitDepth::Float32 => (32, true), + } +} + +/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit +/// samples and writes them to `out`, a few thousand frames at a time through `scratch`. +pub(crate) fn write_interleaved( + out: &mut impl Write, + scratch: &mut Vec, + block: &[Vec], + frames: usize, + quantizers: &mut [Quantizer], + (bits, is_float): (u16, bool), + big_endian: bool, +) -> std::io::Result<()> { + let size = usize::from(bits / 8); + let mut start = 0; + while start < frames { + let end = frames.min(start + 4096); + scratch.clear(); + for f in start..end { + for (c, q) in quantizers.iter_mut().enumerate() { + let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0); + match (is_float, big_endian) { + (true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()), + (true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()), + (false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()), + (false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()), + } + } + } + out.write_all(scratch)?; + start = end; + } + Ok(()) +} + /// Clamp a float sample to [-1, 1], mapping NaN to 0. pub(crate) fn clamp_float(x: f32) -> f32 { if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) } @@ -163,13 +213,18 @@ pub(crate) fn clamp_float(x: f32) -> f32 { /// Check that a buffer is encodable: at least one channel and a non-zero sample rate. pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> { - if buf.channels.is_empty() { + validate_layout(buf.channels.len(), buf.sample_rate) +} + +/// Check that a stream is encodable: at least one channel and a non-zero sample rate. +pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> { + if channels == 0 { return Err(AudioError::Encode("buffer has no channels".into())); } - if buf.channels.len() > usize::from(u16::MAX) { + if channels > usize::from(u16::MAX) { return Err(AudioError::Encode("too many channels".into())); } - if buf.sample_rate == 0 { + if sample_rate == 0 { return Err(AudioError::Encode("sample rate is 0".into())); } Ok(()) diff --git a/crates/audio-io/src/peaks.rs b/crates/audio-io/src/peaks.rs index d13c1ea..9553665 100644 --- a/crates/audio-io/src/peaks.rs +++ b/crates/audio-io/src/peaks.rs @@ -1,48 +1,62 @@ -//! Multi-resolution min/max overview for waveform drawing. +//! Multi-resolution overview for waveform drawing. //! -//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges -//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine -//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of -//! zoom. +//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following +//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range +//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks +//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom. /// Samples per level-0 block. pub const BLOCK: usize = 32; /// Blocks of level `n` merged into one block of level `n + 1`. pub const FANOUT: usize = 4; -/// Upper bound on the number of columns [`Peaks::columns`] will produce. +/// Upper bound on the number of spans [`Peaks::spans`] will produce. pub const MAX_COLUMNS: usize = 1 << 20; -/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4. +/// Multi-resolution overview of one channel for waveform drawing. #[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Peaks { len: usize, - levels: Vec>, + levels: Vec>, } -const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY); - -fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) { - (a.0.min(b.0), a.1.max(b.1)) +/// What a run of samples sums up to; NaN samples count for nothing. +#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] +struct Block { + min: f32, + max: f32, + squares: f32, } -fn finish(acc: (f32, f32)) -> (f32, f32) { - if acc.0 > acc.1 { (0.0, 0.0) } else { acc } +impl Block { + const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 }; + + fn point(s: f32) -> Block { + Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } } + } + + fn of(samples: &[f32]) -> Block { + samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s))) + } + + fn merge(self, o: Block) -> Block { + Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares } + } + + /// (min, max), or (0, 0) when nothing was summed up. + fn range(self) -> (f32, f32) { + if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) } + } } impl Peaks { /// Build the overview of `samples` (one channel). NaN samples are ignored. pub fn build(samples: &[f32]) -> Peaks { - let mut levels = Vec::new(); - let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect(); - if level0.is_empty() { - return Peaks { len: 0, levels }; + if samples.is_empty() { + return Peaks::default(); } - levels.push(level0); - while let Some(last) = levels.last() { - if last.len() <= 1 { - break; - } - let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect(); + let mut levels: Vec> = vec![samples.chunks(BLOCK).map(Block::of).collect()]; + while let Some(last) = levels.last().filter(|l| l.len() > 1) { + let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect(); levels.push(next); } Peaks { len: samples.len(), levels } @@ -58,65 +72,84 @@ impl Peaks { self.levels.len() } - /// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends - /// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges - /// contribute their whole block's extremes, so the result always contains the true range. - /// Returns `(0.0, 0.0)` for an empty range. - pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) { - let end = end.min(self.len); - if start >= end { - return (0.0, 0.0); + /// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the + /// data the overview was built from. Returns `(0.0, 0.0)` for an empty range. + pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) { + self.sum(samples, start, end).range() + } + + fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block { + let end = end.min(self.len).min(samples.len()); + let (first, last) = (start.div_ceil(BLOCK), end / BLOCK); + let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default()); + if first >= last { + return raw(start, end); } - let mut lo = start / BLOCK; - let mut hi = end.div_ceil(BLOCK); - let mut acc = EMPTY; - let mut lvl = 0usize; - while lo < hi { - let Some(level) = self.levels.get(lvl) else { break }; - let has_parent = lvl + 1 < self.levels.len(); - if !has_parent || hi - lo < 2 * FANOUT { - for b in level.get(lo..hi.min(level.len())).unwrap_or_default() { - acc = merge(acc, *b); - } + raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last)) + } + + /// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit. + fn blocks(&self, mut lo: usize, mut hi: usize) -> Block { + let mut acc = Block::EMPTY; + for (lvl, level) in self.levels.iter().enumerate() { + let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY)); + if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT { + (lo..hi).for_each(add); break; } - while !lo.is_multiple_of(FANOUT) && lo < hi { - acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY)); + while !lo.is_multiple_of(FANOUT) { + add(lo); lo += 1; } - while !hi.is_multiple_of(FANOUT) && hi > lo { + while !hi.is_multiple_of(FANOUT) { hi -= 1; - acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY)); + add(hi); } lo /= FANOUT; hi /= FANOUT; - lvl += 1; } - finish(acc) + acc } - /// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range - /// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at - /// least one sample, so zoomed-in views repeat the containing sample's block. - pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> { - let cols = cols.min(MAX_COLUMNS); - let mut out = vec![(0.0, 0.0); cols]; - if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 { - return out; - } - let span = (end - start) / cols as f64; - let len = self.len as f64; - for (c, slot) in out.iter_mut().enumerate() { - let s = start + span * c as f64; - let e = start + span * (c + 1) as f64; - if e <= 0.0 || s >= len { - continue; - } - let si = s.max(0.0).floor(); - let ei = e.min(len).ceil().max(si + 1.0); - *slot = self.minmax(si as usize, ei as usize); - } - out + /// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that + /// reach the data, clamped to it. + fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator> { + let n = self.len.min(samples.len()); + let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0; + let last = n.saturating_sub(1) as f64; + (0..cols.min(MAX_COLUMNS)).map(move |c| { + let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap); + (valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last))) + }) + } + + /// (min, max) of the line through `samples` over each of `cols` spans of sample positions + /// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate + /// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from + /// them stays connected at any zoom. `None` for spans outside the data. + pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec> { + let at = |t: f64| { + let i = t.floor(); + let a = samples.get(i as usize).copied().unwrap_or(0.0); + let b = samples.get(i as usize + 1).copied().unwrap_or(a); + Block::point(a + (b - a) * (t - i) as f32) + }; + self.windows(samples, start, step, overlap, cols) + .map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range())) + .collect() + } + + /// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just + /// outside a span's ends. + pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec> { + self.windows(samples, start, step, overlap, cols) + .map(|w| { + w.map(|(a, b)| { + let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len())); + (self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt() + }) + }) + .collect() } } @@ -134,29 +167,19 @@ mod tests { } #[test] - fn aligned_ranges_are_exact() { + fn every_range_is_exact() { let x = signal(100_003); let p = Peaks::build(&x); - for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] { - assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}"); + for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] { + assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}"); } - } - - #[test] - fn unaligned_ranges_contain_truth_and_match_block_cover() { - let x = signal(50_000); - let p = Peaks::build(&x); let mut seed = 12345u64; for _ in 0..2000 { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let a = (seed >> 33) as usize % 50_000; + let a = (seed >> 33) as usize % 100_003; seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - let b = a + 1 + (seed >> 33) as usize % (50_000 - a); - let got = p.minmax(a, b); - let truth = brute(&x, a, b); - assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}"); - let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len())); - assert_eq!(got, cover, "{a}..{b}"); + let b = a + 1 + (seed >> 33) as usize % (100_003 - a); + assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}"); } } @@ -164,34 +187,87 @@ mod tests { fn degenerate_inputs() { let p = Peaks::build(&[]); assert_eq!(p.len_samples(), 0); - assert_eq!(p.minmax(0, 10), (0.0, 0.0)); - assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]); - let p = Peaks::build(&[0.5, -0.25]); - assert_eq!(p.minmax(0, 2), (-0.25, 0.5)); - assert_eq!(p.minmax(5, 3), (0.0, 0.0)); - assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5)); - assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0))); - assert_eq!(p.columns(0.0, 1.0, 0).len(), 0); - let p = Peaks::build(&[f32::NAN, 1.0]); - assert_eq!(p.minmax(0, 2), (1.0, 1.0)); + assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0)); + assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]); + let x = [0.5, -0.25]; + let p = Peaks::build(&x); + assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5)); + assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0)); + assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5)); + assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5)); + for (start, step, overlap) in [ + (f64::NAN, 1.0, 0.0), + (0.0, 0.0, 0.0), + (0.0, -1.0, 0.0), + (0.0, f64::INFINITY, 0.0), + (f64::NEG_INFINITY, 1.0, 0.0), + (0.0, 1.0, -0.5), + (0.0, 1.0, f64::NAN), + ] { + assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]); + } + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS); + let x = [f32::NAN, 1.0]; + let p = Peaks::build(&x); + assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0)); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]); + assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite)); } #[test] - fn columns_cover_range() { + fn rms_spans_match_the_samples() { + let x = signal(20_000); + let p = Peaks::build(&x); + let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::() / (e - s) as f64).sqrt() as f32; + for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] { + let cols = ((19_999.0 - overlap) / step) as usize; + for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() { + let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0)); + let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000))); + assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}"); + } + } + let sine: Vec = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect(); + let p = Peaks::build(&sine); + let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap(); + assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}"); + assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]); + assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]); + } + + #[test] + fn spans_cover_their_samples_and_touch_their_neighbours() { let x = signal(10_000); let p = Peaks::build(&x); - let cols = p.columns(0.0, 10_000.0, 100); - assert_eq!(cols.len(), 100); - for (c, &(lo, hi)) in cols.iter().enumerate() { - let truth = brute(&x, c * 100, c * 100 + 100); - assert!(lo <= truth.0 && hi >= truth.1); + for step in [0.3, 1.0, 7.5, 100.0, 333.3] { + let cols = (9_999.0 / step) as usize; + let spans = p.spans(&x, 0.0, step, 0.0, cols); + for (c, s) in spans.iter().enumerate() { + let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}")); + let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize); + if a <= b { + let truth = brute(&x, a, b + 1); + assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}"); + } + if let Some(Some((nlo, nhi))) = spans.get(c + 1) { + assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1); + } + } + let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1))); + assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}"); } - let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1))); - assert_eq!(all, brute(&x, 0, 10_000)); - // Past the end of the data. - let cols = p.columns(9_000.0, 11_000.0, 2); - assert_eq!(cols[1], (0.0, 0.0)); - // Zoomed in further than one sample per column. - assert_eq!(p.columns(0.0, 4.0, 8).len(), 8); + } + + #[test] + fn spans_follow_the_line_between_samples() { + let x = [0.0, 1.0, -1.0, 0.5]; + let p = Peaks::build(&x); + assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]); + assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]); + let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None]; + assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans); + assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]); + assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]); } } 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..1aa847d 100644 --- a/crates/audio-io/src/wav.rs +++ b/crates/audio-io/src/wav.rs @@ -1,8 +1,9 @@ //! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia //! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306). -use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer}; +use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved}; use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat}; +use std::io::{Cursor, Seek, SeekFrom, Write}; const WAVE_FORMAT_PCM: u16 = 1; const WAVE_FORMAT_IEEE_FLOAT: u16 = 3; @@ -83,6 +84,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") @@ -255,6 +307,74 @@ fn push_chunk(out: &mut Vec, id: &[u8; 4], body: &[u8]) { } } +/// A WAV written block by block: a placeholder header first, then the real one (the same length, +/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`]. +pub(crate) struct Writer { + out: W, + start: u64, + channels: usize, + sample_rate: u32, + format: (u16, bool), + bwf: Option, + mask: Option, + force_rf64: bool, + quantizers: Vec, + frames: u64, + bytes: Vec, +} + +impl Writer { + pub(crate) fn new( + mut out: W, + channels: usize, + sample_rate: u32, + depth: BitDepth, + dither: bool, + bwf: Option<&BwfInfo>, + mask: Option, + force_rf64: bool, + ) -> Result { + validate_layout(channels, sample_rate)?; + let format = sample_format(depth); + let start = out.stream_position()?; + out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?; + let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels); + Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() }) + } + + fn header(&self, frames: u64) -> Result> { + header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64) + } + + /// The frame count after `frames` more (RF64 has room for any bounce). + pub(crate) fn check_room(&self, frames: u64) -> Result { + let total = self.frames.saturating_add(frames); + self.header(total)?; + Ok(total) + } + + /// Appends `frames` frames of planar audio (missing samples are silence). + pub(crate) fn write(&mut self, block: &[Vec], frames: usize) -> Result<()> { + let total = self.check_room(frames as u64)?; + write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?; + self.frames = total; + Ok(()) + } + + pub(crate) fn finish(mut self) -> Result { + let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames); + if data_len % 2 == 1 { + self.out.write_all(&[0])?; + } + let head = self.header(self.frames)?; + self.out.seek(SeekFrom::Start(self.start))?; + self.out.write_all(&head)?; + self.out.seek(SeekFrom::End(0))?; + self.out.flush()?; + Ok(self.out) + } +} + /// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests); /// it is used automatically when the file would exceed 4 GiB. /// `mask` overrides the speaker mask derived from the channel count. @@ -269,36 +389,15 @@ pub(crate) fn encode( validate_buffer(buf)?; let channels = buf.channels.len(); let frames = buf.frames(); - let (bits, is_float): (u16, bool) = match depth { - BitDepth::Int16 => (16, false), - BitDepth::Int24 => (24, false), - BitDepth::Int32 => (32, false), - BitDepth::Float32 => (32, true), - }; - let bytes = usize::from(bits / 8); - let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?; - let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; - + let format = sample_format(depth); + let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?; + let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?; let mut out = Vec::new(); out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1)) .map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?; - out.extend_from_slice(&head); - let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels); - for f in 0..frames { - for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) { - let s = ch.get(f).copied().unwrap_or(0.0); - if is_float { - out.extend_from_slice(&clamp_float(s).to_le_bytes()); - } else { - let v = q.quantize(s).to_le_bytes(); - out.extend_from_slice(v.get(..bytes).unwrap_or_default()); - } - } - } - if data_len % 2 == 1 { - out.push(0); - } - Ok(out) + let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?; + w.write(&buf.channels, frames)?; + Ok(w.finish()?.into_inner()) } /// Everything before the sample data of a WAV holding `frames` frames. 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/audio-io/tests/stream.rs b/crates/audio-io/tests/stream.rs new file mode 100644 index 0000000..61a2894 --- /dev/null +++ b/crates/audio-io/tests/stream.rs @@ -0,0 +1,64 @@ +use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask}; +use std::io::Cursor; + +fn tone(channels: usize, frames: usize) -> AudioBuffer { + let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect(); + AudioBuffer { sample_rate: 44_100, channels } +} + +fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option) -> Vec { + let mut out = Cursor::new(b"pre".to_vec()); + out.set_position(3); + let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap(); + let mut start = 0; + for len in [1, 7, 4095, 4097, 333].into_iter().cycle() { + if start >= buf.frames() { + break; + } + let len = len.min(buf.frames() - start); + let block: Vec> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect(); + enc.write(&block, len).unwrap(); + start += len; + } + enc.finish().unwrap().into_inner() +} + +#[test] +fn streamed_files_match_whole_buffer_encodes() { + let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() }; + let cases = [ + (FileFormat::Wav, BitDepth::Int24, true, None, None), + (FileFormat::Wav, BitDepth::Float32, false, None, None), + (FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)), + (FileFormat::Aiff, BitDepth::Int24, true, None, None), + (FileFormat::Aiff, BitDepth::Float32, false, None, None), + (FileFormat::Flac, BitDepth::Int16, true, None, None), + (FileFormat::Flac, BitDepth::Int24, false, None, None), + ]; + for (format, bit_depth, dither, bwf, mask) in cases { + let opts = EncodeOptions { format, bit_depth, dither, bwf }; + for channels in [1, 6] { + let buf = tone(channels, 20_001); + let whole = match mask { + Some(m) => encode_with_channel_mask(&buf, &opts, m), + None => encode(&buf, &opts), + } + .unwrap(); + assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels"); + } + } +} + +#[test] +fn blocks_past_a_format_limit_are_refused_before_anything_is_written() { + let buf = tone(2, 100); + for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] { + let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }; + let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap(); + enc.write(&buf.channels, 100).unwrap(); + assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}"); + assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}"); + assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}"); + assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap()); + } +} 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/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs index 16aaf33..d62329d 100644 --- a/crates/clap-host/src/ffi.rs +++ b/crates/clap-host/src/ffi.rs @@ -14,8 +14,10 @@ use crate::{ClapError, scan}; use clap_sys::audio_buffer::clap_audio_buffer; use clap_sys::entry::clap_plugin_entry; use clap_sys::events::{ - CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, clap_event_header, clap_event_midi, - clap_event_note, clap_event_param_value, clap_input_events, clap_output_events, + CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, CLAP_EVENT_TRANSPORT, + CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, CLAP_TRANSPORT_HAS_TIME_SIGNATURE, + CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, clap_event_header, clap_event_midi, clap_event_note, clap_event_param_value, + clap_event_transport, clap_input_events, clap_output_events, }; use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports}; use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui}; @@ -27,12 +29,14 @@ use clap_sys::ext::params::{ }; use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state}; use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory}; +use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR}; use clap_sys::host::clap_host; use clap_sys::id::clap_id; use clap_sys::plugin::clap_plugin; use clap_sys::process::{CLAP_PROCESS_ERROR, clap_process}; use clap_sys::stream::{clap_istream, clap_ostream}; use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible}; +use soundcraft_dsp::Transport; use std::collections::HashMap; use std::ffi::{CStr, CString, c_char, c_void}; use std::path::{Path, PathBuf}; @@ -275,7 +279,7 @@ impl Bundle { inst.state_ext = inst.extension::(CLAP_EXT_STATE); let gui = inst.extension::(CLAP_EXT_GUI); inst.gui = Arc::new(GuiLink { - inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }), + inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false }), host: Arc::clone(&inst.state), }); Ok(inst) @@ -579,6 +583,40 @@ impl RawEvent { } } +fn transport_event(t: &Transport) -> clap_event_transport { + let fixed = |x: f64, factor: i64| if x.is_finite() { (x * factor as f64).round() as i64 } else { 0 }; + let mut flags = + CLAP_TRANSPORT_HAS_TEMPO | CLAP_TRANSPORT_HAS_BEATS_TIMELINE | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE | CLAP_TRANSPORT_HAS_TIME_SIGNATURE; + if t.playing { + flags |= CLAP_TRANSPORT_IS_PLAYING; + } + if t.recording { + flags |= CLAP_TRANSPORT_IS_RECORDING; + } + clap_event_transport { + header: clap_event_header { + size: u32::try_from(size_of::()).unwrap_or(u32::MAX), + time: 0, + space_id: CLAP_CORE_EVENT_SPACE_ID, + type_: CLAP_EVENT_TRANSPORT, + flags: 0, + }, + flags, + song_pos_beats: fixed(t.quarters, CLAP_BEATTIME_FACTOR), + song_pos_seconds: fixed(t.seconds, CLAP_SECTIME_FACTOR), + tempo: t.tempo, + tempo_inc: 0.0, + loop_start_beats: 0, + loop_end_beats: 0, + loop_start_seconds: 0, + loop_end_seconds: 0, + bar_start: fixed(t.bar_start, CLAP_BEATTIME_FACTOR), + bar_number: i32::try_from(t.bar).unwrap_or(0), + tsig_num: u16::try_from(t.numerator).unwrap_or(4), + tsig_denom: u16::try_from(t.denominator).unwrap_or(4), + } +} + unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 { if list.is_null() { return 0; @@ -696,17 +734,19 @@ fn window_api() -> Option<&'static CStr> { struct GuiInner { plugin: *const clap_plugin, gui: *const clap_plugin_gui, + state: *const clap_plugin_state, /// The plugin instance exists (cleared by `Instance::drop` before it destroys the plugin). alive: bool, created: bool, visible: bool, } -/// The link between an instance and its editor handles. The instance may process on the audio -/// thread while the editor is driven from the main thread: CLAP allows `clap.gui` (main-thread) -/// calls concurrently with audio-thread calls. Every GUI call happens under this lock, and the -/// instance clears `alive` (destroying any GUI) under the same lock before it destroys the -/// plugin, so a handle can never call into a destroyed plugin. +/// The link between an instance and its editor and state handles. The instance may process on +/// the audio thread while the editor is driven and the state saved from the main thread: CLAP +/// allows `clap.gui` and `clap.state` (main-thread) calls concurrently with audio-thread calls. +/// Every such call happens under this lock, and the instance clears `alive` (destroying any GUI) +/// under the same lock before it destroys the plugin, so a handle can never call into a destroyed +/// plugin. pub(crate) struct GuiLink { inner: Mutex, host: Arc, @@ -715,7 +755,14 @@ pub(crate) struct GuiLink { impl Default for GuiLink { fn default() -> Self { GuiLink { - inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }), + inner: Mutex::new(GuiInner { + plugin: std::ptr::null(), + gui: std::ptr::null(), + state: std::ptr::null(), + alive: false, + created: false, + visible: false, + }), host: Arc::new(HostState::default()), } } @@ -731,7 +778,8 @@ impl GuiLink { } pub fn available(&self) -> bool { - self.lock().alive + let g = self.lock(); + g.alive && !g.gui.is_null() } pub fn host(&self) -> &HostState { @@ -805,6 +853,22 @@ impl GuiLink { g.visible = false; } + /// `clap_plugin_state.save` into memory (`None` without the extension, on failure, or once the + /// plugin is gone). + pub fn save_state(&self) -> Option> { + let g = self.lock(); + if !g.alive { + return None; + } + // SAFETY: `state` is null or the plugin's static extension vtable, valid while `alive`. + let save = unsafe { g.state.as_ref() }?.save?; + let mut out: Vec = Vec::new(); + let stream = clap_ostream { ctx: (&mut out as *mut Vec).cast::(), write: Some(ostream_write) }; + // SAFETY: live plugin (`alive`, under the lock); the stream and its context live on this + // frame for the call. + unsafe { save(g.plugin, &stream) }.then_some(out) + } + /// Closes (destroys) the editor. pub fn close(&self) { if !on_main_thread() { @@ -1012,19 +1076,14 @@ impl Instance { self.gui.available().then(|| Arc::clone(&self.gui)) } - /// `clap_plugin_state.save` into memory (`None` without the extension or on failure). - pub fn save_state(&self) -> Option> { - // SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin. - let save = unsafe { self.state_ext.as_ref() }?.save?; - let mut out: Vec = Vec::new(); - let stream = clap_ostream { ctx: (&mut out as *mut Vec).cast::(), write: Some(ostream_write) }; - // SAFETY: valid instance; the stream and its context live on this frame for the call. - unsafe { save(self.plugin, &stream) }.then_some(out) + /// The same link, for state handles (`None` when the plugin has no `clap.state`). + pub fn state_link(&self) -> Option> { + (!self.state_ext.is_null()).then(|| Arc::clone(&self.gui)) } /// `clap_plugin_state.load` from memory. `false` without the extension or when refused. pub fn load_state(&self, data: &[u8]) -> bool { - // SAFETY: see `save_state`. + // SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin. let Some(load) = (unsafe { self.state_ext.as_ref() }).and_then(|s| s.load) else { return false }; let mut cursor = ReadCursor { data, pos: 0 }; let stream = clap_istream { ctx: (&mut cursor as *mut ReadCursor<'_>).cast::(), read: Some(istream_read) }; @@ -1186,6 +1245,7 @@ impl Instance { outputs: &mut PortBuffers, events: &[Event], offset: u32, + transport: Option<&Transport>, ) -> Result { if !self.active { return Err(ClapError::Process("not active".into())); @@ -1219,10 +1279,11 @@ impl Instance { self.out_params.clear(); let out_events = clap_output_events { ctx: (&mut self.out_params as *mut Vec<(u32, f64)>).cast::(), try_push: Some(out_events_try_push) }; + let transport = transport.map(transport_event); let p = clap_process { steady_time, frames_count: u32::try_from(frames).unwrap_or(0), - transport: std::ptr::null(), + transport: transport.as_ref().map_or(std::ptr::null(), std::ptr::from_ref), audio_inputs: inputs.bufs.as_ptr(), audio_outputs: outputs.bufs.as_mut_ptr(), audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0), @@ -1232,8 +1293,8 @@ impl Instance { }; // SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more) // valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event - // lists and their contexts live on this stack frame / in `self.raw`, untouched until the - // call returns. + // lists and their contexts and the transport live on this stack frame / in `self.raw`, + // untouched until the call returns. let status = unsafe { process(self.plugin, &p) }; if !self.out_params.is_empty() && let Ok(mut edits) = self.state.edits.try_lock() @@ -1264,3 +1325,33 @@ impl Drop for Instance { self.created = false; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_transport_reaches_clap_in_fixed_point() { + let t = Transport { + playing: true, + tempo: 90.0, + numerator: 3, + denominator: 4, + samples: 96_000, + seconds: 2.0, + quarters: 3.5, + bar_start: 3.0, + bar: 1, + ..Transport::default() + }; + let e = transport_event(&t); + assert_eq!((e.header.type_, e.header.size as usize), (CLAP_EVENT_TRANSPORT, size_of::())); + assert_eq!(e.flags & CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_PLAYING); + assert_eq!(e.flags & CLAP_TRANSPORT_IS_RECORDING, 0); + assert_eq!((e.song_pos_beats, e.bar_start), (7 * CLAP_BEATTIME_FACTOR / 2, 3 * CLAP_BEATTIME_FACTOR)); + assert_eq!(e.song_pos_seconds, 2 * CLAP_SECTIME_FACTOR); + assert_eq!((e.tempo, e.bar_number, e.tsig_num, e.tsig_denom), (90.0, 1, 3, 4)); + let nan = transport_event(&Transport { quarters: f64::NAN, ..t }); + assert_eq!(nan.song_pos_beats, 0); + } +} diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs index 2740f49..15da961 100644 --- a/crates/clap-host/src/lib.rs +++ b/crates/clap-host/src/lib.rs @@ -88,6 +88,9 @@ pub enum ClapError { mod api { use super::*; + pub fn cached() -> Option> { + registry::cached() + } pub fn scan() -> Vec { registry::scan() } @@ -115,6 +118,9 @@ mod api { mod api { use super::*; + pub fn cached() -> Option> { + Some(Vec::new()) + } pub fn scan() -> Vec { Vec::new() } @@ -161,6 +167,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// The plugins of the last scan, without scanning: `None` until something has scanned. +pub fn cached() -> Option> { + api::cached() +} + /// The plugins in one `.clap` file or bundle. pub fn load_bundle(path: &Path) -> Result, ClapError> { api::load_bundle(path) diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs index 6fe1abc..89f8dbf 100644 --- a/crates/clap-host/src/plugin.rs +++ b/crates/clap-host/src/plugin.rs @@ -2,7 +2,7 @@ use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers}; use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI}; -use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport}; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -33,11 +33,14 @@ pub struct ClapPlugin { in_main: Option, out_main: Option, notes: NoteMode, + /// The note port takes raw MIDI (controllers, pitch bend, program changes). + midi_in: bool, held: [bool; 128], channels: usize, max_block: usize, latency: usize, steady: i64, + transport: Option, } impl ClapPlugin { @@ -55,11 +58,13 @@ impl ClapPlugin { in_main: None, out_main: None, notes: NoteMode::None, + midi_in: false, held: [false; 128], channels: 2, max_block: 0, latency: 0, steady: 0, + transport: None, } } @@ -114,7 +119,9 @@ impl Plugin for ClapPlugin { self.out_main = pick_main(&outs); self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::>(), mb); self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::>(), mb); - self.notes = note_mode(self.inst.note_port()); + let port = self.inst.note_port(); + self.notes = note_mode(port); + self.midi_in = port.is_some_and(|p| p.supported & CLAP_NOTE_DIALECT_MIDI != 0); self.max_block = mb; self.inst.state().restart.store(false, Ordering::Relaxed); match self.inst.activate(sr, 1, u32::try_from(mb).unwrap_or(u32::MAX)) { @@ -187,7 +194,7 @@ impl Plugin for ClapPlugin { let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let events = self.queue.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset); + let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref()); self.queue.drain(..k); self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); match (r, self.out_main) { @@ -217,6 +224,10 @@ impl Plugin for ClapPlugin { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn note_on(&mut self, offset: usize, note: u8, velocity: u8) { if note > 127 { return; @@ -248,6 +259,12 @@ impl Plugin for ClapPlugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + if self.midi_in { + self.push(Event::Midi { time: u32::try_from(offset).unwrap_or(u32::MAX), data: msg }); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { @@ -256,10 +273,6 @@ impl Plugin for ClapPlugin { } } - fn save_state(&mut self) -> Option> { - self.inst.save_state() - } - fn load_state(&mut self, data: &[u8]) -> bool { let ok = self.inst.load_state(data); if ok { @@ -283,6 +296,11 @@ impl Plugin for ClapPlugin { let link = self.inst.gui()?; Some(Box::new(ClapEditor { link, info: self.info, ids: self.ids.clone() })) } + + fn state_reader(&mut self) -> Option> { + let link = self.inst.state_link()?; + Some(Box::new(move || link.save_state())) + } } /// A CLAP plugin's floating editor, driven from the main thread (see [`GuiLink`]). diff --git a/crates/clap-host/src/registry.rs b/crates/clap-host/src/registry.rs index 32eedce..89169e5 100644 --- a/crates/clap-host/src/registry.rs +++ b/crates/clap-host/src/registry.rs @@ -83,6 +83,10 @@ pub fn scan() -> Vec { ensure_scanned(&mut r).clone() } +pub fn cached() -> Option> { + registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone() +} + pub fn rescan() -> Vec { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None; diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs index 40de13c..85a7999 100644 --- a/crates/clap-host/tests/fixture/src/lib.rs +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -3,7 +3,8 @@ //! - `org.soundcraft.test.gain`: stereo in/out, param 7 "Gain" (0..2, default 1), param 3 "Mode" //! (stepped enum A/B/C), hidden param 9, latency 3 samples. //! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal -//! to the velocity of the held note (0 when none), switching at the event's sample offset. +//! to the velocity of the held note (0 when none), switching at the event's sample offset. A +//! MIDI CC 7 (volume) sets the constant to its value / 127. //! //! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a //! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI, @@ -150,6 +151,12 @@ fn apply(f: &mut Fx, h: &clap_event_header) { f.level = e.velocity; } CLAP_EVENT_NOTE_OFF => f.level = 0.0, + CLAP_EVENT_MIDI => { + let e = unsafe { &*(h as *const clap_event_header as *const clap_event_midi) }; + if e.data[0] & 0xF0 == 0xB0 && e.data[1] == 7 { + f.level = f64::from(e.data[2]) / 127.0; + } + } _ => {} } } diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/fixture_e2e.rs index 2216487..a6c28ca 100644 --- a/crates/clap-host/tests/fixture_e2e.rs +++ b/crates/clap-host/tests/fixture_e2e.rs @@ -105,6 +105,9 @@ fn synth_plays_notes_at_their_offsets() { p.all_notes_off(); p.process(&mut io, 64); assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + p.midi(20, [0xB0, 7, 64]); + p.process(&mut io, 64); + assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset"); } #[test] @@ -146,11 +149,36 @@ fn state_saves_and_restores_into_a_new_instance() { // Plugins without clap.state. let mut s = soundcraft_clap_host::create(SYNTH).unwrap(); assert!(s.save_state().is_none()); + assert!(s.state_reader().is_none()); assert!(!s.load_state(&blob)); assert!(s.editor().is_none(), "the synth has no GUI"); assert!(s.open_editor().is_err()); } +#[test] +fn state_reader_reads_while_another_thread_processes() { + fixture_dir(); + let mut p = soundcraft_clap_host::create(GAIN).unwrap(); + p.prepare(48_000.0, 64, 2); + assert!(p.set_param("7", 1.5)); + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + let mut reader = p.state_reader().expect("the gain fixture has clap.state"); + let blob = p.save_state().unwrap(); + let audio = std::thread::spawn(move || { + for _ in 0..2_000 { + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + } + p + }); + while !audio.is_finished() { + assert_eq!(reader.save_state().as_ref(), Some(&blob)); + } + let p = audio.join().unwrap(); + assert_eq!(reader.save_state(), Some(blob)); + drop(p); + assert_eq!(reader.save_state(), None); +} + #[test] fn editors_refuse_to_open_off_the_main_thread() { fixture_dir(); diff --git a/crates/dsp/src/lib.rs b/crates/dsp/src/lib.rs index 3d5bd33..d31ddac 100644 --- a/crates/dsp/src/lib.rs +++ b/crates/dsp/src/lib.rs @@ -169,6 +169,24 @@ impl PluginInfo { } } +/// The host's transport at the start of a block, for tempo-synced plugins. +#[derive(Debug, Clone, Copy, PartialEq, Default)] +pub struct Transport { + pub playing: bool, + pub recording: bool, + /// Quarter notes per minute. + pub tempo: f64, + pub numerator: u32, + pub denominator: u32, + /// Timeline position. + pub samples: i64, + pub seconds: f64, + pub quarters: f64, + /// Start of the current bar, in quarter notes, and its number (0 for the first bar). + pub bar_start: f64, + pub bar: i64, +} + /// A realtime audio processor or instrument. /// /// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames @@ -195,9 +213,14 @@ pub trait Plugin: Send { fn gain_reduction_db(&self) -> f32 { 0.0 } + /// The transport for the next block, given before `process`. + fn set_transport(&mut self, _transport: &Transport) {} /// Instruments: note events at sample offsets within the next block. fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {} fn note_off(&mut self, _offset: usize, _note: u8) {} + /// Instruments: any other MIDI 1.0 channel message (control change, pitch bend, pressure, + /// program change) at a sample offset within the next block. + fn midi(&mut self, _offset: usize, _msg: [u8; 3]) {} fn all_notes_off(&mut self) {} /// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it. /// `false` when the plugin does not play samples. Does not allocate; sounding voices stop. @@ -206,11 +229,12 @@ pub trait Plugin: Send { fn set_sample(&mut self, _sample: Option>) -> bool { false } - /// The plugin's complete internal state as an opaque blob (third-party plugins), for saving - /// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or - /// the plugin refused. May allocate: never call it in steady-state audio processing. + /// The plugin's complete internal state as an opaque blob (third-party plugins), as its + /// [`Plugin::state_reader`] reads it. `None` when the plugin has no state beyond its + /// parameters (built-ins) or the plugin refused. May allocate: never call it in steady-state + /// audio processing. fn save_state(&mut self) -> Option> { - None + self.state_reader()?.save_state() } /// Restores a blob from [`Plugin::save_state`]. `false` if unsupported or the plugin rejected /// it (the plugin keeps working with its previous state). The data is hostile input. @@ -229,6 +253,10 @@ pub trait Plugin: Send { fn editor(&mut self) -> Option> { None } + /// Reads this instance's state from the UI thread (`None` when it has no state of its own). + fn state_reader(&mut self) -> Option> { + None + } } /// A plugin's editor (GUI), driven from the main (UI) thread. Obtained from @@ -249,6 +277,19 @@ pub trait PluginEditor: Send { fn set_param(&mut self, _id: &str, _value: f32) {} } +/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio +/// thread, as plugin formats allow. Obtained from [`Plugin::state_reader`]; closures are readers. +pub trait PluginStateReader: Send { + /// What [`Plugin::save_state`] would return; `None` once the instance is gone. + fn save_state(&mut self) -> Option>; +} + +impl Option> + Send> PluginStateReader for F { + fn save_state(&mut self) -> Option> { + self() + } +} + /// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the /// instrument converts rates as it plays). Implementations must be cheap to read from the audio /// thread: no locks, no I/O. @@ -301,6 +342,11 @@ pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> { plugins::REGISTRY.iter().copied().find(|i| i.id == id) } +/// Whether a plugin makes sound instead of processing its input (it ignores the input). +pub fn is_generator(id: &str) -> bool { + id == "signal_generator" +} + /// Silence floor used by the dB helpers. pub const MIN_DB: f32 = -144.0; 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..fcda93b 100644 --- a/crates/dsp/src/offline.rs +++ b/crates/dsp/src/offline.rs @@ -52,7 +52,8 @@ fn max_len(ch: &[Vec]) -> usize { ch.iter().map(Vec::len).max().unwrap_or(0) } -fn peak(ch: &[Vec]) -> f32 { +/// The largest finite sample magnitude in any channel. +pub fn peak(ch: &[Vec]) -> f32 { ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs())) } @@ -341,7 +342,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/drums.rs b/crates/dsp/src/plugins/drums.rs index 75a7976..ca3a52b 100644 --- a/crates/dsp/src/plugins/drums.rs +++ b/crates/dsp/src/plugins/drums.rs @@ -387,7 +387,7 @@ impl DrumSynth { fn apply(&mut self, kind: EventKind) { match kind { EventKind::On(n, v) => self.trigger(n, v), - EventKind::Off(_) => {} + EventKind::Off(_) | EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {} EventKind::AllOff => { let fast = decay60(30.0, self.sr); for v in &mut self.voices { 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/src/plugins/sampler.rs b/crates/dsp/src/plugins/sampler.rs index c181687..ee85178 100644 --- a/crates/dsp/src/plugins/sampler.rs +++ b/crates/dsp/src/plugins/sampler.rs @@ -299,6 +299,7 @@ impl Sampler { v.env.gate_off(); } } + EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {} } } diff --git a/crates/dsp/src/plugins/synth.rs b/crates/dsp/src/plugins/synth.rs index be662c6..1e0b92a 100644 --- a/crates/dsp/src/plugins/synth.rs +++ b/crates/dsp/src/plugins/synth.rs @@ -13,8 +13,15 @@ pub(crate) enum EventKind { On(u8, u8), Off(u8), AllOff, + Sustain(bool), + /// Pitch bend as a frequency ratio. + Bend(f32), + ResetControllers, } +/// Pitch bend range, semitones each way. +const BEND_RANGE: f32 = 2.0; + #[derive(Debug, Clone, Copy)] pub(crate) struct Event { pub(crate) offset: usize, @@ -212,6 +219,8 @@ struct Voice { svf: Svf, age: u64, held: bool, + /// Released while the sustain pedal was down. + sustained: bool, } impl Voice { @@ -228,6 +237,7 @@ impl Voice { svf: Svf::default(), age: 0, held: false, + sustained: false, } } @@ -250,6 +260,8 @@ pub struct SubtractiveSynth { counter: u64, level: Smoother, cutoff: Smoother, + sustain: bool, + bend: f32, } impl Default for SubtractiveSynth { @@ -277,6 +289,8 @@ impl SubtractiveSynth { counter: 0, level: Smoother::new(0.5), cutoff: Smoother::new(2000.0), + sustain: false, + bend: 1.0, }; s.update(); s @@ -337,15 +351,32 @@ impl SubtractiveSynth { v.filt.gate_on(); v.age = counter; v.held = true; + v.sustained = false; } self.last_freq = Some(freq); } fn stop_note(&mut self, note: u8) { + let sustain = self.sustain; for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) { v.held = false; - v.amp.gate_off(); - v.filt.gate_off(); + if sustain { + v.sustained = true; + } else { + v.amp.gate_off(); + v.filt.gate_off(); + } + } + } + + fn set_sustain(&mut self, on: bool) { + self.sustain = on; + if !on { + for v in self.voices.iter_mut().filter(|v| v.sustained) { + v.sustained = false; + v.amp.gate_off(); + v.filt.gate_off(); + } } } @@ -356,10 +387,17 @@ impl SubtractiveSynth { EventKind::AllOff => { for v in &mut self.voices { v.held = false; + v.sustained = false; v.amp.gate_off(); v.filt.gate_off(); } } + EventKind::Sustain(on) => self.set_sustain(on), + EventKind::Bend(ratio) => self.bend = ratio, + EventKind::ResetControllers => { + self.set_sustain(false); + self.bend = 1.0; + } } } @@ -374,6 +412,7 @@ impl SubtractiveSynth { let env_oct = self.p.v(7) / 100.0 * 6.0; let cutoff = self.cutoff.next(); let nyq = sr * 0.45; + let bend = self.bend; let mut out = 0.0; for v in &mut self.voices { if !v.active() { @@ -386,8 +425,8 @@ impl SubtractiveSynth { } let a = v.amp.next(&self.amp_rates); let fe = v.filt.next(&self.filt_rates); - let s1 = v.osc1.next(w1, v.freq / sr); - let s2 = v.osc2.next(w2, v.freq * ratio2 / sr); + let s1 = v.osc1.next(w1, v.freq * bend / sr); + let s2 = v.osc2.next(w2, v.freq * bend * ratio2 / sr); let raw = s1 * (1.0 - mix) + s2 * mix; let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq); let g = (std::f32::consts::PI * fc / sr).tan(); @@ -418,6 +457,8 @@ impl Plugin for SubtractiveSynth { self.last_freq = None; self.level.snap(); self.cutoff.snap(); + self.sustain = false; + self.bend = 1.0; } fn process(&mut self, io: &mut [Vec], frames: usize) { @@ -467,6 +508,21 @@ impl Plugin for SubtractiveSynth { self.events.push(offset, EventKind::Off(note)); } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let [status, d1, d2] = msg; + let kind = match (status & 0xF0, d1) { + (0xB0, 64) => EventKind::Sustain(d2 >= 64), + (0xB0, 120 | 123) => EventKind::AllOff, + (0xB0, 121) => EventKind::ResetControllers, + (0xE0, _) => { + let v = (i32::from(d2 & 0x7F) << 7 | i32::from(d1 & 0x7F)) - 8192; + EventKind::Bend(2f32.powf(v as f32 / 8192.0 * BEND_RANGE / 12.0)) + } + _ => return, + }; + self.events.push(offset, kind); + } + fn all_notes_off(&mut self) { self.events.push(0, EventKind::AllOff); } 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..ad55d83 100644 --- a/crates/dsp/tests/plugins.rs +++ b/crates/dsp/tests/plugins.rs @@ -1,7 +1,7 @@ use std::collections::HashSet; use std::f32::consts::TAU; -use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins}; +use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, is_generator, plugin_info, plugins}; const SR: f32 = 48_000.0; const BLOCK: usize = 512; @@ -45,7 +45,7 @@ fn prepared(id: &str) -> Box { } fn is_source(id: &str) -> bool { - plugin_info(id).unwrap().is_instrument || id == "signal_generator" + plugin_info(id).unwrap().is_instrument || is_generator(id) } #[test] @@ -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"); @@ -394,6 +449,34 @@ fn synth_note_on_sounds_and_note_off_decays() { assert!(peak(&out[0][72_000..]) < 1e-6); } +#[test] +fn synth_follows_the_sustain_pedal_and_pitch_bend() { + let mut p = prepared("subtractive_synth"); + p.midi(0, [0xB0, 64, 127]); + p.note_on(0, 60, 100); + run(p.as_mut(), &vec![vec![0.0; 4800]; 2]); + p.note_off(0, 60); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(rms(&out[0][72_000..]) > 0.01, "the pedal holds the note"); + p.midi(0, [0xB0, 64, 0]); + let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]); + assert!(peak(&out[0][72_000..]) < 1e-6, "lifting the pedal releases it"); + + let rises = |x: &[f32]| x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() as f64; + let mut p = prepared("subtractive_synth"); + p.set_param("osc1_wave", 2.0); + p.set_param("osc_mix", 0.0); + p.note_on(0, 69, 100); + let plain = run(p.as_mut(), &[vec![0.0; 48_000]]); + p.midi(0, [0xE0, 0x7F, 0x7F]); + let bent = run(p.as_mut(), &[vec![0.0; 48_000]]); + let ratio = rises(&bent[0][4800..]) / rises(&plain[0][4800..]); + assert!((ratio - 2f64.powf(2.0 / 12.0)).abs() < 0.01, "{ratio}"); + p.midi(0, [0xB0, 121, 0]); + let reset = run(p.as_mut(), &[vec![0.0; 48_000]]); + assert!((rises(&reset[0][4800..]) / rises(&plain[0][4800..]) - 1.0).abs() < 0.01); +} + #[test] fn synth_event_offsets_are_sample_accurate() { let mut p = prepared("subtractive_synth"); diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs new file mode 100644 index 0000000..d42e81f --- /dev/null +++ b/crates/engine/src/autosave.rs @@ -0,0 +1,615 @@ +//! Crash-recovery copies of open sessions, one folder each, written in the background, and the +//! recovery of the ones left behind with unsaved work. + +use crate::io::{Recording, open_session, sanitize_name}; +use crate::{Engine, EngineError, Result}; +use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; +use soundcraft_model::{Session, SourceAudio, SourceId}; +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; +use std::sync::{Arc, Mutex, PoisonError, Weak}; +use std::time::{Duration, Instant, SystemTime}; + +const SESSION: &str = "session.scraft"; +const ORIGIN: &str = "origin"; +const LOCK: &str = "lock"; +/// The plan of a recording still running when the folder was written. +const RECORDING: &str = "recording.json"; +/// While changes keep coming, the newest is written at most this often. +const INTERVAL: Duration = Duration::from_secs(1); +const STOPPED: &str = "the autosave thread stopped"; + +/// Writes autosaves on a thread of its own, so the UI never waits on the disk. +pub struct Autosaver { + jobs: Option>, + thread: Option>, + failure: Arc>>, +} + +enum Job { + Write(Arc, Option, Option), + /// Done with the session's folder: keep it for the next launch, or delete it. + Close(bool), + Flush(Sender>), +} + +impl Autosaver { + /// A writer for autosave folders under `root`. + pub fn new(root: PathBuf) -> Autosaver { + let (tx, rx) = channel(); + let failure = Arc::new(Mutex::new(None)); + let writer = Writer { root, failure: Arc::clone(&failure), folder: None, pending: None, last: None, written: Ok(()) }; + let thread = std::thread::Builder::new().name("soundcraft-autosave".into()).spawn(move || writer.run(&rx)); + if let Err(e) = &thread { + report(&failure, format!("{STOPPED}: {e}")); + } + Autosaver { jobs: Some(tx), thread: thread.ok(), failure } + } + + /// Autosave `session`, opened from the session file `origin`, soon, with the plan of the + /// recording running in it. + pub fn write(&self, session: Arc, origin: Option, recording: Option) { + self.send(Job::Write(session, origin, recording)); + } + + /// Done with the open session's folder: keep it for the next launch, or delete it. + pub fn close(&self, keep: bool) { + self.send(Job::Close(keep)); + } + + /// Wait until everything handed over so far is done. A failure returned here isn't reported + /// again by `failure`. + pub fn flush(&self) -> Result<()> { + let (tx, rx) = channel(); + self.send(Job::Flush(tx)); + let done = rx.recv().unwrap_or_else(|_| Err(STOPPED.into())); + if done.is_err() { + self.failure(); + } + done.map_err(EngineError::Io) + } + + /// The newest failure since the last call. + pub fn failure(&self) -> Option { + self.failure.lock().unwrap_or_else(PoisonError::into_inner).take() + } + + fn send(&self, job: Job) { + if self.jobs.as_ref().is_none_or(|tx| tx.send(job).is_err()) { + report(&self.failure, STOPPED.into()); + } + } +} + +impl Drop for Autosaver { + /// What was handed over is written, and an open folder stays for the next launch. + fn drop(&mut self) { + drop(self.jobs.take()); + if let Some(t) = self.thread.take() { + let _ = t.join(); + } + } +} + +/// The writer thread. +struct Writer { + root: PathBuf, + failure: Arc>>, + /// The open session's folder. + folder: Option, + /// The newest session not written yet. + pending: Option<(Arc, Option, Option)>, + last: Option, + /// How the newest write into the open folder went, for `Flush`. + written: std::result::Result<(), String>, +} + +impl Writer { + fn run(mut self, jobs: &Receiver) { + loop { + let wait = match (&self.pending, self.last) { + (None, _) => Duration::MAX, + (Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())), + }; + match jobs.recv_timeout(wait) { + Ok(Job::Write(session, origin, recording)) => self.pending = Some((session, origin, recording)), + Ok(Job::Flush(reply)) => { + self.write_pending(); + let _ = reply.send(self.written.clone()); + } + Ok(Job::Close(keep)) => { + if keep { + self.write_pending(); + } + self.pending = None; + self.written = Ok(()); + if let Some(a) = self.folder.take() + && !keep + { + a.discard(); + } + } + Err(RecvTimeoutError::Timeout) => self.write_pending(), + Err(RecvTimeoutError::Disconnected) => { + self.write_pending(); + return; + } + } + } + } + + fn write_pending(&mut self) { + let Some((session, origin, recording)) = self.pending.take() else { return }; + self.last = Some(Instant::now()); + let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> { + if self.folder.is_none() { + self.folder = Some(Autosave::create(&self.root, &session.name)?); + } + self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref(), recording.as_ref())) + })); + self.written = match written { + Ok(written) => written.map_err(|e| e.to_string()), + Err(_) => Err("internal error (see the log)".into()), + }; + if let Err(e) = &self.written { + report(&self.failure, e.clone()); + } + } +} + +fn report(failure: &Mutex>, e: String) { + log::warn!("autosave failed: {e}"); + *failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(e); +} + +/// An open session's autosave folder, locked while the session is open so a later launch can tell +/// it from one a crash left behind. +struct Autosave { + dir: PathBuf, + lock: Option, + copies: BTreeMap, String)>, + next: u64, + /// A recording's plan is in the folder. + recording: bool, +} + +impl Autosave { + /// A new folder under `root` for the session called `name`. + fn create(root: &Path, name: &str) -> Result { + let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); + let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); + let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; + lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; + Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }) + } + + /// Write `session`, opened from the session file `origin`, and the plan of the `recording` + /// running in it. Audio saved with it is referenced where it is; audio that exists only in + /// memory is copied, once per recording or render. + fn write(&mut self, session: &Session, origin: Option<&str>, recording: Option<&Recording>) -> Result<()> { + let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok()); + let audio_dir = self.dir.join("Audio Files"); + let mut s = session.clone(); + let mut copies = BTreeMap::new(); + for src in &mut s.sources { + let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) }; + let audio = s.pool.get(src.id); + if let Some(f) = &file + && (!src.unsaved && f.is_file() || audio.is_none()) + { + src.path = f.to_string_lossy().into_owned(); + continue; + } + let Some(audio) = audio else { continue }; + let name = match self.copies.remove(&src.id) { + Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name, + _ => { + let name = format!("{} {}.wav", sanitize_name(&src.name), self.next); + self.next += 1; + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?; + std::fs::create_dir_all(&audio_dir) + .and_then(|()| write_synced(&audio_dir.join(&name), &bytes)) + .map_err(|err| io_error(&audio_dir, err))?; + name + } + }; + src.path = audio_dir.join(&name).to_string_lossy().into_owned(); + src.unsaved = true; + copies.insert(src.id, (Arc::downgrade(audio), name)); + } + let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; + write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?; + write_atomically(&self.dir.join(SESSION), text.as_bytes())?; + for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() { + let _ = std::fs::remove_file(audio_dir.join(name)); + } + if let Some(r) = recording { + let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?; + write_atomically(&self.dir.join(RECORDING), plan.as_bytes())?; + } else if self.recording { + let _ = std::fs::remove_file(self.dir.join(RECORDING)); + } + self.recording = recording.is_some(); + Ok(()) + } + + /// Delete the folder: the session closed with nothing left to recover. + fn discard(mut self) { + drop(self.lock.take()); + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// Unsaved work left behind in an autosave folder. +#[derive(Debug, Clone, PartialEq)] +pub struct Leftover { + pub dir: PathBuf, + /// The session's name. + pub name: String, + /// The session file it was autosaved from, if it had one. + pub origin: Option, + pub saved: SystemTime, + /// A recording was still running in it. + pub recording: bool, +} + +/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the +/// session file they came from or holding a recording. Empty and outdated ones are deleted. +pub fn leftovers(root: &Path) -> Vec { + let mut out = Vec::new(); + for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) { + let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue }; + if lock.try_lock().is_err() { + continue; + } + let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty()); + let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok()); + let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified()); + let recording = dir.join(RECORDING).is_file(); + drop(lock); + match saved { + Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => { + let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default(); + let named = serde_json::from_str::(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string)); + let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned()); + out.push(Leftover { dir, name, origin, saved, recording }); + } + _ => { + let _ = std::fs::remove_dir_all(&dir); + } + } + } + out.sort_by_key(|l| std::cmp::Reverse(l.saved)); + out +} + +/// Open `left` as an unsaved version of the session it came from, so Save writes it back there, +/// with the takes of a recording that never finished placed as Stop would have. Returns the +/// media that couldn't be loaded. +pub fn recover(e: &mut Engine, left: &Leftover) -> Result> { + let mut r = Engine::default(); + let mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?; + let mut s = r.session().clone(); + let unfinished = unfinished_takes(left, &s, &mut missing); + if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) { + for src in s.sources.iter_mut().filter(|src| !src.unsaved) { + if let Ok(rel) = Path::new(&src.path).strip_prefix(home) { + src.path = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::>().join("/"); + } + } + } + e.replace_session(Session::default()); + *e.session_mut() = s; + e.path = left.origin.clone(); + if let Some((plan, takes)) = unfinished { + crate::io::place_recording(e, &plan, takes); + } + Ok(missing) +} + +/// The takes of the recording `left` holds the plan of, repaired, minus those already in `s`. +/// A take the plan names that isn't a take file SoundCraft recorded, in a folder it records into, +/// is left untouched and added to `skipped`. +fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> { + let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?; + let known: Vec = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect(); + let folders = take_folders(left); + let mut takes = Vec::new(); + for t in &plan.takes { + if std::fs::canonicalize(&t.path).is_ok_and(|p| known.contains(&p)) { + continue; + } + match repair_take(&t.path, &folders, t.channels, plan.rate) { + Ok(frames) if frames > 0 => takes.push((t.track, t.path.clone())), + Ok(_) => {} + Err(err) => { + log::warn!("recovery skipped the take {}: {err}", t.path.display()); + skipped.push(format!("{}: {err}", t.path.display())); + } + } + } + Some((plan, takes)) +} + +/// The folders a recording in `left`'s session writes its takes into: the `Audio Files` next to +/// the session file, or, never saved, the one in its folder under the autosave root. +fn take_folders(left: &Leftover) -> Vec { + let saved = left.origin.as_deref().and_then(|o| Path::new(o).parent()).map(|d| d.join("Audio Files")); + let unsaved = left.dir.parent().map(|root| root.join(sanitize_name(&left.name)).join("Audio Files")); + saved.into_iter().chain(unsaved).filter_map(|d| std::fs::canonicalize(d).ok()).collect() +} + +/// Make a take file's header cover all the audio in it; returns its frames. The recorder +/// rewrites the header only every second, so a crash leaves up to a second uncovered. Only a +/// regular file in one of `folders` that starts with the header the recorder writes is touched. +fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) -> std::io::Result { + let refuse = |why: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string()); + if !std::fs::symlink_metadata(path)?.file_type().is_file() { + return Err(refuse("not a regular file")); + } + let folder = path.parent().and_then(|d| std::fs::canonicalize(d).ok()); + if !folder.is_some_and(|d| folders.contains(&d)) { + return Err(refuse("not in a folder SoundCraft records into")); + } + let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other); + let expected = header(0)?; + let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?; + let mut head = vec![0u8; expected.len()]; + if std::io::Read::read_exact(&mut f, &mut head).is_err() || !is_take_header(&head, &expected) { + return Err(refuse("not a take SoundCraft recorded")); + } + let audio = f.metadata()?.len().saturating_sub(expected.len() as u64); + let frames = audio / (channels.max(1) as u64 * 4); + std::io::Seek::rewind(&mut f)?; + f.write_all(&header(frames)?)?; + f.sync_all()?; + Ok(frames) +} + +/// `head` is a take header like `expected` (the recorder's header for no frames yet): RIFF or +/// RF64 WAVE, the same format chunk at the same place, and the data chunk right after it all. +/// Only the sizes and frame counts may differ. +fn is_take_header(head: &[u8], expected: &[u8]) -> bool { + let magic = matches!(head.get(..4), Some(b"RIFF" | b"RF64")) && head.get(8..12) == Some(b"WAVE"); + let fmt = expected.windows(4).position(|w| w == b"fmt "); + let fmt_len = fmt.and_then(|at| expected.get(at + 4..at + 8)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes); + let same_fmt = match (fmt, fmt_len) { + (Some(at), Some(n)) => { + let end = at.saturating_add(8).saturating_add(n as usize); + expected.get(at..end).is_some_and(|chunk| head.get(at..end) == Some(chunk)) + } + _ => false, + }; + let data = expected.len().checked_sub(8).and_then(|at| head.get(at..at + 4)) == Some(b"data"); + magic && same_fmt && data +} + +/// Delete a leftover autosave. +pub fn discard(left: &Leftover) { + let _ = std::fs::remove_dir_all(&left.dir); +} + +fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> { + let mut f = std::fs::File::create(path)?; + f.write_all(bytes)?; + f.sync_all() +} + +fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> { + let tmp = path.with_extension("tmp"); + write_synced(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|err| { + let _ = std::fs::remove_file(&tmp); + io_error(path, err) + }) +} + +fn io_error(path: &Path, err: std::io::Error) -> EngineError { + EngineError::Io(format!("{}: {err}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use soundcraft_audio_io::AudioBuffer; + use soundcraft_model::{ChannelFormat, Clip, TrackKind}; + + fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + fn record(e: &mut Engine, level: f32) { + let before = e.session_arc(); + let s = e.session_mut(); + let t = match s.tracks.first() { + Some(t) => t.id, + None => s.add_track(TrackKind::Audio, ChannelFormat::Mono, None), + }; + let src = + crate::io::add_source(s, "Audio 1_01", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_01", src, 0, 0, 4_800)); + e.push_undo("Record", before); + } + + fn levels(e: &Engine) -> Vec { + let s = e.session(); + s.sources.iter().map(|src| s.pool.get(src.id).map_or(f32::NAN, |a| a.buffer.channels[0][0])).collect() + } + + #[test] + fn saved_audio_is_referenced_and_takes_are_copied_once() { + let dir = folder("autosave-copies"); + let path = dir.join("Song/Song.scraft"); + let mut e = Engine::default(); + record(&mut e, 0.5); + crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); + record(&mut e, -0.25); + let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); + assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1); + drop(a); + let left = leftovers(&dir.join("Autosave")); + let mut r = Engine::default(); + assert_eq!(recover(&mut r, &left[0]).unwrap(), Vec::::new()); + let paths: Vec<_> = r.session().sources.iter().map(|s| (s.path.starts_with("Audio Files/"), s.unsaved)).collect(); + assert_eq!(paths, [(true, false), (false, true)]); + assert_eq!(levels(&r), [0.5, -0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_take_recorded_after_undo_gets_its_own_copy() { + let dir = folder("autosave-undo"); + let mut e = Engine::default(); + record(&mut e, 0.5); + let mut a = Autosave::create(&dir, "Untitled").unwrap(); + a.write(e.session(), None, None).unwrap(); + e.execute("edit.undo", &json!({})).unwrap(); + record(&mut e, -0.25); + a.write(e.session(), None, None).unwrap(); + drop(a); + let mut r = Engine::default(); + recover(&mut r, &leftovers(&dir)[0]).unwrap(); + assert_eq!(levels(&r), [-0.25]); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_writer_saves_the_newest_change_and_drops_the_folder_when_done() { + let root = folder("autosave-writer"); + let w = Autosaver::new(root.clone()); + let mut e = Engine::default(); + for _ in 0..5 { + record(&mut e, 0.5); + w.write(e.session_arc(), None, None); + } + w.flush().unwrap(); + let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::>(); + let mut r = Engine::default(); + open_session(&mut r, &file[0].to_string_lossy()).unwrap(); + assert_eq!((file.len(), r.session().sources.len()), (1, 5)); + w.close(false); + w.flush().unwrap(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + record(&mut e, 0.5); + w.write(e.session_arc(), None, None); + drop(w); + let mut r = Engine::default(); + recover(&mut r, &leftovers(&root)[0]).unwrap(); + assert_eq!(r.session().sources.len(), 6); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn a_take_still_recording_at_a_crash_is_placed_on_recovery() { + use crate::io::Take; + let dir = folder("autosave-take"); + let root = dir.join("Autosave"); + let mut e = Engine::default(); + let track = + soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); + let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav"); + std::fs::create_dir_all(take.parent().unwrap()).unwrap(); + let audio: Vec = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect(); + std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap(); + let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] }; + let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::>(); + let w = Autosaver::new(root.clone()); + w.write(e.session_arc(), None, Some(plan.clone())); + drop(w); + let left = leftovers(&root); + assert!(left[0].recording); + let mut r = Engine::default(); + recover(&mut r, &left[0]).unwrap(); + assert_eq!(clips(&r), [(48_000, 480)], "all of the take, its last second included"); + discard(&left[0]); + let w = Autosaver::new(root.clone()); + w.write(r.session_arc(), None, Some(plan)); + drop(w); + let mut again = Engine::default(); + recover(&mut again, &leftovers(&root)[0]).unwrap(); + assert_eq!((clips(&again), again.session().sources.len()), (vec![(48_000, 480)], 1), "placed once"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn recovery_never_writes_a_file_that_isnt_a_take_in_a_take_folder() { + use crate::io::Take; + let dir = folder("autosave-hostile-take"); + let root = dir.join("Autosave"); + let mut e = Engine::default(); + let track = + soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap()); + let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files"); + std::fs::create_dir_all(&takes).unwrap(); + let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat(); + let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4); + let files = + [(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())]; + for (path, bytes) in &files { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + let mut paths: Vec = files.iter().map(|(p, _)| p.clone()).collect(); + #[cfg(unix)] + { + let link = takes.join("Vox_03.wav"); + std::os::unix::fs::symlink(&files[1].0, &link).unwrap(); + paths.push(link); + } + let plan = Recording { + start: 0, + pass: None, + punch: None, + rate: 48_000, + takes: paths.iter().map(|p| Take { track, path: p.clone(), channels: 1 }).collect(), + }; + let w = Autosaver::new(root.clone()); + w.write(e.session_arc(), None, Some(plan)); + drop(w); + let mut r = Engine::default(); + let skipped = recover(&mut r, &leftovers(&root)[0]).unwrap(); + assert_eq!(skipped.len(), paths.len(), "{skipped:?}"); + assert!(r.session().sources.is_empty()); + for (path, bytes) in &files { + assert_eq!(&std::fs::read(path).unwrap(), bytes, "{} untouched", path.display()); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn unsaved_work_is_offered_once_its_session_closes() { + let dir = folder("autosave-leftovers"); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + let root = dir.join("Autosave"); + std::fs::create_dir_all(&dir).unwrap(); + let mut e = Engine::default(); + crate::io::save_session(&mut e, &path).unwrap(); + record(&mut e, 0.5); + let mut a = Autosave::create(&root, "Song").unwrap(); + a.write(e.session(), e.path.as_deref(), None).unwrap(); + assert!(leftovers(&root).is_empty()); + drop(a); + let left = leftovers(&root); + assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Song", Some(path.as_str()))); + let mut r = Engine::default(); + recover(&mut r, &left[0]).unwrap(); + assert!(r.is_dirty()); + r.execute("session.save", &json!({})).unwrap(); + assert!(leftovers(&root).is_empty()); + assert!(!left[0].dir.exists()); + let mut reopened = Engine::default(); + open_session(&mut reopened, &path).unwrap(); + assert_eq!(levels(&reopened), [0.5]); + Autosave::create(&root, "Song").unwrap().discard(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/clip_group_file.rs b/crates/engine/src/clip_group_file.rs index c889c99..e20caf0 100644 --- a/crates/engine/src/clip_group_file.rs +++ b/crates/engine/src/clip_group_file.rs @@ -14,7 +14,7 @@ use std::path::{Path, PathBuf}; pub const EXTENSION: &str = "scgrp"; const FORMAT: &str = "soundcraft.clipgroup"; -/// Refuse files that would place absurd numbers of clips. +/// Refuse files that would place absurd numbers of clips, tracks or audio files. const MAX_CLIPS: usize = 10_000; #[derive(Debug, Serialize, Deserialize)] @@ -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,35 +84,50 @@ 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())); } - if file.lanes.iter().map(Vec::len).sum::() > MAX_CLIPS { + if file.lanes.iter().map(|l| l.len().max(1)).sum::() > MAX_CLIPS || file.audio.len() > MAX_CLIPS { return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into())); } let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default(); - // Decode audio first so a bad file changes nothing. - let mut decoded = BTreeMap::new(); + // Decode audio first so a bad file changes nothing, and each file once however many ids name it. + let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); + let rate = e.session().sample_rate.hz(); for (old, rel) in &file.audio { let full = dir.join(rel); 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 (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; - decoded.insert(*old, (rel.clone(), buf)); + let key = crate::io::file_key(&full); + let n = match index.get(&key) { + Some(n) => *n, + None => { + let bytes = crate::io::read_file(&full)?; + let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?; + let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES) + .ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?; + decoded.push((rel.clone(), buf)); + index.insert(key, decoded.len() - 1); + decoded.len() - 1 + } + }; + file_of.insert(*old, n); } let src_rate = f64::from(file.sample_rate.max(1)); let s = e.session_mut(); let ratio = f64::from(s.sample_rate.hz()) / src_rate; let scale = |v: Samples| -> Samples { (v as f64 * ratio).round() as Samples }; - let mut map: BTreeMap = BTreeMap::new(); - for (old, (rel, buf)) in decoded { - let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); - map.insert(old, crate::io::add_source(s, &name, buf, None, FileFormat::Wav)); - } + let sources: Vec = decoded + .into_iter() + .map(|(rel, buf)| { + let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); + crate::io::add_source(s, &name, buf, None, FileFormat::Wav) + }) + .collect(); + let map: BTreeMap = file_of.into_iter().filter_map(|(old, n)| Some((old, *sources.get(n)?))).collect(); let start_idx = first.and_then(|t| s.tracks.iter().position(|x| x.id == t)); let mut placed = Vec::new(); let group_id = s.alloc(); @@ -142,9 +159,9 @@ pub fn import(e: &mut Engine, path: &str, first: Option, at: Samples) - } c.id = s.new_clip_id(); c.start = at.saturating_add(scale(c.start.max(0))); - c.length = scale(c.length).max(1); + c.length = scale(c.length); c.group = Some(group_id); - c.clamp_fades(); + c.sanitize(); placed.push(c.id); crate::edit::place_clip(s, target, c); } @@ -190,6 +207,46 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn positions_in_a_group_file_are_kept_within_reach() { + let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let p = dir.join("far.scgrp"); + let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": i64::MAX, "length": i64::MAX}); + let clip = + json!({"id": 1, "name": "far", "start": i64::MAX, "length": i64::MAX, "content": {"Midi": {"sequence": {"notes": [note], "ctrls": []}}}}); + let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1, "lanes": [[clip]], "audio": {}}); + std::fs::write(&p, file.to_string()).unwrap(); + let mut e = crate::demo::demo_engine(); + let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap(); + let id = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap()); + let c = e.session().find_clip(id).unwrap().1; + assert!(c.start <= crate::cmd::MAX_POSITION && c.length <= crate::cmd::MAX_POSITION); + e.execute("edit.select_all", &json!({})).unwrap(); + for cmd in ["edit.duplicate", "event.extract_chords"] { + assert!(!matches!(e.execute(cmd, &json!({})), Err(crate::EngineError::Internal(..))), "{cmd}"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_file_named_by_many_ids_becomes_one_source() { + let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + let audio = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] }; + std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap(); + let clip = |id: u64| json!({"id": id, "name": "k", "start": id * 1000, "length": 480, "content": {"Audio": {"source": id, "offset": 0}}}); + let audio_paths = json!({"1": "k.wav", "2": "./k.wav", "3": "k.wav"}); + let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 4000, "lanes": [[clip(1), clip(2), clip(3)]], "audio": audio_paths}); + let p = dir.join("k.scgrp"); + std::fs::write(&p, file.to_string()).unwrap(); + let mut e = crate::demo::demo_engine(); + let before = e.session().sources.len(); + let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap(); + assert_eq!((r["clips"].as_array().unwrap().len(), e.session().sources.len()), (3, before + 1)); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn rejects_foreign_files_and_escaping_paths() { let dir = std::env::temp_dir().join(format!("sc-grp-bad-{}", std::process::id())); diff --git a/crates/engine/src/cmd/audio_midi.rs b/crates/engine/src/cmd/audio_midi.rs index 786e66d..218e395 100644 --- a/crates/engine/src/cmd/audio_midi.rs +++ b/crates/engine/src/cmd/audio_midi.rs @@ -40,7 +40,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result { seq.sort(); let count = seq.notes.len(); let clip = Clip::midi(ClipId(0), "Audio as MIDI", 0, r.len(), seq); - e.clipboard = Clipboard { length: r.len(), tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::default() }; + e.clipboard = Clipboard { tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::of_range(e.session(), r) }; Ok(json!({"notes": count, "length": r.len()})) } diff --git a/crates/engine/src/cmd/audiosuite.rs b/crates/engine/src/cmd/audiosuite.rs index e2abfca..585f2be 100644 --- a/crates/engine/src/cmd/audiosuite.rs +++ b/crates/engine/src/cmd/audiosuite.rs @@ -4,7 +4,7 @@ use super::*; use crate::cmd; use serde_json::json; use soundcraft_audio_io::{AudioBuffer, FileFormat}; -use soundcraft_model::{Clip, ClipContent}; +use soundcraft_model::{Clip, ClipContent, TrackKind}; pub fn specs() -> Vec { vec![cmd!( @@ -12,7 +12,7 @@ pub fn specs() -> Vec { "AudioSuite Process", [], None, - "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}", + "{process: | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)", has_selection, process )] @@ -35,17 +35,36 @@ fn process(e: &mut Engine, p: &Value) -> Result { if !known { return Err(bad("audiosuite.process", format!("unknown process `{name}`"))); } + if soundcraft_dsp::is_generator(&name) && p.get("clips").is_none() && p.get("clip").is_none() { + let r = range_param(e, "audiosuite.process", p)?; + if !r.is_empty() { + return generate(e, p, &name, ¶ms, r); + } + } let ids = clip_ids_param(e, p); if ids.is_empty() { 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,20 +97,11 @@ 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)); - for (k, v) in ¶ms { - pl.set_param(k, *v); - } - soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf); + apply(plugin, ¶ms, &mut audio, srf)?; audio } }; @@ -122,6 +132,46 @@ fn process(e: &mut Engine, p: &Value) -> Result { Ok(json!({"processed": done, "process": name})) } +/// Fill `r` on each selected audio track with a new clip from the generator `name`, over +/// whatever was there, like Pro Tools' Signal Generator. +fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result { + let sr = e.session().sample_rate; + let tracks = tracks_param(e, "audiosuite.process", p)?; + // Refuse before generating anything, rather than cut the clip short. + for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) { + crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?; + } + let mut clips = Vec::new(); + for track in tracks { + let Some(t) = e.session().track(track).filter(|t| t.kind == TrackKind::Audio) else { continue }; + let channels = t.channels().max(1); + let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels)); + let clip_name = format!("{}-{}", t.name, short(name)); + let mut audio = vec![vec![0.0; frames]; channels]; + apply(name, params, &mut audio, sr.as_f64() as f32)?; + let s = e.session_mut(); + let src = crate::io::add_source(s, &clip_name, AudioBuffer { sample_rate: sr.hz(), channels: audio }, None, FileFormat::Wav); + let id = s.new_clip_id(); + crate::edit::place_clip(s, track, Clip::audio(id, &clip_name, src, 0, r.start, i64::try_from(frames).unwrap_or(0))); + clips.push(id.0); + } + if clips.is_empty() { + return Err(bad("audiosuite.process", "select an audio track to generate on")); + } + Ok(json!({"processed": clips.len(), "process": name, "clips": clips})) +} + +/// Run plugin `id` with `params` over `audio`, in place. +fn apply(id: &str, params: &[(String, f32)], audio: &mut [Vec], srf: f32) -> Result<()> { + let Some(mut pl) = soundcraft_dsp::create(id) else { return Err(bad("audiosuite.process", format!("cannot create `{id}`"))) }; + pl.prepare(srf, 1024, audio.len().max(1)); + for (k, v) in params { + pl.set_param(k, *v); + } + soundcraft_dsp::offline::apply_plugin(audio, pl.as_mut(), srf); + Ok(()) +} + fn short(n: &str) -> String { let s: String = n.split('_').map(|w| w.chars().take(4).collect::()).collect::>().join(""); s.chars().take(8).collect() @@ -165,4 +215,28 @@ mod tests { let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0]; assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}"); } + + #[test] + fn signal_generator_fills_the_selection_with_a_clip() { + let (mut e, _) = gained_clip(); + let tone = e.session().tracks[0].id; + let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Empty")); + e.execute("edit.select", &json!({"tracks": [t.0], "start": 48_000, "end": 96_000})).unwrap(); + assert!(matches!(e.execute("audiosuite.process", &json!({"process": "normalize"})), Err(crate::EngineError::BadParams(..)))); + + let generator = json!({"process": "signal_generator", "params": {"freq": 440.0, "level": -6.0}}); + assert_eq!(e.execute("audiosuite.process", &generator).unwrap()["processed"], 1); + let c = e.session().tracks[1].clips()[0].clone(); + assert_eq!((c.start, c.length), (48_000, 48_000)); + let buf = &e.session().pool.get(c.source().unwrap()).unwrap().buffer; + assert_eq!(buf.channels.len(), 2); + let peak = buf.channels[1].iter().fold(0.0f32, |m, x| m.max(x.abs())); + assert!((peak - 0.5).abs() < 0.01, "-6 dB sine peaks near 0.5, got {peak}"); + + // Over existing audio it replaces just the selection. + e.execute("edit.select", &json!({"tracks": [tone.0], "start": 32, "end": 96})).unwrap(); + e.execute("audiosuite.process", &generator).unwrap(); + let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect(); + assert_eq!(ranges, vec![(0, 32), (32, 64)]); + } } diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 520e537..9d68dc2 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 } } @@ -153,8 +153,13 @@ pub fn specs() -> Vec { Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")), None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)), }; + let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1)); + let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at }; + if sh { + crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?; + } let id = s.new_clip_id(); - crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, frames.max(1))); + crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len)); Ok(json!({"clip": id, "track": t})) } ), @@ -199,9 +204,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 +291,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(); @@ -297,7 +307,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result { copies.push((c.start + c.length * k, nc)); } for (at, nc) in copies { - crate::edit::paste(s, tid, at, &[nc], c.length, false); + let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false); made += 1; } } @@ -341,14 +351,11 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result { fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session(); - let moves: Vec<(soundcraft_model::ClipId, i64)> = ids - .iter() - .filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point))) - .map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor)) - .collect(); + let moves: Vec<(soundcraft_model::ClipId, i64)> = + ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect(); let s = e.session_mut(); for (id, d) in &moves { - crate::edit::move_clips(s, &[*id], *d, None); + crate::edit::move_clips(s, &[*id], *d, &[]); } Ok(json!({"clips": moves.len()})) } @@ -378,6 +385,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 776fe02..468170c 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -1,25 +1,25 @@ //! Edit menu commands. +use super::more_util::toggle_flag; use super::*; use crate::edit as ops; use crate::{Clipboard, cmd}; use serde_json::json; -use soundcraft_model::{EditMode, FadeShape, Session}; +use soundcraft_model::{EditMode, Fade, FadeShape, Session}; 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, @@ -72,8 +72,8 @@ pub fn specs() -> Vec { cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)), cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)), cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)), - cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)), - cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)), + cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], Some("Ctrl+Shift+P"), "{}", has_tracks, |e, _| extend_tracks(e, -1)), + cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], Some("Ctrl+Shift+;"), "{}", has_tracks, |e, _| extend_tracks(e, 1)), cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| { let s = e.session_mut(); if !s.edit.selected_tracks.is_empty() { @@ -103,7 +103,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", @@ -118,19 +126,41 @@ pub fn specs() -> Vec { "Create...", ["Edit", "Fades"], Some("Cmd+F"), - "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}", + "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?, curvature?: -1..1}", has_range, fades_create ), 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!(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; - Ok(json!({"mode": m})) - }), + 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, Alt+1-4, `"), + "{mode: shuffle|slip|spot|grid|relative_grid, snap_to_grid?: bool (Shuffle, Slip and Spot)}", + always, + set_mode + ), + cmd!( + noundo "edit.snap_to_grid", + "Snap to Grid", + [], + Some("Shift+F4"), + "{value?: bool} (toggles when omitted) — in Shuffle, Slip and Spot the Edit cursor and selections snap to the grid", + always, + snap_to_grid + ), + cmd!( + noundo "edit.shuffle_lock", + "Shuffle Lock", + [], + None, + "{value?: bool} (toggles when omitted) — keeps Shuffle mode from being chosen", + always, + shuffle_lock + ), cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| { let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?; e.session_mut().edit.tool = t; @@ -141,10 +171,19 @@ pub fn specs() -> Vec { "Move Clips", [], None, - "{clips?: [id], by?: samples, to?: position (first clip start), track?: target}", + "{clips?: [id], by?: samples, to?: position for the first clip that can move (Grid modes snap its sync point), track?: where the first clip's track goes; the others keep their distance}", has_selection, move_clips ), + cmd!( + "edit.trim_clip", + "Trim Clip", + [], + None, + "{clip: id, start?: position, end?: position} — moves the clip's edges like the Trim tool, in the current Edit mode", + always, + trim_clip + ), cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate), ] } @@ -153,8 +192,57 @@ fn shuffle(s: &Session) -> bool { s.edit.edit_mode == EditMode::Shuffle } +fn set_mode(e: &mut Engine, p: &Value) -> Result { + 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"))?; + if m == EditMode::Shuffle && e.session().edit.flag(ops::SHUFFLE_LOCK) { + return Err(bad("edit.mode", "Shuffle is locked")); + } + let snap = bool_or(p, "snap_to_grid", false) && !m.is_grid(); + let ed = &mut e.session_mut().edit; + ed.edit_mode = m; + ed.set_flag(ops::SNAP_TO_GRID, snap); + if m.is_grid() { + ed.set_flag(ops::RELATIVE_GRID, m == EditMode::GridRelative); + } + Ok(json!({"mode": m, "snap_to_grid": snap})) +} + +fn snap_to_grid(e: &mut Engine, p: &Value) -> Result { + if e.session().edit.edit_mode.is_grid() { + return Err(bad("edit.snap_to_grid", "Grid mode always snaps")); + } + toggle_flag(e, p, ops::SNAP_TO_GRID) +} + +fn shuffle_lock(e: &mut Engine, p: &Value) -> Result { + if shuffle(e.session()) && bool_or(p, "value", true) { + return Err(bad("edit.shuffle_lock", "leave Shuffle mode before locking it")); + } + toggle_flag(e, p, ops::SHUFFLE_LOCK) +} + +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 { @@ -163,7 +251,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { if range.is_empty() { return Err(bad(id, "selection is empty")); } - let mut cb = Clipboard { length: range.len(), ..Clipboard::default() }; + let mut cb = Clipboard::of_range(e.session(), range); let follow = e.session().edit.automation_follows_edit; for t in &tracks { cb.tracks.push(ops::copy_range(e.session(), *t, range)); @@ -205,15 +293,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { let sh = shuffle(e.session()); let s = e.session_mut(); for t in &tracks { - ops::clear_range(s, *t, range, sh); - if follow && let Some(tr) = s.track_mut(*t) { - for l in &mut tr.automation { - l.clear_range(range.start, range.end); - if sh { - l.shift_from(range.end, -range.len()); - } - } - } + cut_track(s, *t, range, sh, follow); } s.edit.selection = soundcraft_time::Range::point(range.start); } @@ -222,17 +302,39 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result { Ok(json!({"clips": n, "length": range.len()})) } +/// Remove `r` from a track, closing the gap in Shuffle mode; automation follows when `follow`. +fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, follow: bool) { + if follow && let Some(tr) = s.track_mut(t) { + for l in &mut tr.automation { + l.clear_range(r.start, r.end); + } + } + ops::clear_range(s, t, r, sh); +} + fn paste(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.paste", p)?; - let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start); + let given = position_param(e, "edit.paste", p, "at")?; let cb = e.clipboard.clone(); - let sh = shuffle(e.session()); - let follow = e.session().edit.automation_follows_edit; + let s = e.session(); + let at = match given { + Some(at) => at, + None if s.edit.edit_mode == EditMode::GridRelative => { + s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset) + } + None => s.edit.selection.start, + }; + let sh = shuffle(s) && !cb.automation_only; + let follow = s.edit.automation_follows_edit; + let replace = Some(s.edit.selection).filter(|r| sh && given.is_none() && !r.is_empty()); let s = e.session_mut(); let mut pasted = 0; for (i, t) in tracks.iter().enumerate() { let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue }; - pasted += ops::paste(s, *t, at, clips, cb.length, sh).len(); + if let Some(r) = replace { + cut_track(s, *t, r, true, follow); + } + pasted += ops::paste(s, *t, at, clips, cb.length, sh).map_err(|why| bad("edit.paste", why))?.len(); if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) { for l in lanes { let dst = tr.lane_mut(&l.param); @@ -329,7 +431,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result { c }) .collect(); - ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); + let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false); } } at += len; @@ -429,20 +531,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})) } @@ -453,12 +560,14 @@ fn duplicate(e: &mut Engine, p: &Value) -> Result { if range.is_empty() { return Err(bad("edit.duplicate", "selection is empty")); } + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; let s = e.session_mut(); let mut n = 0; for t in &tracks { let clips = ops::copy_range(s, *t, range); - n += ops::paste(s, *t, range.end, &clips, range.len(), false).len(); - if let Some(tr) = s.track_mut(*t) { + n += ops::paste(s, *t, range.end, &clips, range.len(), sh).map_err(|why| bad("edit.duplicate", why))?.len(); + if follow && let Some(tr) = s.track_mut(*t) { for l in &mut tr.automation { let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect(); for pt in pts { @@ -478,11 +587,13 @@ fn repeat(e: &mut Engine, p: &Value) -> Result { if range.is_empty() { return Err(bad("edit.repeat", "selection is empty")); } + let sh = shuffle(e.session()); let s = e.session_mut(); for t in &tracks { let clips = ops::copy_range(s, *t, range); for k in 1..=count { - ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false); + let at = range.start.saturating_add(range.len().saturating_mul(k)); + ops::paste(s, *t, at, &clips, range.len(), sh).map_err(|why| bad("edit.repeat", why))?; } } Ok(json!({"repeats": count})) @@ -493,19 +604,25 @@ fn shift(e: &mut Engine, p: &Value) -> Result { let later = bool_or(p, "later", true); let ids = clip_ids_param(e, p); let s = e.session_mut(); - let n = ops::move_clips(s, &ids, if later { by } else { -by }, None); + let n = ops::move_clips(s, &ids, if later { by } else { -by }, &[]); Ok(json!({"moved": n})) } fn insert_silence(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.insert_silence", p)?; let range = range_param(e, "edit.insert_silence", p)?; + let sh = shuffle(e.session()); + let follow = e.session().edit.automation_follows_edit; let s = e.session_mut(); for t in &tracks { - ops::insert_silence(s, *t, range); - if let Some(tr) = s.track_mut(*t) { - for l in &mut tr.automation { - l.shift_from(range.start, range.len()); + if sh { + ops::make_room(s, *t, range.start, range.len()).map_err(|why| bad("edit.insert_silence", why))?; + } else { + ops::clear_range(s, *t, range, false); + if follow && let Some(tr) = s.track_mut(*t) { + for l in &mut tr.automation { + l.clear_range(range.start, range.end); + } } } } @@ -591,13 +708,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 +727,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,17 +795,59 @@ 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})) } fn fades_create(e: &mut Engine, p: &Value) -> Result { - let tracks = tracks_required(e, "edit.fades_create", p)?; - let r = range_param(e, "edit.fades_create", p)?; let shape = str_param(p, "shape") .map_or(Some(FadeShape::EqualPower), FadeShape::from_id) .ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?; + if p.get("clip").is_some() || p.get("side").is_some() { + let curvature = match p.get("curvature") { + None => 0.0, + Some(value) => value + .as_f64() + .filter(|v| v.is_finite() && (-1.0..=1.0).contains(v)) + .ok_or_else(|| bad("edit.fades_create", "curvature must be a finite number from -1 to 1"))? as f32, + }; + if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) { + return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range")); + } + let id = p.get("clip").and_then(Value::as_u64).map(soundcraft_model::ClipId).ok_or_else(|| bad("edit.fades_create", "clip must be an id"))?; + let fade_in = match str_param(p, "side") { + Some("in") => true, + Some("out") => false, + _ => return Err(bad("edit.fades_create", "side must be in or out")), + }; + let length = p + .get("length") + .and_then(Value::as_i64) + .filter(|len| *len > 0) + .ok_or_else(|| bad("edit.fades_create", "length must be a positive sample count"))?; + let (_, clip) = e.session().find_clip(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; + let available = clip.length.min(clip.end().saturating_sub(clip.start)); + if available <= 0 { + return Err(bad("edit.fades_create", "clip has no length")); + } + if p.get("shape").is_some_and(|v| !v.is_string()) { + return Err(bad("edit.fades_create", "shape must be a fade shape name")); + } + let fade = Fade { len: length.min(available), shape, curvature }; + let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; + if fade_in { + clip.fade_in = fade; + } else { + clip.fade_out = fade; + } + return Ok(json!({"fades": 1})); + } + if p.get("curvature").is_some() { + return Err(bad("edit.fades_create", "curvature requires a clip and side")); + } + let tracks = tracks_required(e, "edit.fades_create", p)?; + let r = range_param(e, "edit.fades_create", p)?; let s = e.session_mut(); let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum(); if n == 0 { @@ -715,10 +887,11 @@ 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) }; + let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, &[]) }; s.edit.selection = s.edit.selection.shifted(step); if s.edit.selection.start < 0 { s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start); @@ -731,44 +904,183 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result { if ids.is_empty() { return Err(bad("edit.move_clips", "no clips")); } - let to_track = track_param(e, "edit.move_clips", p, "track")?; - let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0); + let s = e.session(); + let dest = match track_param(e, "edit.move_clips", p, "track")? { + Some(to) => ids + .iter() + .find_map(|c| s.find_clip(*c)) + .and_then(|(from, _)| ops::destinations(s, &ids, from, to)) + .ok_or_else(|| bad("edit.move_clips", "the clips can't land there: past the last track, or on a track that can't hold them"))?, + None => Vec::new(), + }; + let first = ops::first_movable(s, &ids); let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) { (Some(by), _) => by, - (None, Some(to)) => to - first, + (None, Some(to)) => to.saturating_sub(first.map_or(0, |c| c.start)), _ => 0, }; - // Grid mode snaps the destination. - let s = e.session(); - let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() { - s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first - } else { - delta + let mode = s.edit.edit_mode; + let delta = match first { + Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative), + _ => delta, }; let s = e.session_mut(); - let n = ops::move_clips(s, &ids, delta, to_track); + let n = if mode == EditMode::Shuffle { + ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))? + } else { + ops::move_clips(s, &ids, delta, &dest) + }; s.edit.selected_clips = ids; Ok(json!({"moved": n, "by": delta})) } +fn trim_clip(e: &mut Engine, p: &Value) -> Result { + let id = clip_id(p, "edit.trim_clip")?; + let start = position_param(e, "edit.trim_clip", p, "start")?; + let end = position_param(e, "edit.trim_clip", p, "end")?; + if start.is_none() && end.is_none() { + return Err(bad("edit.trim_clip", "pass `start` and/or `end`")); + } + let sh = shuffle(e.session()); + let c = ops::trim_clip(e.session_mut(), id, start, end, sh).ok_or_else(|| bad("edit.trim_clip", "no such clip, or it is edit-locked"))?; + Ok(json!({"start": c.start, "end": c.end()})) +} + fn consolidate(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "edit.consolidate", p)?; let r = range_param(e, "edit.consolidate", p)?; if r.is_empty() { return Err(bad("edit.consolidate", "selection is empty")); } + 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; } } - Ok(json!({"consolidated": n})) + Ok(json!({"consolidated": n + midi})) } #[cfg(test)] mod tests { use super::*; + use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind}; + + #[test] + fn targeted_fade_curvature_saves_reopens_and_undoes_without_changing_lengths() { + for side in ["in", "out"] { + let mut e = crate::demo::demo_engine(); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve"})).unwrap(); + let before = e.session().clone(); + let undo_len = e.undo_history().len(); + e.execute( + "edit.fades_create", + &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve", "curvature": -0.65}), + ) + .unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + if side == "in" { + c.fade_in.curvature = -0.65; + } else { + c.fade_out.curvature = -0.65; + } + assert_eq!(e.session(), &expected); + let reopened = Session::from_json(&e.session().to_json().unwrap()).unwrap(); + let reopened = reopened.find_clip(clip.id).unwrap().1; + let stored = expected.find_clip(clip.id).unwrap().1; + assert_eq!(reopened.fade_in, stored.fade_in); + assert_eq!(reopened.fade_out, stored.fade_out); + assert_eq!(e.undo_history().len(), undo_len + 1); + assert!(e.undo()); + assert_eq!(e.session(), &before); + assert!(e.redo()); + assert_eq!(e.session(), &expected); + } + } + + #[test] + fn targeted_fades_fill_only_the_requested_side_and_undo_once() { + for side in ["in", "out"] { + let mut e = crate::demo::demo_engine(); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.select_none", &json!({})).unwrap(); + let before = e.session().clone(); + let undo_len = e.undo_history().len(); + assert_eq!( + e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": i64::MAX, "shape": "s-curve"})).unwrap(), + json!({"fades": 1}) + ); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 }; + if side == "in" { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(e.session(), &expected); + assert_eq!(e.undo_history().len(), undo_len + 1); + assert!(e.undo()); + assert_eq!(e.session(), &before); + assert!(e.redo()); + assert_eq!(e.session(), &expected); + } + } + + #[test] + fn targeted_fades_reject_invalid_parameters_without_an_edit() { + let mut e = crate::demo::demo_engine(); + let id = e.session().track_by_name("Kick").unwrap().clips()[0].id.0; + for params in [ + json!({"clip": id, "side": "in"}), + json!({"clip": id, "length": 10}), + json!({"side": "out", "length": 10}), + json!({"clip": "bad", "side": "in", "length": 10}), + json!({"clip": u64::MAX, "side": "in", "length": 10}), + json!({"clip": id, "side": "bad", "length": 10}), + json!({"clip": id, "side": "in", "length": 0}), + json!({"clip": id, "side": "in", "length": -1}), + json!({"clip": id, "side": "in", "length": 1.5}), + json!({"clip": id, "side": "in", "length": "10"}), + json!({"clip": id, "side": "in", "length": u64::MAX}), + json!({"clip": id, "side": "in", "length": 10, "shape": "bad"}), + json!({"clip": id, "side": "in", "length": 10, "shape": null}), + json!({"clip": id, "side": "in", "length": 10, "start": 0}), + json!({"clip": id, "side": "in", "length": 10, "end": 100}), + json!({"clip": id, "side": "in", "length": 10, "tracks": []}), + json!({"clip": id, "side": "in", "length": 10, "curvature": 2}), + json!({"clip": id, "side": "in", "length": 10, "curvature": -2}), + json!({"clip": id, "side": "in", "length": 10, "curvature": 1e100}), + json!({"clip": id, "side": "in", "length": 10, "curvature": null}), + json!({"clip": id, "side": "in", "length": 10, "curvature": "0.5"}), + json!({"start": 0, "end": 100, "curvature": 0.5}), + ] { + let before = e.session().clone(); + let history = e.undo_history(); + assert!(e.execute("edit.fades_create", ¶ms).is_err(), "{params}"); + assert_eq!(e.session(), &before, "{params}"); + assert_eq!(e.undo_history(), history, "{params}"); + } + e.session_mut().find_clip_mut(soundcraft_model::ClipId(id)).unwrap().length = 0; + let before = e.session().clone(); + assert!(e.execute("edit.fades_create", &json!({"clip": id, "side": "in", "length": 1})).is_err()); + assert_eq!(e.session(), &before); + } + + #[test] + fn targeted_fades_support_one_sample_clips() { + let mut e = crate::demo::demo_engine(); + let id = e.session().track_by_name("Kick").unwrap().clips()[0].id; + e.session_mut().find_clip_mut(id).unwrap().length = 1; + for side in ["in", "out"] { + e.execute("edit.fades_create", &json!({"clip": id.0, "side": side, "length": 1})).unwrap(); + let clip = e.session().find_clip(id).unwrap().1; + assert_eq!(if side == "in" { clip.fade_in.len } else { clip.fade_out.len }, 1); + } + } #[test] fn selecting_what_is_already_selected_leaves_the_document_alone() { @@ -782,4 +1094,119 @@ mod tests { e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap(); assert_ne!(e.revision, rev); } + + fn keys() -> (Engine, TrackId, impl Fn(i64) -> i64) { + let mut e = Engine::default(); + let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys")); + let (tempo, sr) = (e.session().tempo.clone(), e.session().sample_rate); + (e, t, move |q| tempo.tick_to_samples(q * soundcraft_time::TICKS_PER_QUARTER, sr)) + } + + fn note(start: i64, length: i64, pitch: u8) -> soundcraft_midi::Note { + let q = soundcraft_time::TICKS_PER_QUARTER; + soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start: start * q, length: length * q } + } + + #[test] + fn consolidating_an_empty_midi_selection_makes_an_empty_clip() { + let (mut e, t, at) = keys(); + e.execute("edit.select", &json!({"tracks": [t.0], "start": at(4), "end": at(12)})).unwrap(); + assert_eq!(e.execute("edit.consolidate", &json!({})).unwrap()["consolidated"], 1); + let clips = e.session().track(t).unwrap().clips().to_vec(); + assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::>(), vec![(at(4), at(12))]); + assert!(matches!(&clips[0].content, ClipContent::Midi { sequence } if sequence.notes.is_empty())); + } + + #[test] + fn consolidating_midi_merges_the_clips_as_they_sound() { + let (mut e, t, at) = keys(); + let s = e.session_mut(); + let cc = soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(1), value: 64 }; + let a = soundcraft_midi::Sequence { notes: vec![note(0, 1, 60), note(3, 2, 62), note(5, 1, 63)], ctrls: vec![] }; + let b = soundcraft_midi::Sequence { notes: vec![note(0, 1, 64)], ctrls: vec![] }; + let c = soundcraft_midi::Sequence { notes: vec![note(1, 1, 65)], ctrls: vec![cc] }; + for (start, end, seq, muted) in [(0, 4, a, false), (6, 8, b, true), (8, 12, c, false)] { + let id = s.new_clip_id(); + let mut clip = Clip::midi(id, "Keys", at(start), at(end) - at(start), seq); + clip.muted = muted; + ops::place_clip(s, t, clip); + } + e.execute("edit.select", &json!({"tracks": [t.0], "start": at(2), "end": at(10)})).unwrap(); + e.execute("edit.consolidate", &json!({})).unwrap(); + let clips = e.session().track(t).unwrap().clips().to_vec(); + assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::>(), vec![(0, at(2)), (at(2), at(10)), (at(10), at(12))]); + let ClipContent::Midi { sequence } = &clips[1].content else { panic!("not MIDI") }; + 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..fdd265c 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", @@ -284,7 +290,7 @@ pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result { fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result { let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; - let mut cb = Clipboard { length: r.len(), ..Clipboard::default() }; + let mut cb = Clipboard { automation_only: true, ..Clipboard::of_range(e.session(), r) }; let mut lanes_n = 0; for t in &tracks { cb.tracks.push(Vec::new()); @@ -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)?; @@ -545,12 +551,13 @@ fn duplicate_extend(e: &mut Engine, p: &Value) -> Result { let id = "edit.duplicate_extend"; let tracks = tracks_required(e, id, p)?; let r = nonempty_range(e, p, id)?; + let (sh, follow) = (shuffle(e.session()), e.session().edit.automation_follows_edit); let s = e.session_mut(); let mut n = 0; for t in &tracks { let clips = ops::copy_range(s, *t, r); - n += ops::paste(s, *t, r.end, &clips, r.len(), false).len(); - if let Some(tr) = s.track_mut(*t) { + n += ops::paste(s, *t, r.end, &clips, r.len(), sh).map_err(|why| bad(id, why))?.len(); + if follow && let Some(tr) = s.track_mut(*t) { for l in &mut tr.automation { let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect(); for pt in pts { @@ -677,7 +684,7 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result { others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start) }; if let Some(d) = delta.filter(|d| *d != 0) { - moved += ops::move_clips(s, &[id], d, None); + moved += ops::move_clips(s, &[id], d, &[]); } } Ok(json!({"moved": moved})) @@ -1044,6 +1051,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..205f038 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; @@ -152,7 +176,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result { let s = e.session_mut(); let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect(); for t in ids { - crate::edit::clear_range(s, t, r, true); + crate::edit::clear_range(s, t, r, false); + crate::edit::shift_from(s, t, r.end, -r.len()); if let Some(tr) = s.track_mut(t) { for l in &mut tr.automation { l.clear_range(r.start, r.end); @@ -314,7 +339,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 +402,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/event_more.rs b/crates/engine/src/cmd/event_more.rs index 3dc634c..c0780a5 100644 --- a/crates/engine/src/cmd/event_more.rs +++ b/crates/engine/src/cmd/event_more.rs @@ -555,6 +555,10 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option { Some(name) } +/// The most bars Extract Chords looks at (55 hours of 4/4 at 120 BPM), and notes sounding in them. +const MAX_CHORD_BARS: usize = 100_000; +const MAX_CHORD_NOTES: usize = 10_000_000; + fn extract_chords(e: &mut Engine, p: &Value) -> Result { let id = "event.extract_chords"; let (_, t0, t1) = selection_ticks(e, p, id)?; @@ -580,20 +584,36 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result { } } } - let first_bar = s.tempo.bar_beat_at_tick(t0).bar; - let mut bar = first_bar; + notes.sort_unstable_by_key(|n| n.0); + let mut bar = s.tempo.bar_beat_at_tick(t0).bar; + let mut sounding: Vec<(i64, i64, u8)> = Vec::new(); + let (mut next, mut bars, mut heard) = (0, 0, 0); let mut chords: Vec = Vec::new(); let mut marks: Vec<(Samples, String)> = Vec::new(); let mut last = String::new(); - while chords.len() < 2000 { + while chords.len() < 2000 && bars < MAX_CHORD_BARS && heard < MAX_CHORD_NOTES { let a = s.tempo.bar_start_tick(bar).max(t0); let b = s.tempo.bar_start_tick(bar + 1).min(t1); if a >= t1 { break; } + while let Some(&n) = notes.get(next).filter(|n| n.0 < b) { + sounding.push(n); + next += 1; + } + sounding.retain(|n| n.1 > a); + if sounding.is_empty() { + match notes.get(next) { + Some(n) => bar = (bar + 1).max(s.tempo.bar_beat_at_tick(n.0).bar), + None => break, + } + continue; + } + bars += 1; + heard += sounding.len(); let mut w = [0.0f64; 12]; let mut bass: Option = None; - for (na, nb, pitch) in ¬es { + for (na, nb, pitch) in &sounding { let ov = (*nb).min(b) - (*na).max(a); if ov > 0 { if let Some(x) = w.get_mut(usize::from(*pitch) % 12) { @@ -901,6 +921,18 @@ mod tests { assert!(e.session().markers.iter().any(|m| m.ruler == 2)); } + #[test] + fn chords_far_down_the_timeline_are_found_without_walking_every_bar() { + let mut e = crate::demo::demo_engine(); + let keys = e.session().track_by_name("Keys").unwrap().id; + let far = 1i64 << 39; + if let Some(p) = e.session_mut().track_mut(keys).and_then(soundcraft_model::Track::playlist_mut) { + p.clips.iter_mut().for_each(|c| c.start += far); + } + let r = e.execute("event.extract_chords", &json!({"tracks": [keys.0], "start": 0, "end": far + 48_000 * 600})).unwrap(); + assert!(!r["chords"].as_array().unwrap().is_empty(), "{r}"); + } + #[test] fn beat_detective_finds_demo_tempo() { let mut e = crate::demo::demo_engine(); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ddbabf8..87bba99 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); @@ -275,6 +281,21 @@ fn fold_down_param(p: &Value, id: &str) -> Result { } fn bounce(e: &mut Engine, p: &Value) -> Result { + let job = bounce_job(e, p)?; + let (path, seconds) = (job.path().to_string_lossy().into_owned(), e.session().sample_rate.seconds(job.range().len())); + let done = job.run()?; + Ok(json!({ + "path": path, + "bytes": done.bytes, + "seconds": seconds, + "peak_db": done.peak_db, + "lufs": done.lufs, + "true_peak_db": done.true_peak_db, + })) +} + +/// The bounce `file.bounce_mix` runs for `p`, not rendered yet. +pub fn bounce_job(e: &Engine, p: &Value) -> Result { let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let r = range_param(e, "file.bounce_mix", p)?; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; @@ -292,11 +313,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result { let normalize = bool_or(p, "normalize", false); let fold = fold_down_param(p, "file.bounce_mix")?; let bus = bounce_bus(e, p)?; - let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?; - crate::io::write_file(&path, &bytes)?; - Ok( - json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}), - ) + crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold) } /// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`). diff --git a/crates/engine/src/cmd/midi.rs b/crates/engine/src/cmd/midi.rs index fe9fdc2..10bcd76 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, @@ -26,10 +26,6 @@ pub fn specs() -> Vec { ] } -fn clip_id(p: &Value, cmd: &str) -> Result { - p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) -} - fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> { match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) { Some(ClipContent::Midi { sequence }) => Ok(sequence), 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..cec7628 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -35,6 +35,11 @@ mod video; mod view; mod view_more; mod vst3; +mod zoom_toggle; + +pub use file::bounce_job; + +pub(crate) use edit::remember_selection; pub type Run = fn(&mut Engine, &Value) -> Result; /// `Err(reason)` = disabled. @@ -128,6 +133,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(instrument::specs()); v.extend(sampler::specs()); v.extend(video::specs()); + v.extend(zoom_toggle::specs()); v }) } @@ -238,9 +244,7 @@ pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result> if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) } } -/// Largest position accepted from parameters: 2^40 samples (about 265 days at 48 kHz). Clamping -/// here keeps every later addition and subtraction on positions far away from overflow. -pub const MAX_POSITION: Samples = 1 << 40; +pub use soundcraft_time::MAX_POSITION; /// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format. /// Values are clamped to ±[`MAX_POSITION`]. @@ -275,7 +279,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result Result { } } +pub fn clip_id(p: &Value, cmd: &str) -> Result { + p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required")) +} + pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec { if let Some(arr) = p.get("clips").and_then(Value::as_array) { return arr.iter().filter_map(Value::as_u64).map(ClipId).collect(); diff --git a/crates/engine/src/cmd/more_util.rs b/crates/engine/src/cmd/more_util.rs index 756106b..bf16982 100644 --- a/crates/engine/src/cmd/more_util.rs +++ b/crates/engine/src/cmd/more_util.rs @@ -132,9 +132,10 @@ pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) { } } -/// 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/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs index b3ca0d3..e999c86 100644 --- a/crates/engine/src/cmd/plugin_state.rs +++ b/crates/engine/src/cmd/plugin_state.rs @@ -1,6 +1,6 @@ //! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's -//! state from the audio engine right before saving and stores it on its insert (base64), from -//! where the mixer restores it into new instances. +//! state right before saving and stores it on its insert (base64), from where the mixer restores +//! it into new instances. use super::*; use serde_json::json; 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..e467234 100644 --- a/crates/engine/src/cmd/sampler.rs +++ b/crates/engine/src/cmd/sampler.rs @@ -55,16 +55,15 @@ 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}")))?; if buf.frames() == 0 || buf.num_channels() == 0 { return Err(EngineError::Io(format!("{name}: no audio"))); } - if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES { - return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)"))); - } + let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES) + .ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?; let channels = buf.num_channels(); let s = e.session_mut(); let src = crate::io::add_source(s, &name, buf, Some(&path), format.format); diff --git a/crates/engine/src/cmd/setup_more.rs b/crates/engine/src/cmd/setup_more.rs index 1d21e99..3d43675 100644 --- a/crates/engine/src/cmd/setup_more.rs +++ b/crates/engine/src/cmd/setup_more.rs @@ -5,10 +5,9 @@ use super::more_util::{set_text, store_value, text, toggle_flag, values_with_pre use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; +use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind}; use soundcraft_time::Timecode; use std::path::{Path, PathBuf}; -use std::sync::Arc; macro_rules! option { ($id:literal, $label:literal, $flag:literal) => { @@ -147,7 +146,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 +319,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})) } @@ -401,22 +402,10 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result { Ok(json!({"enabled": enabled})) } -/// 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 (_, 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); - buf.sample_rate = sr; - } - Some(buf) -} - fn import_session_data(e: &mut Engine, p: &Value) -> 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> = @@ -441,31 +430,29 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result { needed.sort_by_key(|x| x.0); needed.dedup(); let sr = e.session().sample_rate.hz(); - let decoded: Vec<(Source, Option)> = - needed.iter().filter_map(|sid| other.source(*sid).cloned()).map(|src| (src.clone(), load_source(&dir, &src, sr))).collect(); let s = e.session_mut(); + let mut files = crate::io::DecodedFiles::new(); let mut map: std::collections::BTreeMap = Default::default(); let mut missing = Vec::new(); - for (src, buf) in decoded { - let nid = SourceId(s.alloc()); + for src in needed.iter().filter_map(|sid| other.source(*sid)) { let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; let mut ns = src.clone(); - ns.id = nid; + ns.id = SourceId(s.alloc()); ns.path = abs.to_string_lossy().into_owned(); - match buf { - Some(b) => { - ns.frames = b.frames() as u64; + match crate::io::decode_file(&mut files, &abs, sr) { + Ok(audio) => { + ns.frames = audio.buffer.frames() as u64; ns.sample_rate = sr; ns.unsaved = true; - s.pool.insert(nid, Arc::new(SourceAudio::new(b))); + s.pool.insert(ns.id, audio); } - None => { + Err(_) => { ns.unsaved = false; missing.push(ns.path.clone()); } } + map.insert(src.id.0, ns.id); s.sources.push(ns); - map.insert(src.id.0, nid); } let mut out = Vec::new(); for mut tr in picked { @@ -556,8 +543,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..8791238 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -3,7 +3,7 @@ use super::*; use crate::cmd; use serde_json::json; -use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, TrackHeight, TrackKind}; +use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind}; pub fn specs() -> Vec { vec![ @@ -45,6 +45,8 @@ pub fn specs() -> Vec { has_selection, move_to_folder ), + cmd!("track.move_to_folder", "Move to Folder", [], None, "{tracks?, folder}", has_selection, move_into_folder), + cmd!("track.remove_from_folder", "Remove from Folder", [], None, "{tracks?}", has_selection, remove_from_folder), cmd!( "track.change_width", "Change Track Width", @@ -58,7 +60,21 @@ pub fn specs() -> Vec { cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename), cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments), cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color), - cmd!("track.height", "Track Height", [], None, "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme}", has_selection, height), + cmd!( + "track.height", + "Track Height", + [], + None, + "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme | points}", + has_selection, + height + ), + cmd!("track.height_up", "Increase Track Height", [], Some("Ctrl+Up"), "{tracks?} — one preset taller", has_selection, |e, p| height_step( + e, p, true + )), + cmd!("track.height_down", "Decrease Track Height", [], Some("Ctrl+Down"), "{tracks?} — one preset shorter", has_selection, |e, p| { + height_step(e, p, false) + }), cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|}", has_selection, view), cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag( e, @@ -98,7 +114,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 +144,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) { @@ -174,7 +193,7 @@ pub fn specs() -> Vec { tr.active_playlist = active; } let _ = src; - let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false); + let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false).unwrap_or_default(); Ok(json!({"clips": ids})) } ), @@ -239,6 +258,14 @@ fn new_track(e: &mut Engine, p: &Value) -> Result { t.instrument = Some(soundcraft_model::Insert::new(instrument.clone().unwrap_or_else(|| "subtractive_synth".into()))); } } + if kind == TrackKind::Folder { + let folder_name = s.track(id).map(|track| track.name.clone()).unwrap_or_else(|| "Folder".into()); + let bus_name = unique_folder_bus_name(s, &format!("{folder_name} Bus"), None); + let bus = s.add_bus(&bus_name, format); + if let Some(track) = s.track_mut(id) { + track.mixer.input = Route::Bus(bus); + } + } ids.push(id); } s.edit.selected_tracks = ids.clone(); @@ -385,6 +412,17 @@ fn flag( Ok(json!({"value": target, "tracks": tracks})) } +/// A routing folder owns its summing bus; never reuse a user bus with the same name. +fn unique_folder_bus_name(s: &Session, base: &str, own: Option) -> String { + for suffix in 1..=s.busses.len().saturating_add(2) { + let candidate = if suffix == 1 { base.to_string() } else { format!("{base} {suffix}") }; + if !s.busses.iter().any(|bus| bus.name == candidate && Some(bus.id) != own) { + return candidate; + } + } + format!("{base} {}", s.busses.len().saturating_add(3)) +} + fn move_to_folder(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.move_to_new_folder", p)?; let name = str_param(p, "name").map(str::to_string); @@ -398,7 +436,8 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { } // A routing folder sums its members through its own bus. let fname = s.track(fid).map(|x| x.name.clone()).unwrap_or_else(|| "Folder".into()); - let bus = s.add_bus(&format!("{fname} Bus"), ChannelFormat::Stereo); + let bus_name = unique_folder_bus_name(s, &format!("{fname} Bus"), None); + let bus = s.add_bus(&bus_name, ChannelFormat::Stereo); if let Some(f) = s.track_mut(fid) { f.mixer.input = Route::Bus(bus); } @@ -410,9 +449,118 @@ fn move_to_folder(e: &mut Engine, p: &Value) -> Result { } } } + place_under(s, fid, &tracks); Ok(json!({"folder": fid, "bus": bus})) } +/// Whether `track` sits inside `folder`, directly or through nested folders. +fn in_folder(s: &Session, track: TrackId, folder: TrackId) -> bool { + let mut current = s.track(track).and_then(|track| track.folder); + // The depth cap guards against a cyclic folder chain in a hostile session file. + for _ in 0..64 { + match current { + Some(parent) if parent == folder => return true, + Some(parent) => current = s.track(parent).and_then(|track| track.folder), + None => return false, + } + } + false +} + +/// Keep a folder's members together immediately below it, preserving their relative order. +fn place_under(s: &mut Session, folder: TrackId, tracks: &[TrackId]) { + // Moving a folder also moves its complete subtree as one stable block. + let moved: Vec<_> = s + .tracks + .iter() + .filter(|track| track.id != folder && (tracks.contains(&track.id) || tracks.iter().any(|root| in_folder(s, track.id, *root)))) + .cloned() + .collect(); + let moved_ids: Vec<_> = moved.iter().map(|track| track.id).collect(); + s.tracks.retain(|track| !moved_ids.contains(&track.id)); + let ids: Vec = s.tracks.iter().map(|track| track.id).collect(); + let Some(folder_index) = ids.iter().position(|track| *track == folder) else { return }; + let mut insert_at = folder_index + 1; + while ids.get(insert_at).is_some_and(|track| in_folder(s, *track, folder)) { + insert_at += 1; + } + for (offset, track) in moved.into_iter().enumerate() { + s.tracks.insert((insert_at + offset).min(s.tracks.len()), track); + } +} + +fn folder_bus(s: &Session, folder: TrackId) -> Option { + s.track(folder).map(|folder| folder.mixer.input.clone()).filter(|route| matches!(route, Route::Bus(_))) +} + +/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree. +fn root_tracks(s: &Session, tracks: Vec) -> Vec { + tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect() +} + +fn move_into_folder(e: &mut Engine, p: &Value) -> Result { + let folder = track_param(e, "track.move_to_folder", p, "folder")?.ok_or_else(|| bad("track.move_to_folder", "`folder` required"))?; + let tracks = tracks_required(e, "track.move_to_folder", p)?; + let session = e.session_mut(); + if !session.track(folder).is_some_and(Track::is_folder) { + return Err(bad("track.move_to_folder", "`folder` is not a folder track")); + } + // A folder cannot go inside itself or one of its descendants. + let tracks: Vec = tracks.into_iter().filter(|track| *track != folder && !in_folder(session, folder, *track)).collect(); + let tracks = root_tracks(session, tracks); + if tracks.is_empty() { + return Err(bad("track.move_to_folder", "nothing to move into that folder")); + } + let bus = folder_bus(session, folder); + for track in &tracks { + let old_bus = session.track(*track).and_then(|track| track.folder).and_then(|parent| folder_bus(session, parent)); + if let Some(moved) = session.track_mut(*track) { + moved.folder = Some(folder); + if let Some(bus) = &bus + && (moved.mixer.output == Route::Main || Some(&moved.mixer.output) == old_bus.as_ref()) + { + moved.mixer.output = bus.clone(); + } + } + } + place_under(session, folder, &tracks); + Ok(json!({"folder": folder, "moved": tracks.len()})) +} + +fn remove_from_folder(e: &mut Engine, p: &Value) -> Result { + let tracks = tracks_required(e, "track.remove_from_folder", p)?; + let session = e.session_mut(); + let tracks = root_tracks(session, tracks); + let mut removed = 0; + for track in &tracks { + let Some(folder) = session.track(*track).and_then(|track| track.folder) else { continue }; + let bus = folder_bus(session, folder); + let parent = session.track(folder).and_then(|folder| folder.folder); + let parent_bus = parent.and_then(|parent| folder_bus(session, parent)); + if let Some(moved) = session.track_mut(*track) { + moved.folder = parent; + if bus.as_ref() == Some(&moved.mixer.output) { + moved.mixer.output = parent_bus.unwrap_or(Route::Main); + } + removed += 1; + } + let moved: Vec<_> = + session.tracks.iter().filter(|candidate| candidate.id == *track || in_folder(session, candidate.id, *track)).cloned().collect(); + let moved_ids: Vec<_> = moved.iter().map(|candidate| candidate.id).collect(); + session.tracks.retain(|candidate| !moved_ids.contains(&candidate.id)); + if let Some(folder_index) = session.track_index(folder) { + let mut insert_at = folder_index + 1; + while session.tracks.get(insert_at).is_some_and(|candidate| in_folder(session, candidate.id, folder)) { + insert_at += 1; + } + for (offset, moved) in moved.into_iter().enumerate() { + session.tracks.insert((insert_at + offset).min(session.tracks.len()), moved); + } + } + } + Ok(json!({"removed": removed})) +} + fn change_width(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.change_width", p)?; let f = str_param(p, "format").and_then(ChannelFormat::from_id).ok_or_else(|| bad("track.change_width", "`format` required"))?; @@ -429,16 +577,36 @@ fn change_width(e: &mut Engine, p: &Value) -> Result { fn delete(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.delete", p)?; let s = e.session_mut(); + // Promote children of deleted folders and repair only routes that used the deleted + // folder's own bus. Custom outputs remain untouched. + let mut promoted = Vec::new(); + for child in &s.tracks { + if tracks.contains(&child.id) { + continue; + } + let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue }; + let mut parent = s.track(folder).and_then(|folder| folder.folder); + for _ in 0..64 { + match parent { + Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder), + _ => break, + } + } + promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent)))); + } + for (child, old_bus, parent, parent_bus) in promoted { + if let Some(child) = s.track_mut(child) { + child.folder = parent; + if old_bus.as_ref() == Some(&child.mixer.output) { + child.mixer.output = parent_bus.unwrap_or(Route::Main); + } + } + } s.tracks.retain(|t| !tracks.contains(&t.id)); s.edit.selected_tracks.retain(|t| !tracks.contains(t)); for g in &mut s.groups { g.members.retain(|t| !tracks.contains(t)); } - for t in &mut s.tracks { - if t.folder.is_some_and(|f| tracks.contains(&f)) { - t.folder = None; - } - } Ok(json!({"deleted": tracks.len()})) } @@ -449,8 +617,19 @@ fn rename(e: &mut Engine, p: &Value) -> Result { if s.tracks.iter().any(|x| x.name == name && x.id != t) { return Err(bad("track.rename", format!("a track named `{name}` already exists"))); } - if let Some(tr) = s.track_mut(t) { - tr.name = name.chars().take(64).collect(); + let name: String = name.chars().take(64).collect(); + let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) { + (TrackKind::Folder, Route::Bus(bus)) => Some(*bus), + _ => None, + }); + if let Some(track) = s.track_mut(t) { + track.name.clone_from(&name); + } + if let Some(bus) = bus { + let bus_name = unique_folder_bus_name(s, &format!("{name} Bus"), Some(bus)); + if let Some(bus) = s.busses.iter_mut().find(|candidate| candidate.id == bus) { + bus.name = bus_name; + } } Ok(json!({"name": name})) } @@ -485,7 +664,10 @@ fn color(e: &mut Engine, p: &Value) -> Result { fn height(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.height", p)?; - let h = str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?; + let h = match p.get("height").and_then(Value::as_f64) { + Some(points) => TrackHeight::from_points(points as f32), + None => str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?, + }; let s = e.session_mut(); for t in &tracks { if let Some(tr) = s.track_mut(*t) { @@ -495,6 +677,26 @@ fn height(e: &mut Engine, p: &Value) -> Result { Ok(json!({"height": h})) } +fn height_step(e: &mut Engine, p: &Value, taller: bool) -> Result { + let tracks = tracks_required(e, if taller { "track.height_up" } else { "track.height_down" }, p)?; + let mut steps = Vec::new(); + for tr in tracks.iter().filter_map(|t| e.session().track(*t)) { + let h = tr.height.step(taller); + if h != tr.height { + steps.push((tr.id, h)); + } + } + if !steps.is_empty() { + let s = e.session_mut(); + for (t, h) in &steps { + if let Some(tr) = s.track_mut(*t) { + tr.height = *h; + } + } + } + Ok(json!({"changed": steps.len()})) +} + fn view(e: &mut Engine, p: &Value) -> Result { let tracks = tracks_required(e, "track.view", p)?; let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string(); @@ -677,7 +879,108 @@ mod tests { assert!(r["bus"].is_u64()); let s = e.session(); let kick = s.track_by_name("Kick").unwrap(); + let snare = s.track_by_name("Snare").unwrap(); + let folder = s.tracks.iter().find(|track| track.kind == TrackKind::Folder).unwrap(); assert!(matches!(kick.mixer.output, Route::Bus(_))); - assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output)); + assert_eq!(folder.mixer.input, kick.mixer.output); + assert_eq!(folder.mixer.input, snare.mixer.output); + assert_eq!(s.track_index(folder.id).unwrap() + 1, s.track_index(kick.id).unwrap()); + assert_eq!(s.track_index(kick.id).unwrap() + 1, s.track_index(snare.id).unwrap()); + } + + #[test] + fn empty_folder_owns_a_unique_bus_and_renames_it() { + let mut e = crate::demo::demo_engine(); + let first = e.execute("track.new", &json!({"kind": "folder", "name": "Guitars"})).unwrap(); + let second = e.execute("track.new", &json!({"kind": "folder", "name": "Vocals"})).unwrap(); + let first = TrackId(first["tracks"][0].as_u64().unwrap()); + let second = TrackId(second["tracks"][0].as_u64().unwrap()); + let first_bus = match e.session().track(first).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("folder did not receive a bus"), + }; + let second_bus = match e.session().track(second).unwrap().mixer.input { + Route::Bus(bus) => bus, + _ => panic!("folder did not receive a bus"), + }; + assert_ne!(first_bus, second_bus); + e.session_mut().add_bus("Percussion Bus", ChannelFormat::Stereo); + e.execute("track.rename", &json!({"track": first.0, "name": "Percussion"})).unwrap(); + let renamed = e.session().busses.iter().find(|bus| bus.id == first_bus).unwrap(); + assert_eq!(renamed.name, "Percussion Bus 2"); + } + + #[test] + fn tracks_can_move_between_nested_folders_and_leave() { + let mut e = crate::demo::demo_engine(); + let outer = e.execute("track.new", &json!({"kind": "folder", "name": "Band"})).unwrap(); + let inner = e.execute("track.new", &json!({"kind": "folder", "name": "Drums"})).unwrap(); + let outer = TrackId(outer["tracks"][0].as_u64().unwrap()); + let inner = TrackId(inner["tracks"][0].as_u64().unwrap()); + + e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap(); + e.execute("track.move_to_folder", &json!({"tracks": [inner.0], "folder": outer.0})).unwrap(); + assert!(e.execute("track.move_to_folder", &json!({"tracks": [outer.0], "folder": inner.0})).is_err()); + + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, Some(inner)); + assert_eq!(kick.mixer.output, e.session().track(inner).unwrap().mixer.input); + assert_eq!(e.session().track_index(outer).unwrap() + 1, e.session().track_index(inner).unwrap()); + assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap()); + + e.execute("track.remove_from_folder", &json!({"tracks": ["Kick"]})).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, Some(outer)); + assert_eq!(kick.mixer.output, e.session().track(outer).unwrap().mixer.input); + + e.execute("track.move_to_folder", &json!({"tracks": ["Kick"], "folder": inner.0})).unwrap(); + e.execute("track.remove_from_folder", &json!({"tracks": [inner.0]})).unwrap(); + let inner_track = e.session().track(inner).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(inner_track.folder, None); + assert_eq!(inner_track.mixer.output, Route::Main); + assert_eq!(kick.folder, Some(inner)); + assert_eq!(e.session().track_index(inner).unwrap() + 1, e.session().track_index(kick.id).unwrap()); + + e.execute("track.delete", &json!({"tracks": [inner.0]})).unwrap(); + let kick = e.session().track_by_name("Kick").unwrap(); + assert_eq!(kick.folder, None); + assert_eq!(kick.mixer.output, Route::Main); + } + + #[test] + fn heights_take_points_and_step_through_the_presets() { + let mut e = crate::demo::demo_engine(); + let height = |e: &Engine, name: &str| e.session().track_by_name(name).map(|t| t.height); + e.execute("track.height", &json!({"tracks": ["Kick"], "height": 57})).unwrap(); + assert_eq!(height(&e, "Kick"), Some(TrackHeight::Custom(57))); + e.execute("track.height_up", &json!({"tracks": ["Kick", "Snare"]})).unwrap(); + assert_eq!((height(&e, "Kick"), height(&e, "Snare")), (Some(TrackHeight::Medium), Some(TrackHeight::Large))); + e.execute("track.height", &json!({"tracks": ["Kick"], "height": "extreme"})).unwrap(); + let steps = e.undo_history().len(); + assert_eq!(e.execute("track.height_up", &json!({"tracks": ["Kick"]})).unwrap()["changed"], 0); + assert_eq!(e.undo_history().len(), steps); + e.execute("track.height_down", &json!({"tracks": ["Kick"]})).unwrap(); + assert_eq!(height(&e, "Kick"), Some(TrackHeight::Jumbo)); + } + + #[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/cmd/zoom_toggle.rs b/crates/engine/src/cmd/zoom_toggle.rs new file mode 100644 index 0000000..d91d515 --- /dev/null +++ b/crates/engine/src/cmd/zoom_toggle.rs @@ -0,0 +1,88 @@ +//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. + +use super::*; +use crate::cmd; +use serde_json::json; +use soundcraft_model::{TrackHeight, ZoomToggle}; + +pub fn specs() -> Vec { + vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)] +} + +fn toggle(e: &mut Engine, p: &Value) -> Result { + let s = e.session_mut(); + if let Some(back) = s.edit.zoom_toggle.take() { + s.edit.zoom.samples_per_px = back.samples_per_px.clamp(0.25, 1_000_000.0); + s.edit.zoom.scroll = back.scroll.clamp(0, MAX_POSITION); + for t in &mut s.tracks { + t.height = back.heights.iter().find(|(id, _)| *id == t.id).map_or(t.height, |(_, h)| *h); + } + return Ok(json!({"on": false})); + } + let heights: Vec<_> = s.tracks.iter().filter(|t| !t.hidden && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.height)).collect(); + // Rows sit 1 point apart. + let each = f64_or(p, "height_px", 600.0) / heights.len().max(1) as f64 - 1.0; + let fit = TrackHeight::Fit(each.clamp(16.0, f64::from(u16::MAX)) as u16); + for t in s.tracks.iter_mut().filter(|t| heights.iter().any(|(id, _)| *id == t.id)) { + t.height = fit; + } + let (r, z) = (s.edit.selection, &mut s.edit.zoom); + s.edit.zoom_toggle = Some(ZoomToggle { samples_per_px: z.samples_per_px, scroll: z.scroll, heights }); + if !r.is_empty() { + z.samples_per_px = (r.len() as f64 / f64_or(p, "width_px", 1200.0).max(100.0)).clamp(0.25, 1_000_000.0); + z.scroll = r.start; + } + Ok(json!({"on": true})) +} + +#[cfg(test)] +mod tests { + use super::*; + use TrackHeight::{Fit, Large, Medium}; + + fn engine(select: Value) -> Engine { + let mut e = Engine::default(); + e.execute("track.new", &json!({"count": 3})).unwrap(); + e.execute("view.zoom_set", &json!({"samples_per_px": 500.0})).unwrap(); + e.execute("view.scroll", &json!({"to": 1000})).unwrap(); + e.execute("edit.select", &select).unwrap(); + e + } + + fn view(e: &Engine) -> (f64, Samples, Vec) { + let s = e.session(); + (s.edit.zoom.samples_per_px, s.edit.zoom.scroll, s.tracks.iter().map(|t| t.height).collect()) + } + + #[test] + fn the_selection_fills_the_window_and_toggling_again_goes_back() { + let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000})); + let steps = e.undo_history().len(); + assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true})); + assert_eq!(view(&e), (48.0, 48_000, vec![Fit(249), Medium, Fit(249)])); + e.execute("view.zoom_in", &json!({})).unwrap(); + e.execute("track.height", &json!({"tracks": ["Audio 2"], "height": "large"})).unwrap(); + assert_eq!(e.execute("view.zoom_toggle", &json!({})).unwrap(), json!({"on": false})); + assert_eq!(view(&e), (500.0, 1000, vec![Medium, Large, Medium])); + assert_eq!(e.undo_history().len(), steps + 1); + e.execute("edit.select", &json!({"tracks": ["Audio 2"], "start": 5000})).unwrap(); + e.execute("view.zoom_toggle", &json!({"height_px": 300.0})).unwrap(); + assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium])); + } + + #[test] + fn hostile_sizes_and_stored_views_stay_in_range() { + let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION})); + e.execute("view.zoom_toggle", &json!({"width_px": -5.0, "height_px": 1e300})).unwrap(); + assert_eq!((view(&e).0, view(&e).2[0]), (1_000_000.0, Fit(u16::MAX))); + e.execute("view.zoom_toggle", &json!({})).unwrap(); + e.execute("view.zoom_toggle", &json!({"width_px": "wide", "height_px": -1.0})).unwrap(); + assert_eq!(view(&e).2[0], Fit(16)); + let audio_1 = e.session().tracks[0].id; + let heights = vec![(TrackId(9999), TrackHeight::Jumbo), (audio_1, Fit(0))]; + e.session_mut().edit.zoom_toggle = Some(ZoomToggle { samples_per_px: -1.0, scroll: Samples::MIN, heights }); + e.execute("view.zoom_toggle", &json!({})).unwrap(); + assert_eq!(view(&e), (0.25, 0, vec![Fit(0), Medium, Medium])); + assert_eq!(Fit(0).points(), 16.0); + } +} diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 1e7daa0..bdefafa 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -228,7 +228,7 @@ pub fn demo_session() -> Session { let len = total as i64 - start; let cid = s.new_clip_id(); let mut clip = Clip::audio(cid, name, src, start, start, len - (srf * 1.5) as i64); - clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve }; + clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve, curvature: 0.0 }; crate::edit::place_clip(s, t, clip); t }; diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e6..5531953 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -5,8 +5,16 @@ //! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle //! mode also ripples later clips. -use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId}; +use soundcraft_model::{AutoParam, AutomationPoint, Clip, ClipContent, ClipId, Fade, FadeShape, Session, Track, TrackId}; use soundcraft_time::{Range, SampleRate, Samples, TempoMap}; +use std::collections::{HashMap, HashSet}; + +/// Edit-state flag: the Edit cursor and selections snap to the grid in Shuffle, Slip and Spot. +pub const SNAP_TO_GRID: &str = "edit.snap_to_grid"; +/// Edit-state flag: Shuffle mode can't be chosen. +pub const SHUFFLE_LOCK: &str = "edit.shuffle_lock"; +/// Edit-state flag: the Grid mode last used was Relative, so choosing Grid again picks it. +pub const RELATIVE_GRID: &str = "edit.relative_grid"; /// Context needed to split MIDI clips in musical time. #[derive(Clone, Copy)] @@ -101,8 +109,9 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize { count } -/// Remove material inside `range` on a track. In shuffle mode later clips move left. -/// Returns the removed material as clips positioned relative to `range.start`. +/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the +/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative +/// to `range.start`. pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec { if range.is_empty() { return Vec::new(); @@ -122,14 +131,9 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) } }); if shuffle { - let len = range.len(); - for c in &mut pl.clips { - if c.start >= range.end { - c.start -= len; - } - } + let moved = ripple(s, track, range.end, -range.len()); + follow_automation(s, track, range.end, moved); } - pl.sort(); removed } @@ -155,20 +159,42 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec { out } -/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless -/// `shuffle`, which first pushes later material right by `length`. -pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec { - let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0)); - if shuffle { - separate_at(s, track, at); - if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { - for c in &mut pl.clips { - if c.start >= at { - c.start += length; - } +/// Merge the MIDI inside `range` into one clip covering the whole range, gaps included, as it +/// sounds (muted clips stay silent). Returns false for tracks that aren't MIDI or instrument tracks. +pub fn consolidate_midi(s: &mut Session, track: TrackId, range: Range) -> bool { + let Some(name) = s.track(track).filter(|t| t.kind.is_midi()).map(|t| t.name.clone()) else { return false }; + let mut merged = soundcraft_midi::Sequence::default(); + { + let t = TimeCtx::of(s); + let base = t.ticks(range.start); + for part in copy_range(s, track, range).into_iter().filter(|c| !c.muted) { + let at = t.ticks(range.start.saturating_add(part.start)).saturating_sub(base); + let len = t.ticks(range.start.saturating_add(part.end())).saturating_sub(base).saturating_sub(at); + let ClipContent::Midi { sequence } = part.content else { continue }; + for mut n in sequence.notes.into_iter().filter(|n| (0..len).contains(&n.start)) { + n.length = n.length.min(len - n.start); + n.start += at; + merged.notes.push(n); + } + for mut c in sequence.ctrls.into_iter().filter(|c| (0..len).contains(&c.tick)) { + c.tick += at; + merged.ctrls.push(c); } } } + merged.sort(); + let id = s.new_clip_id(); + place_clip(s, track, Clip::midi(id, name, range.start, range.len(), merged)); + true +} + +/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless +/// `shuffle`, which first makes room for `length` (see [`make_room`], whose refusal it returns). +pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Result, &'static str> { + let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0)); + if shuffle { + make_room(s, track, at, length)?; + } let mut ids = Vec::new(); for c in clips { let mut nc = c.clone(); @@ -177,7 +203,7 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt ids.push(nc.id); place_clip(s, track, nc); } - ids + Ok(ids) } /// Place one clip, overwriting whatever it covers. @@ -189,21 +215,146 @@ pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) { } } -/// Insert silence: shift material at/after `range.start` right by the range length. +/// Insert silence: shift material at/after `range.start` right by the range length (Insert Time; +/// Shuffle mode edits use [`make_room`]). pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) { if range.is_empty() { return; } separate_at(s, track, range.start); + shift_from(s, track, range.start, range.len()); +} + +/// Move every clip that starts at or after `from` by `by`, locked or not. +pub fn shift_from(s: &mut Session, track: TrackId, from: Samples, by: Samples) { if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) { - for c in &mut pl.clips { - if c.start >= range.start { - c.start += range.len(); - } + for c in pl.clips.iter_mut().filter(|c| c.start >= from) { + c.start = c.start.saturating_add(by); } } } +/// Time- or edit-locked: the clip doesn't move, and Shuffle stops at it. +pub fn locked(c: &Clip) -> bool { + c.time_locked || c.edit_locked +} + +/// A clip's place on its track, for working out Shuffle layouts without touching the clips. +#[derive(Clone, Copy)] +struct Span { + id: ClipId, + start: Samples, + end: Samples, + locked: bool, +} + +fn spans(clips: &[Clip]) -> Vec { + clips.iter().map(|c| Span { id: c.id, start: c.start, end: c.end(), locked: locked(c) }).collect() +} + +/// Shuffle mode's ripple: the spans from `from` on move by `by`, up to the first locked one, which +/// stays put with everything after it. A move left stops at the session start, keeping their +/// spacing. Returns how far they moved and where the lock is (`Samples::MAX` when none). +fn ripple_spans(spans: &mut [Span], from: Samples, by: Samples) -> (Samples, Samples) { + let lock = spans.iter().filter(|c| c.locked && c.start >= from).map(|c| c.start).min().unwrap_or(Samples::MAX); + let moves = |c: &Span| c.start >= from && c.start < lock; + let floor = spans.iter().filter(|c| moves(c)).map(|c| c.start).min().unwrap_or(0).max(0); + let by = by.max(-floor); + for c in spans.iter_mut().filter(|c| moves(c)) { + c.start = c.start.saturating_add(by); + c.end = c.end.saturating_add(by); + } + (by, lock) +} + +/// [`ripple_spans`] on a track's clips. +fn ripple(s: &mut Session, track: TrackId, from: Samples, by: Samples) -> (Samples, Samples) { + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return (0, Samples::MAX) }; + let mut sp = spans(&pl.clips); + let moved = ripple_spans(&mut sp, from, by); + for (c, n) in pl.clips.iter_mut().zip(&sp) { + c.start = n.start; + } + pl.sort(); + moved +} + +/// Automation Follows Edit for a Shuffle ripple (`moved` from [`ripple`]): the points from `from` +/// up to the lock move with the clips, and when material closes up the points in the span that +/// goes away go too. +fn follow_automation(s: &mut Session, track: TrackId, from: Samples, (by, lock): (Samples, Samples)) { + if !s.edit.automation_follows_edit || by == 0 { + return; + } + let Some(t) = s.track_mut(track) else { return }; + for l in &mut t.automation { + if by < 0 { + l.clear_range(from.saturating_add(by), from); + } + for p in l.points.iter_mut().filter(|p| p.at >= from && p.at < lock) { + p.at = p.at.saturating_add(by); + } + l.points.sort_by_key(|p| p.at); + l.points.dedup_by_key(|p| p.at); + } +} + +/// With Automation Follows Edit, take the points in `[start, end)` off a track, timed from `origin`. +fn take_automation(s: &mut Session, track: TrackId, start: Samples, end: Samples, origin: Samples) -> Vec<(AutoParam, AutomationPoint)> { + let mut out = Vec::new(); + if !s.edit.automation_follows_edit || end <= start { + return out; + } + let Some(t) = s.track_mut(track) else { return out }; + for l in &mut t.automation { + let taken = l.points.iter().filter(|p| p.at >= start && p.at < end); + out.extend(taken.map(|p| (l.param.clone(), AutomationPoint { at: p.at.saturating_sub(origin), value: p.value }))); + l.clear_range(start, end); + } + out +} + +fn put_automation(s: &mut Session, track: TrackId, origin: Samples, points: &[(AutoParam, AutomationPoint)]) { + let Some(t) = s.track_mut(track) else { return }; + for (param, p) in points { + t.lane_mut(param).set_point(origin.saturating_add(p.at), p.value); + } +} + +/// How far Shuffle mode can push the material from `at` on before it runs into a locked clip +/// (`Samples::MAX` when none follows). +pub fn shuffle_room(s: &Session, track: TrackId, at: Samples) -> Samples { + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + let Some(lock) = clips.iter().filter(|c| locked(c) && c.end() > at).map(|c| c.start).min() else { return Samples::MAX }; + let end = clips.iter().filter(|c| c.end() > at && c.start < lock).map(Clip::end).fold(at, Samples::max); + lock.saturating_sub(end) +} + +/// Why Shuffle can't make room: a locked clip follows too closely. +pub const NO_ROOM: &str = "not enough room before a locked clip"; +/// Why Shuffle can't make room: the point is inside a crossfade, which can't be pulled apart. +pub const IN_CROSSFADE: &str = "can't make room inside a crossfade"; + +/// Shuffle mode's room for `len` at `at`: later clips (and with Automation Follows Edit their +/// automation) move right. Refused, with nothing changed, when a locked clip is in the way or +/// `at` is inside a crossfade. +pub fn make_room(s: &mut Session, track: TrackId, at: Samples, len: Samples) -> Result<(), &'static str> { + if len <= 0 { + return Ok(()); + } + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + if clips.iter().filter(|c| c.start <= at && at < c.end()).count() > 1 { + return Err(IN_CROSSFADE); + } + if shuffle_room(s, track, at) < len { + return Err(NO_ROOM); + } + separate_at(s, track, at); + let moved = ripple(s, track, at, len); + follow_automation(s, track, at, moved); + Ok(()) +} + /// Trim clips to keep only material inside `range`. pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { let tempo = s.tempo.clone(); @@ -221,6 +372,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize { n += 1; } } + pl.sort(); n } @@ -279,14 +431,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize { healed } -/// Move clips by `delta` (and optionally to another track), overwriting what they land on. -pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option) -> usize { +/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them +/// elsewhere (see [`destinations`]). +pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize { + let ids: HashSet = ids.iter().copied().collect(); let mut moving: Vec<(TrackId, Clip)> = Vec::new(); for t in &mut s.tracks { let tid = t.id; if let Some(pl) = t.playlist_mut() { pl.clips.retain(|c| { - if ids.contains(&c.id) && !c.time_locked && !c.edit_locked { + if ids.contains(&c.id) && !locked(c) { moving.push((tid, c.clone())); false } else { @@ -296,13 +450,311 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Opt } } let n = moving.len(); - for (tid, mut c) in moving { + let placed = moving.into_iter().map(|(tid, mut c)| { c.start = c.start.saturating_add(delta).max(0); - place_clip(s, to_track.unwrap_or(tid), c); - } + (lands(dest, tid), c) + }); + put_clips(s, placed.collect()); n } +/// Place moved clips, overwriting what they cover but not each other, so a crossfaded pair +/// moved together stays a crossfade. +fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) { + for (t, c) in &placed { + clear_range(s, *t, c.range(), false); + } + for (t, c) in placed { + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips.push(c); + pl.sort(); + } + } +} + +/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level. +pub fn shown(s: &Session, t: &Track) -> bool { + !t.hidden && !folder_collapsed(s, t) +} + +/// Hidden because a folder at any level above this track is closed. +fn folder_collapsed(s: &Session, track: &Track) -> bool { + let mut current = track.folder; + for _ in 0..64 { + let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false }; + if !folder.folder_open { + return true; + } + current = folder.folder; + } + false +} + +/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance +/// from it among the tracks the Edit window shows, or among all tracks when a hidden one is +/// involved. None when one would land past either end, or on a track that can't hold its clips. +pub fn destinations(s: &Session, ids: &[ClipId], from: TrackId, to: TrackId) -> Option> { + let ids: HashSet = ids.iter().copied().collect(); + let holds = |t: &Track| t.clips().iter().any(|c| ids.contains(&c.id)); + let visible = s.tracks.iter().filter(|t| t.id == from || t.id == to || holds(t)).all(|t| shown(s, t)); + let rows: Vec<&Track> = s.tracks.iter().filter(|t| !visible || shown(s, t)).collect(); + let row = |id: TrackId| rows.iter().position(|t| t.id == id).and_then(|i| isize::try_from(i).ok()); + let shift = row(to)? - row(from)?; + s.tracks + .iter() + .filter(|t| holds(t)) + .map(|t| { + let d = rows.get(usize::try_from(row(t.id)? + shift).ok()?)?; + (d.kind.has_playlist() && (d.kind == t.kind || d.kind.is_midi() && t.kind.is_midi())).then_some((t.id, d.id)) + }) + .collect() +} + +/// The track that clips on `t` land on under a [`destinations`] map. +pub fn lands(dest: &[(TrackId, TrackId)], t: TrackId) -> TrackId { + dest.iter().find(|(from, _)| *from == t).map_or(t, |(_, d)| *d) +} + +/// The earliest of clips `ids` that can move (neither time- nor edit-locked). +pub fn first_movable<'a>(s: &'a Session, ids: &[ClipId]) -> Option<&'a Clip> { + ids.iter().filter_map(|id| s.find_clip(*id)).map(|(_, c)| c).filter(|c| !locked(c)).min_by_key(|c| c.start) +} + +/// One Shuffle lift: where the hole the clip leaves starts and how long it is, how far the clips +/// after it actually close up (none past a lock, none before the session start) and where the +/// first locked clip after it is. +#[derive(Clone, Copy)] +struct Lift { + id: ClipId, + start: Samples, + from: Samples, + shift: Samples, + by: Samples, + lock: Samples, +} + +/// Lifting the movable `moving` clips off a track's `spans` the Shuffle way, right to left, in +/// one pass: the lifts in that order, and the spans that stay at the starts they close up to. +/// A hole starts where the clips before it end (a crossfade partner keeps its material) and +/// closes up to the next clip, so silence after the lifted clip stays. Clips are ordered by +/// start, then id, so of two stacked clips the later one counts as after the other. +fn lift_plan(mut sp: Vec, moving: &HashSet) -> (Vec, Vec) { + sp.sort_by_key(|c| (c.start, c.id)); + let lifted = |c: &Span| !c.locked && moving.contains(&c.id); + let reach: Vec = std::iter::once(Samples::MIN) + .chain(sp.iter().scan(Samples::MIN, |far, c| { + *far = (*far).max(c.end); + Some(*far) + })) + .collect(); + let mut lifts = Vec::new(); + let (mut next, mut next_moves, mut lock) = (None, false, Samples::MAX); + for (i, c) in sp.iter().enumerate().rev() { + if !lifted(c) { + (next, next_moves) = (Some(c.start), !c.locked); + if c.locked { + lock = c.start; + } + continue; + } + let from = reach.get(i).map_or(c.start, |far| c.start.max(*far)); + let shift = next.unwrap_or(c.end).min(c.end).saturating_sub(from).max(0); + let by = if next_moves { -shift.min(next.unwrap_or(0).max(0)) } else { 0 }; + next = next.map(|n| n.saturating_add(by)); + lifts.push(Lift { id: c.id, start: c.start, from, shift, by, lock }); + } + let (mut by, mut pending) = (0, lifts.iter().rev()); + let mut staying = Vec::with_capacity(sp.len() - lifts.len()); + for c in sp { + if lifted(&c) { + by += pending.next().map_or(0, |l| l.by); + } else if c.locked { + by = 0; + staying.push(c); + } else { + staying.push(Span { start: c.start.saturating_add(by), end: c.end.saturating_add(by), ..c }); + } + } + (lifts, staying) +} + +/// Where Shuffle can land material on `track` once the movable `moving` clips have left it (later +/// clips close each gap): the clip boundaries, never inside a clip or a crossfade, sorted. Empty +/// for an empty track. +pub fn shuffle_slots(s: &Session, track: TrackId, moving: &[ClipId]) -> Vec { + let clips = s.track(track).map(|t| t.clips()).unwrap_or_default(); + let (_, mut sp) = lift_plan(spans(clips), &moving.iter().copied().collect()); + sp.sort_by_key(|c| c.start); + let reach: Vec = sp + .iter() + .scan(Samples::MIN, |far, c| { + *far = (*far).max(c.end); + Some(*far) + }) + .collect(); + let inside = |b: Samples| reach.get(sp.partition_point(|c| c.start < b).wrapping_sub(1)).is_some_and(|far| *far > b); + let mut slots: Vec = sp.iter().flat_map(|c| [c.start, c.end]).filter(|b| !inside(*b)).collect(); + slots.sort_unstable(); + slots.dedup(); + slots +} + +/// The slot nearest `at` (the earlier one on a tie), or the session start when there are none. +pub fn nearest_slot(slots: &[Samples], at: Samples) -> Samples { + let i = slots.partition_point(|b| *b < at); + [i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|i| slots.get(i)).copied().min_by_key(|b| b.abs_diff(at)).unwrap_or(0) +} + +/// Where Shuffle mode lands material dropped at `at` on `track` (see [`shuffle_slots`]). +pub fn shuffle_slot(s: &Session, track: TrackId, at: Samples, moving: &[ClipId]) -> Samples { + nearest_slot(&shuffle_slots(s, track, moving), at) +} + +/// Lift the movable `moving` clips off a track the Shuffle way (see [`lift_plan`]); with +/// Automation Follows Edit their automation leaves with them. Returns the clips, each with that +/// automation timed from its start. +fn lift_all(s: &mut Session, track: TrackId, moving: &HashSet) -> Vec<(Clip, Vec<(AutoParam, AutomationPoint)>)> { + let Some(clips) = s.track(track).map(|t| spans(t.clips())) else { return Vec::new() }; + let (lifts, staying) = lift_plan(clips, moving); + let mut points: HashMap> = HashMap::new(); + for l in &lifts { + points.insert(l.id, take_automation(s, track, l.from, l.from.saturating_add(l.shift), l.start)); + follow_automation(s, track, l.from.saturating_add(l.shift), (l.by, l.lock)); + } + let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() }; + let starts: HashMap = staying.iter().map(|c| (c.id, c.start)).collect(); + let (out, kept): (Vec, Vec) = std::mem::take(&mut pl.clips).into_iter().partition(|c| points.contains_key(&c.id)); + pl.clips = kept; + for c in &mut pl.clips { + if let Some(start) = starts.get(&c.id) { + c.start = *start; + } + } + pl.sort(); + out.into_iter() + .map(|c| { + let p = points.remove(&c.id).unwrap_or_default(); + (c, p) + }) + .collect() +} + +/// The movable clips `ids` grouped by the track they land on (per `dest`): each destination and +/// the start of its earliest clip, which Shuffle lands on a slot; the others keep their distance. +pub fn shuffle_groups(s: &Session, ids: &[ClipId], dest: &[(TrackId, TrackId)]) -> Vec<(TrackId, Samples)> { + let ids: HashSet = ids.iter().copied().collect(); + let mut groups: Vec<(TrackId, Samples)> = Vec::new(); + for t in &s.tracks { + for c in t.clips().iter().filter(|c| ids.contains(&c.id) && !locked(c)) { + let d = lands(dest, t.id); + match groups.iter_mut().find(|g| g.0 == d) { + Some(g) => g.1 = g.1.min(c.start), + None => groups.push((d, c.start)), + } + } + } + groups +} + +/// Move clips in Shuffle mode: each leaves its track (later clips close the gap, see +/// [`lift_plan`]), and the clips bound for each track land together, the earliest on the +/// [`shuffle_slot`] nearest `delta` away, pushing later clips right. With Automation Follows Edit +/// their automation travels with them. Refused, with the reason, when a locked clip or a +/// crossfade leaves no room. +pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> Result { + let groups: Vec<(TrackId, Samples, Samples)> = + shuffle_groups(s, ids, dest).into_iter().map(|(d, first)| (d, first, shuffle_slot(s, d, first.saturating_add(delta), ids))).collect(); + let moving: HashSet = ids.iter().copied().collect(); + let sources: Vec = s.tracks.iter().filter(|t| t.clips().iter().any(|c| moving.contains(&c.id) && !locked(c))).map(|t| t.id).collect(); + let mut lifted = Vec::new(); + for t in sources { + lifted.extend(lift_all(s, t, &moving).into_iter().map(|(c, points)| (lands(dest, t), c, points))); + } + for (d, first, at) in groups { + let block: Vec<&(TrackId, Clip, Vec<(AutoParam, AutomationPoint)>)> = lifted.iter().filter(|l| l.0 == d).collect(); + let len = block.iter().map(|l| l.1.end()).max().unwrap_or(first).saturating_sub(first); + make_room(s, d, at, len)?; + let mut placed = Vec::new(); + for (_, c, points) in block { + let mut c = c.clone(); + c.start = at.saturating_add(c.start.saturating_sub(first)); + put_automation(s, d, c.start, points); + placed.push(c); + } + if let Some(pl) = s.track_mut(d).and_then(|t| t.playlist_mut()) { + pl.clips.extend(placed); + pl.sort(); + } + } + Ok(lifted.len()) +} + +/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its +/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole +/// grid steps, keeping its offset from the grid. +pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples { + let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate); + let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 }; + let anchor = clip.start.saturating_add(sync); + let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor }; + g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from) +} + +/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In +/// Shuffle mode the start stays put (the material slides under it) and later clips follow the +/// change, up to a locked clip; otherwise a trim stops at the neighbouring clips. +pub fn trim_clip(s: &mut Session, id: ClipId, start: Option, end: Option, shuffle: bool) -> Option { + let (track, old) = s.find_clip(id).filter(|(_, c)| !c.edit_locked).map(|(t, c)| (t, c.clone()))?; + let (mut lo, mut hi) = (Samples::MIN, Samples::MAX); + if shuffle { + let room = shuffle_room(s, track, old.end()).max(0); + lo = old.start.saturating_sub(room); + hi = old.end().saturating_add(room); + } else { + let others = s.track(track).map(|t| t.clips()).unwrap_or_default().iter().filter(|c| c.id != id); + if let Some(prev) = others.clone().filter(|c| c.start < old.start).map(Clip::end).max() { + lo = lo.max(prev.min(old.start)); + } + if let Some(next) = others.filter(|c| c.start > old.start).map(|c| c.start).min() { + hi = hi.min(next.max(old.end())); + } + lo = lo.max(0); + } + let file = |c: &Clip| { + let start = c.start.saturating_sub(c.source_offset()); + material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end()))) + }; + let t = TimeCtx::of(s); + let mut c = old.clone(); + if let Some(at) = start { + c.trim_start_to(at.max(file(&c).map_or(lo, |(f, _)| lo.max(f)))); + rebase_midi_start(&mut c, old.start, t); + if shuffle { + c.start = old.start; + } + } + if let Some(at) = end { + c.trim_end_to(at.min(file(&c).map_or(hi, |(_, f)| hi.min(f)))); + } + let pl = s.track_mut(track).and_then(|t| t.playlist_mut())?; + *pl.clip_mut(id)? = c.clone(); + pl.sort(); + if shuffle { + let moved = ripple(s, track, old.start.saturating_add(1), c.end().saturating_sub(old.end())); + follow_automation(s, track, old.end(), moved); + } + Some(c) +} + +/// Length of the file or movie under a clip; None for MIDI, which has no end. +fn material_len(s: &Session, c: &Clip) -> Option { + match c.content { + ClipContent::Audio { source, .. } => s.source(source).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)), + ClipContent::Video { source, .. } => s.video(source).map(|v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))), + ClipContent::Midi { .. } => None, + } +} + /// Create fades from a selection (the incumbent's Edit › Fades › Create semantics): /// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out. pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize { @@ -311,23 +763,7 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh } let handles: Vec<(ClipId, Samples)> = { let Some(tr) = s.track(track) else { return 0 }; - tr.clips() - .iter() - .map(|c| { - let avail = match c.content { - ClipContent::Audio { source, offset } => { - let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX)); - total.saturating_sub(offset).saturating_sub(c.length) - } - ClipContent::Video { source, offset } => { - let total = s.video(source).map_or(0, |v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))); - total.saturating_sub(offset).saturating_sub(c.length) - } - ClipContent::Midi { .. } => 0, - }; - (c.id, avail) - }) - .collect() + tr.clips().iter().map(|c| (c.id, material_len(s, c).map_or(0, |n| n.saturating_sub(c.source_offset()).saturating_sub(c.length)))).collect() }; let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 }; pl.sort(); @@ -348,12 +784,12 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh let ext_b = before.min(b_handle).max(0); if let Some(ca) = pl.clips.get_mut(i) { ca.length += ext_a; - ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape }; + ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape, curvature: 0.0 }; } if let Some(cb) = pl.clips.get_mut(i + 1) { let old = cb.start; cb.trim_start_to(old - ext_b); - cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape }; + cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape, curvature: 0.0 }; } made += 1; xfaded.push(a.id); @@ -362,10 +798,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh } for c in pl.clips.iter_mut().filter(|c| !xfaded.contains(&c.id)) { if range.start <= c.start && range.end > c.start && range.end < c.end() { - c.fade_in = Fade { len: range.end - c.start, shape }; + c.fade_in = Fade { len: range.end - c.start, shape, curvature: 0.0 }; made += 1; } else if range.end >= c.end() && range.start > c.start && range.start < c.end() { - c.fade_out = Fade { len: c.end() - range.start, shape }; + c.fade_out = Fade { len: c.end() - range.start, shape, curvature: 0.0 }; made += 1; } } @@ -460,7 +896,7 @@ mod tests { let copied = copy_range(&s, t, Range::new(1500, 2500)); assert_eq!(copied.len(), 1); assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500)); - paste(&mut s, t, 4000, &copied, 1000, false); + paste(&mut s, t, 4000, &copied, 1000, false).unwrap(); assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]); } @@ -468,7 +904,7 @@ mod tests { fn paste_shuffle_ripples() { let (mut s, t) = session_with_clip(); let copied = copy_range(&s, t, Range::new(1000, 2000)); - paste(&mut s, t, 1000, &copied, 1000, true); + paste(&mut s, t, 1000, &copied, 1000, true).unwrap(); assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]); } @@ -500,13 +936,109 @@ mod tests { assert_eq!((c[0].end(), c[1].start), (3000, 3000)); } + fn three_clips() -> (Session, TrackId, [ClipId; 3]) { + let (mut s, t) = session_with_clip(); + let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()]; + if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { + pl.clips = vec![ + Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000), + Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000), + Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000), + ]; + } + (s, t, ids) + } + + #[test] + fn shuffle_moves_reorder_clips_and_keep_the_silence() { + let (mut s, t, [_, b, _]) = three_clips(); + assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1)); + assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]); + let (mut s, t, [a, ..]) = three_clips(); + assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0); + assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000); + shuffle_clips(&mut s, &[a], 2500, &[]).unwrap(); + assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]); + } + + #[test] + fn shuffling_onto_an_empty_track_starts_it_at_zero() { + let (mut s, t, [_, _, c]) = three_clips(); + let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap(); + assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]); + } + + #[test] + fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() { + let (mut s, t, [a, b, c]) = three_clips(); + trim_clip(&mut s, b, None, Some(2500), true); + trim_clip(&mut s, b, Some(1500), None, true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]); + let (mut s, t, _) = three_clips(); + trim_clip(&mut s, a, None, Some(2500), false); + trim_clip(&mut s, c, Some(2000), None, false); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]); + trim_clip(&mut s, c, None, Some(99_999), false); + assert_eq!(clips(&s, t)[2], (3000, 9000, 1000)); + } + + #[test] + fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() { + let (mut s, t, [a, b, c]) = three_clips(); + s.find_clip_mut(c).unwrap().time_locked = true; + clear_range(&mut s, t, Range::new(1000, 3000), true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]); + let (mut s, t, [a2, ..]) = three_clips(); + s.find_clip_mut(c).unwrap().edit_locked = true; + assert_eq!(shuffle_room(&s, t, 0), 2000); + assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM)); + assert_eq!(make_room(&mut s, t, 0, 2000), Ok(())); + assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]); + assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1)); + assert_eq!(clips(&s, t)[2], (5000, 1000, 3000)); + let (mut s, t, _) = three_clips(); + s.find_clip_mut(c).unwrap().time_locked = true; + trim_clip(&mut s, b, None, Some(9000), true); + assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]); + assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000); + } + + #[test] + fn multi_track_moves_keep_the_tracks_apart() { + let (mut s, t1, [a, ..]) = three_clips(); + let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None); + let d = s.new_clip_id(); + s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500)); + let dest = destinations(&s, &[a, d], t1, t2); + assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)])); + assert_eq!(destinations(&s, &[a, d], t1, m), None); + assert_eq!(destinations(&s, &[a], t1, m), None); + assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2); + assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]); + assert_eq!(clips(&s, t3), vec![(0, 500, 0)]); + } + + #[test] + fn grid_moves_snap_the_clip_start_or_keep_its_offset() { + let (s, t) = session_with_clip(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + let c = s.track(t).unwrap().clips()[0].clone(); + assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step); + assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000); + assert_eq!(grid_delta(&s, &c, step / 3, true), 0); + } + #[test] fn moves_overwrite_and_respect_locks() { let (mut s, t) = session_with_clip(); let id = s.track(t).unwrap().clips()[0].id; - assert_eq!(move_clips(&mut s, &[id], 500, None), 1); + assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1); assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]); s.find_clip_mut(id).unwrap().time_locked = true; - assert_eq!(move_clips(&mut s, &[id], 500, None), 0); + assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0); } } diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 0000000..b0f02fd --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,489 @@ +//! 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. Plug-in scans live in the host crates. The one engine module that reads + /// and writes a fixed folder is `autosave.rs`: crash-recovery copies in the app's own data + /// folder, which no command names, so it is skipped too. + #[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; `autosave` writes the app's own + // recovery folder; `tests*.rs` are test-only modules. + if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "autosave.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..fa3d1f8 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -9,21 +9,90 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; /// Largest import we accept (samples per channel × channels), ~4 GiB of f32. -const MAX_IMPORT_SAMPLES: usize = 1 << 30; +pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30; + +/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a +/// tiny sample rate grows by orders of magnitude. +pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option { + let from = buf.sample_rate.max(1); + if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 { + return None; + } + if from != rate { + buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate); + buf.sample_rate = rate; + } + Some(buf) +} + +// ─── 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. Only a regular file qualifies: a device or a pipe named by a session or a command would +/// never end or never answer. Errors name the file. +pub fn read_file(path: impl AsRef) -> Result> { + let path = path.as_ref(); + crate::file_access::check_read(path).map_err(EngineError::Io)?; + let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display())); + if !std::fs::metadata(path).map_err(err)?.is_file() { + return Err(EngineError::Io(format!("{}: not a regular file", path.display()))); + } + std::fs::read(path).map_err(err) +} + +/// [`read_file`] as UTF-8 text. +pub fn read_text(path: impl AsRef) -> Result { + let path = path.as_ref(); + String::from_utf8(read_file(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); - let mut path = format!("Audio Files/{stem}.wav"); - let mut n = 2u32; - while s.sources.iter().any(|src| src.path == path) { - path = format!("Audio Files/{stem}-{n}.wav"); - n = n.saturating_add(1); - if n > 10_000 { - break; - } + let (plain, prefix) = (format!("Audio Files/{stem}.wav"), format!("Audio Files/{stem}-")); + let used: std::collections::HashSet = s + .sources + .iter() + .filter_map(|src| { + if src.path == plain { + return Some(1); + } + let n = src.path.strip_prefix(&prefix)?.strip_suffix(".wav")?; + n.parse().ok().filter(|i: &u64| *i > 1 && i.to_string() == n) + }) + .collect(); + match (1..).find(|i| !used.contains(i)) { + Some(n) if n > 1 => format!("{prefix}{n}.wav"), + _ => plain, } - path } /// Register decoded audio as a new source. Resamples to the session rate if needed. @@ -54,21 +123,31 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio id } +/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled. +fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 { + if file_rate == 0 || file_rate == session_rate { + return stamp; + } + let (from, to) = (u128::from(file_rate), u128::from(session_rate)); + u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX) +} + /// Decode and place an audio file. With no target track, a new track of matching width is made. pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option, at: Samples) -> Result { let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; - if buf.frames().saturating_mul(buf.num_channels().max(1)) > MAX_IMPORT_SAMPLES { - return Err(EngineError::Io(format!("{name}: file is too large to import"))); - } + let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES) + .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?; if buf.frames() == 0 { return Err(EngineError::Io(format!("{name}: no audio"))); } let channels = buf.num_channels(); + let file_rate = info.sample_rate; let s = e.session_mut(); + let session_rate = s.sample_rate.hz(); let src = add_source(s, name, buf, path, info.format); if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) { - sx.time_reference = bwf.time_reference; + sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate); } let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0)); let track = match target { @@ -152,12 +231,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())), @@ -166,11 +246,18 @@ pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec { missing } +/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful. +const MAX_MIDI_METERS: usize = 10_000; + /// Import a Standard MIDI File as MIDI tracks (one per SMF track). pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result> { let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?; let s = e.session_mut(); if tempo_map { + // Each meter is placed by walking the ones before it. + if smf.meters.len() > MAX_MIDI_METERS { + return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes"))); + } for (tick, bpm) in &smf.tempos { let _ = s.tempo.set_tempo(*tick, *bpm); } @@ -184,14 +271,18 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b if tr.sequence.notes.is_empty() { continue; } - let name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() }; + let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() }; + soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS); let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name)); let drums = tr.sequence.notes.iter().all(|n| n.channel == 9); if let Some(t) = s.track_mut(id) { t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" })); } - let end_tick = base_tick + tr.sequence.end_tick().max(1); - let len = s.tempo.tick_to_samples(end_tick, s.sample_rate) - at; + let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(1)); + let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at); + if len > s.sample_rate.samples(86_400.0) { + return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1))); + } let cid = s.new_clip_id(); crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone())); out.push(id); @@ -237,61 +328,260 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result> { soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string())) } -/// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS). -pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec, (f32, f32, f32))> { - bounce_bytes_with(e, r, opts, normalize, false) -} - -/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV -/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`. -pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec, (f32, f32, f32))> { - bounce_source(e, r, opts, normalize, fold_stereo, None) -} - -/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named. -pub fn bounce_source( - e: &Engine, - r: Range, - opts: &EncodeOptions, - normalize: bool, - fold_stereo: bool, +/// A bounce of the main mix, or of one bus, over a range, written to its file block by block so no +/// bounce has to fit in memory, and rendered a slice at a time ([`Bounce::step`]) so a UI can show +/// progress. The file appears at its path only once complete; a bounce that fails, or is dropped +/// unfinished, leaves any older file there alone. +pub struct Bounce { + session: Arc, + path: PathBuf, + part: PathBuf, + range: Range, bus: Option, -) -> Result<(Vec, (f32, f32, f32))> { - let s = e.session(); - if r.len() > s.sample_rate.samples(4.0 * 3600.0) { - return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); - } - let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus); - if normalize { - soundcraft_dsp::offline::normalize(&mut ch, -0.1, false); - } - let peak = soundcraft_dsp::offline::peak_db(&ch); - let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len()); - let frames = ch.first().map_or(0, Vec::len); - lm.process(&ch, frames); - let lufs = lm.integrated_lufs(); - let true_peak = lm.true_peak_db(); - 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()))?; - Ok((bytes, (peak, lufs, true_peak))) + fold: Option, + normalize: bool, + /// Measuring the peak to normalize to, the first of two passes. + measuring: bool, + render: soundcraft_mix::RangeRender, + block: Vec>, + done: usize, + gain: f32, + peak: f32, + meter: soundcraft_dsp::meter::LoudnessMeter, + enc: Option>, } -/// The main mix over `r` and its format (folded to stereo on request). -fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec>, ChannelFormat) { - render_bounce(s, r, fold_stereo, None) +/// A finished bounce: the size of its file and its levels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Bounced { + pub bytes: u64, + pub peak_db: f32, + pub lufs: f32, + pub true_peak_db: f32, } -/// The main mix, or one bus, over `r`. -fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option) -> (Vec>, ChannelFormat) { - let (ch, fmt) = if let Some(id) = bus { - let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo); - (soundcraft_mix::render_bus(s, id, r, 1024), fmt) - } else { - let ch = soundcraft_mix::render_range(s, r, 1024); - let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() }; - (ch, fmt) +impl Bounce { + /// Starts a bounce of `s`'s main mix, or of `bus`, over `r` to `path`: every channel (SMPTE/WAV + /// order, with the WAV speaker mask of its format), or an ITU stereo fold-down when + /// `fold_stereo`, peak-normalized to -0.1 dBFS when `normalize` (which renders twice). + pub fn new( + s: Arc, + path: &Path, + r: Range, + opts: &EncodeOptions, + bus: Option, + normalize: bool, + fold_stereo: bool, + ) -> Result { + if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) { + return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into())); + } + let (fmt, channels, fold) = bounce_layout(&s, bus, fold_stereo); + let name = path.file_name().map_or_else(|| "bounce".into(), |n| n.to_string_lossy().into_owned()); + let part = path.with_file_name(format!("{name}.part")); + crate::file_access::check_write(path).map_err(EngineError::Io)?; + crate::file_access::check_write(&part).map_err(EngineError::Io)?; + let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> { + let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?; + enc.check_room(u64::try_from(r.len()).unwrap_or(0))?; + Ok(enc) + })() + .map_err(|err| { + let _ = std::fs::remove_file(&part); + EngineError::Io(format!("{}: {err}", path.display())) + })?; + let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024); + let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels); + Ok(Bounce { + session: s, + path: path.to_path_buf(), + part, + range: r, + bus, + fold, + normalize, + measuring: normalize, + render, + block: Vec::new(), + done: 0, + gain: 1.0, + peak: 0.0, + meter, + enc: Some(enc), + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn range(&self) -> Range { + self.range + } + + /// How far along the bounce is, from 0 to 1. + pub fn progress(&self) -> f32 { + let pass = (self.done as f64 / self.range.len().max(1) as f64).min(1.0) as f32; + match (self.normalize, self.measuring) { + (false, _) => pass, + (true, true) => pass / 2.0, + (true, false) => 0.5 + pass / 2.0, + } + } + + /// Renders blocks until `more` returns false (at least one per call). Returns the finished + /// bounce once its file is complete. + pub fn step(&mut self, mut more: impl FnMut() -> bool) -> Result> { + loop { + match self.render.next_block(&self.session) { + Some((chunk, n)) => { + fill(&mut self.block, chunk, n, self.fold, self.gain); + self.done += n; + self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block)); + if !self.measuring { + self.meter.process(&self.block, n); + if let Some(enc) = &mut self.enc { + enc.write(&self.block, n).map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?; + } + } + } + None if self.measuring => { + self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 }; + self.peak = 0.0; + self.done = 0; + self.measuring = false; + self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024); + } + None => return self.finish().map(Some), + } + if !more() { + return Ok(None); + } + } + } + + /// Renders the whole bounce (see [`Bounce::step`]). + pub fn run(mut self) -> Result { + loop { + if let Some(done) = self.step(|| true)? { + return Ok(done); + } + } + } + + fn finish(&mut self) -> Result { + let bytes = match self.enc.take() { + Some(enc) => enc.finish().map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?.complete(&self.part, &self.path)?, + None => 0, + }; + Ok(Bounced { + bytes, + peak_db: soundcraft_dsp::gain_to_db(self.peak), + lufs: self.meter.integrated_lufs(), + true_peak_db: self.meter.true_peak_db(), + }) + } +} + +impl Drop for Bounce { + /// Removes the part file unless the bounce completed (which renamed it). + fn drop(&mut self) { + drop(self.enc.take()); + let _ = std::fs::remove_file(&self.part); + } +} + +/// Where a bounce is written: its part file, or memory in the browser, which has no file system. +enum Out { + File(std::io::BufWriter), + Memory(std::io::Cursor>), +} + +impl Out { + fn create(part: &Path) -> std::io::Result { + if cfg!(target_arch = "wasm32") { + return Ok(Out::Memory(std::io::Cursor::new(Vec::new()))); + } + Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?))) + } + + /// Puts the finished file at `path` and returns its size: the part file is synced and renamed + /// over it; in the browser the bytes are delivered ([`write_file`]). + fn complete(self, part: &Path, path: &Path) -> Result { + match self { + Out::File(out) => { + let mut len = 0; + let synced = out.into_inner().map_err(std::io::IntoInnerError::into_error).and_then(|f| { + f.sync_all()?; + len = f.metadata()?.len(); + Ok(()) + }); + replace_with(part, path, synced).map(|()| len) + } + Out::Memory(m) => { + let bytes = m.into_inner(); + write_file(&path.to_string_lossy(), &bytes)?; + Ok(bytes.len() as u64) + } + } + } +} + +impl std::io::Write for Out { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match self { + Out::File(f) => f.write(buf), + Out::Memory(m) => { + // A long bounce may not fit in the browser's memory: fail instead of aborting. + let end = usize::try_from(m.position()).unwrap_or(usize::MAX).saturating_add(buf.len()); + let v = m.get_mut(); + v.try_reserve(end.saturating_sub(v.len())).map_err(|_| std::io::ErrorKind::OutOfMemory)?; + m.write(buf) + } + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match self { + Out::File(f) => f.flush(), + Out::Memory(_) => Ok(()), + } + } +} + +impl std::io::Seek for Out { + fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { + match self { + Out::File(f) => f.seek(pos), + Out::Memory(m) => m.seek(pos), + } + } +} + +const MAX_BOUNCE_SECS: f64 = 4.0 * 3600.0; + +/// The format, channel count and fold-down source of a bounce of the main mix or `bus`: every +/// channel (a mono or stereo main mix as stereo), or an ITU stereo fold-down of a surround source +/// when `fold_stereo`. +fn bounce_layout(s: &Session, bus: Option, fold_stereo: bool) -> (ChannelFormat, usize, Option) { + let nch = soundcraft_mix::source_channels(s, bus); + let fmt = match bus { + Some(id) => s.bus(id).map_or(ChannelFormat::Stereo, |b| b.format), + None if nch <= 2 => ChannelFormat::Stereo, + None => s.main_format(), }; - if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) } + if fold_stereo && nch > 2 { (ChannelFormat::Stereo, 2, Some(fmt)) } else { (fmt, nch, None) } +} + +/// The first `n` frames of `chunk`, folded down from `fold` when set and scaled by `gain`. +fn fill(block: &mut Vec>, chunk: &[Vec], n: usize, fold: Option, gain: f32) { + let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, chunk)); + let src = folded.as_deref().unwrap_or(chunk); + block.resize_with(src.len(), Vec::new); + for (d, c) in block.iter_mut().zip(src) { + d.clear(); + d.extend(c.iter().take(n).map(|v| v * gain)); + } } /// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included) @@ -303,7 +593,10 @@ 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}")))?; + if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) { + return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into())); + } + make_dir(dir)?; let ext = match opts.format { FileFormat::Aiff => "aif", FileFormat::Flac => "flac", @@ -321,11 +614,8 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, for t in &mut solo.tracks { t.mixer.solo = t.id == id; } - let (ch, fmt) = render_main(&solo, r, fold_stereo); - 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())))?; + Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?; out.push(path.to_string_lossy().into_owned()); } Ok(out) @@ -349,20 +639,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 +655,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 +666,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 +736,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 +792,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 +824,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 +841,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) @@ -540,15 +885,26 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result { /// Write `session` to `path`; returns the session with updated media paths, the number of audio /// files written and the final path. fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> { + use std::collections::{HashMap, HashSet}; 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(); let from = s.folder.clone(); let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir)); let mut written = 0; - let shared_paths: Vec = - s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect(); + // A file can stay put for all the sources that name it, unless their audio differs. + let shared_paths: Vec = { + let mut audio_by_path: HashMap<&str, HashSet>> = HashMap::new(); + for src in &s.sources { + audio_by_path.entry(&src.path).or_default().insert(s.pool.get(src.id).map(Arc::as_ptr)); + } + s.sources.iter().map(|src| audio_by_path.get(src.path.as_str()).is_some_and(|a| a.len() > 1)).collect() + }; + // Files written for audio during this save: sources playing the same audio share one. + let mut files: HashMap<*const SourceAudio, String> = HashMap::new(); for (i, src) in s.sources.iter_mut().enumerate() { if !same_dir && s.pool.get(src.id).is_none() @@ -568,7 +924,13 @@ 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())))?; + if let Some(path) = files.get(&Arc::as_ptr(audio)) { + src.path.clone_from(path); + src.unsaved = false; + src.format = FileFormat::Wav; + continue; + } + 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. @@ -576,6 +938,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess if audio_dir.join(format!("{stem}.wav")).exists() { src.path = format!("Audio Files/{stem}.wav"); src.unsaved = false; + files.insert(Arc::as_ptr(audio), src.path.clone()); continue; } } @@ -585,6 +948,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess src.path = format!("Audio Files/{name}"); src.unsaved = false; src.format = FileFormat::Wav; + files.insert(Arc::as_ptr(audio), src.path.clone()); written += usize::from(new); } if written > 0 { @@ -595,10 +959,10 @@ 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()) { + if !overwrite && read_file(&p).is_ok_and(|old| old != text.as_bytes()) { back_up(&p); } replace_file(&tmp, &p, text.as_bytes())?; @@ -608,7 +972,15 @@ 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<()> { - if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) { + crate::file_access::check_write(file).map_err(EngineError::Io)?; + crate::file_access::check_write(tmp).map_err(EngineError::Io)?; + replace_with(tmp, file, write_synced(tmp, bytes)) +} + +/// Renames `tmp` over `file` once it is `written`, or removes it when writing or renaming failed. +/// The caller has checked both paths ([`crate::file_access::check_write`]). +fn replace_with(tmp: &Path, file: &Path, written: std::io::Result<()>) -> Result<()> { + if let Err(err) = written.and_then(|()| std::fs::rename(tmp, file)) { let _ = std::fs::remove_file(tmp); return Err(EngineError::Io(format!("{}: {err}", file.display()))); } @@ -636,6 +1008,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 +1035,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,19 +1086,16 @@ 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 mut files = DecodedFiles::new(); + for src in &s.sources { let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) }; - 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())) { - missing.push(format!("{}: {err}", p.display())); - } - } - Err(_) => missing.push(p.display().to_string()), + match decode_file(&mut files, &p, s.sample_rate.hz()) { + Ok(audio) => s.pool.insert(src.id, audio), + Err(err) => missing.push(err), } } missing.extend(relink_videos(&mut s, &dir)); @@ -733,15 +1108,33 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result> { Ok(missing) } -/// Decode one source's audio file into the session pool, at the session rate. -fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> { - let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; - if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 { - buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz()); - buf.sample_rate = s.sample_rate.hz(); - } - s.pool.insert(id, Arc::new(SourceAudio::new(buf))); - Ok(()) +/// Decode one source's audio file at the session rate. +fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result, String> { + let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?; + let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?; + Ok(Arc::new(SourceAudio::new(buf))) +} + +/// Audio files decoded so far, by path: a session may name one file thousands of times. +/// The one name of the file at `p` (links followed), so the same file named two ways is read once. +pub(crate) fn file_key(p: &Path) -> PathBuf { + std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf()) +} + +pub(crate) type DecodedFiles = std::collections::HashMap, String>>; + +/// The audio of the file at `p` at `rate`, decoded the first time it is asked for. The error +/// names the file. +/// A file named in a session is hostile input too: a path that leaves the read root is not read. +pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std::result::Result, String> { + crate::file_access::check_read(p)?; + files + .entry(file_key(p)) + .or_insert_with(|| { + let bytes = read_file(p).map_err(|_| p.display().to_string())?; + decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display())) + }) + .clone() } // ─── Hosts without a filesystem (the browser) ───────────────────────────────────────────────── @@ -774,6 +1167,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}"))) } @@ -844,14 +1238,12 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result { - let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string); - if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) { - missing.push(format!("{}: {err}", src.path)); - } - } + Some(audio) => match decode_source(&audio, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) { + Ok(audio) => s.pool.insert(src.id, audio), + Err(err) => missing.push(format!("{}: {err}", src.path)), + }, None => missing.push(src.path.clone()), } } @@ -925,6 +1317,37 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec< .collect() } +/// A recording's plan, made when it starts: each record-enabled track's take file, where the +/// takes start on the timeline, the loop length they split into when loop recording, and the +/// punch range they're trimmed to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Recording { + pub start: Samples, + pub pass: Option, + pub punch: Option, + /// The input's sample rate, which the take files are written at. + pub rate: u32, + pub takes: Vec, +} + +/// One track's take file. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct Take { + pub track: TrackId, + pub path: PathBuf, + pub channels: usize, +} + +/// Place recorded take files as `plan` says, as one undo step ("Record"). +pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec { + let ids = add_recording(e, plan.start, takes, plan.pass); + if let Some(p) = plan.punch { + let clips: Vec = ids.iter().map(|c| c.0).collect(); + let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record"); + } + ids +} + /// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the /// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most /// 1000), each pass after the first on a new playlist. @@ -932,8 +1355,9 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu let before = e.session_arc(); let mut out = Vec::new(); for (tid, path) in takes { - let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string())); - let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue }; + let read = + read_file(&path).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))); + let Ok((_, buf)) = read.inspect_err(|err| e.message(err.to_string())) else { continue }; let s = e.session_mut(); if s.track(tid).is_none() { continue; @@ -967,6 +1391,15 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu out } +/// Where a take recording from `start` shows while it holds `len` samples, as it will be placed: +/// just the pass under way when loop recording in passes of `pass`, within the `punch` range. +/// Returns the timeline position of its first sample and the span it covers. +pub fn live_span(start: Samples, pass: Option, punch: Option, len: Samples) -> Option<(Samples, Range)> { + let origin = start.saturating_sub(pass.filter(|p| *p > 0).map_or(0, |p| len / p * p)); + let span = Range::new(start, origin.saturating_add(len)); + Some((origin, span.intersect(&punch.unwrap_or(span))?)) +} + #[cfg(test)] mod record_tests { use super::*; @@ -1029,6 +1462,17 @@ mod record_tests { assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() { + assert_eq!(live_span(1_000, None, None, 0), None); + assert_eq!(live_span(1_000, None, None, 500), Some((1_000, Range::new(1_000, 1_500)))); + assert_eq!(live_span(1_000, Some(400), None, 900), Some((200, Range::new(1_000, 1_100)))); + assert_eq!(live_span(1_000, Some(400), None, 800), None); + let punch = Some(Range::new(1_200, 1_400)); + assert_eq!(live_span(1_000, None, punch, 150), None); + assert_eq!(live_span(1_000, None, punch, 500), Some((1_000, Range::new(1_200, 1_400)))); + } } #[cfg(test)] @@ -1139,7 +1583,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); } @@ -1214,6 +1661,145 @@ mod save_tests { } } +#[cfg(test)] +mod rate_tests { + use super::*; + + #[test] + fn a_tiny_sample_rate_is_refused_before_it_is_converted() { + let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] }; + assert!(at_rate(buf(1), 48_000, 1_000_000).is_none()); + let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap(); + assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600)); + } +} + +#[cfg(test)] +mod open_tests { + use super::*; + + #[test] + fn a_file_named_by_many_sources_is_decoded_once() { + let dir = std::env::temp_dir().join(format!("soundcraft-shared-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] }; + std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap(); + let mut s = Session::default(); + for _ in 0..3 { + add_source(&mut s, "k", audio.clone(), Some("k.wav"), FileFormat::Wav); + } + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + std::fs::write(&path, s.to_json().unwrap()).unwrap(); + let mut e = Engine::default(); + assert!(open_session(&mut e, &path).unwrap().is_empty()); + let (sources, pool) = (&e.session().sources, &e.session().pool); + let first = pool.get(sources[0].id).unwrap(); + assert!(sources.iter().all(|src| pool.get(src.id).is_some_and(|a| Arc::ptr_eq(a, first)))); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn sources_playing_one_file_share_it_when_saved() { + let dir = std::env::temp_dir().join(format!("soundcraft-shared-save-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Audio Files")).unwrap(); + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] }; + std::fs::write(dir.join("Audio Files/k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap(); + let mut s = Session::default(); + for _ in 0..3 { + add_source(&mut s, "k", audio.clone(), Some("Audio Files/k.wav"), FileFormat::Wav); + } + s.sources.iter_mut().for_each(|src| src.unsaved = false); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + std::fs::write(&path, s.to_json().unwrap()).unwrap(); + let mut e = Engine::default(); + open_session(&mut e, &path).unwrap(); + assert_eq!(save_session(&mut e, &path).unwrap(), 0); + assert!(e.session().sources.iter().all(|src| src.path == "Audio Files/k.wav")); + assert_eq!(save_copy(&e, &dir.join("copy/Song.scraft").to_string_lossy()).unwrap(), 1); + assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[cfg(test)] +mod name_tests { + use super::*; + + #[test] + fn a_midi_track_name_is_cut_to_a_length_that_can_be_drawn() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: "n".repeat(1_000_000), sequence }], ..Default::default() }; + let mut e = Engine::default(); + let ids = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap(); + let track = e.session().track(ids[0]).unwrap(); + assert_eq!((track.name.len(), track.clips()[0].name.len()), (soundcraft_model::MAX_NAME_CHARS, soundcraft_model::MAX_NAME_CHARS)); + } + + #[test] + fn midi_longer_than_a_day_is_refused() { + let held = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: (1 << 28) - 1 }; + let sequence = soundcraft_midi::Sequence { notes: vec![held], ..Default::default() }; + let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], ..Default::default() }; + let err = import_midi_bytes(&mut Engine::default(), &soundcraft_midi::write_smf(&smf).unwrap(), 0, false).unwrap_err(); + assert!(err.to_string().contains("longer than 24 hours"), "{err}"); + } + + #[test] + fn midi_with_more_meter_changes_than_scores_have_is_refused() { + let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 }; + let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() }; + let meters = (0..=MAX_MIDI_METERS as i64).map(|bar| (bar * 3840, 4, 4)).collect(); + let smf = soundcraft_midi::Smf { tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }], meters, ..Default::default() }; + let err = import_midi_bytes(&mut Engine::default(), &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap_err(); + assert!(err.to_string().contains("meter changes"), "{err}"); + } + + #[test] + fn new_sources_take_the_first_free_file_name() { + let mut s = Session::default(); + let buf = || AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 4]] }; + add_source(&mut s, "k", buf(), Some("Audio Files/k-02.wav"), FileFormat::Wav); + let mut path = || { + let id = add_source(&mut s, "k", buf(), None, FileFormat::Wav); + s.source(id).map(|x| x.path.clone()).unwrap_or_default() + }; + assert_eq!([path(), path(), path()], ["Audio Files/k.wav", "Audio Files/k-2.wav", "Audio Files/k-3.wav"]); + } +} + +#[cfg(all(test, unix))] +mod pipe_tests { + use super::*; + + #[test] + fn a_pipe_named_as_media_is_refused_not_waited_on() { + let dir = std::env::temp_dir().join(format!("soundcraft-pipe-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let fifo = dir.join("pipe.wav"); + assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success()); + let mut s = Session::default(); + let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 10]] }; + add_source(&mut s, "pipe", audio, Some(&fifo.to_string_lossy()), FileFormat::Wav); + let path = dir.join("Song.scraft").to_string_lossy().into_owned(); + std::fs::write(&path, s.to_json().unwrap()).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let pipe = fifo.to_string_lossy().into_owned(); + std::thread::spawn(move || { + let mut e = Engine::default(); + let imported = e.execute("file.import_audio", &json!({"path": pipe})).is_ok(); + let _ = tx.send((imported, open_session(&mut e, &path).map_err(|err| err.to_string()))); + }); + let (imported, opened) = rx.recv_timeout(std::time::Duration::from_secs(10)).unwrap(); + assert!(!imported); + assert_eq!(opened.unwrap(), [fifo.display().to_string()]); + let _ = std::fs::remove_dir_all(&dir); + } +} + #[cfg(test)] mod stem_tests { #[test] @@ -1229,6 +1815,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..6cc1c90 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -5,12 +5,14 @@ //! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod autosave; pub mod catalog; pub mod catalog_more; 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; @@ -88,6 +90,18 @@ pub struct Clipboard { pub clip_effects: Vec<(String, f64)>, /// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers. pub markers: Vec, + /// How far the copied range started after a grid line; Relative Grid pastes keep it. + pub grid_offset: Samples, + /// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too. + pub automation_only: bool, +} + +impl Clipboard { + /// An empty clipboard for material copied from `range`. + pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard { + let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate); + Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() } + } } #[derive(Debug, Clone)] @@ -118,8 +132,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 +166,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 +202,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; } @@ -223,6 +245,7 @@ impl Engine { pub fn undo(&mut self) -> bool { let Some(h) = self.undo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc); + self.keep_edit_mode(&cur); self.redo.push(HistoryEntry { label: h.label, doc: cur }); self.revision = self.revision.wrapping_add(1); self.dirty = true; @@ -232,12 +255,27 @@ impl Engine { pub fn redo(&mut self) -> bool { let Some(h) = self.redo.pop() else { return false }; let cur = std::mem::replace(&mut self.doc, h.doc); + self.keep_edit_mode(&cur); self.undo.push(HistoryEntry { label: h.label, doc: cur }); self.revision = self.revision.wrapping_add(1); self.dirty = true; true } + /// Undo and Redo never change the Edit mode or its settings (Shuffle Lock above all). + fn keep_edit_mode(&mut self, from: &Session) { + use edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID}; + let flags = [SNAP_TO_GRID, SHUFFLE_LOCK, RELATIVE_GRID]; + let ed = &self.doc.edit; + if ed.edit_mode != from.edit.edit_mode || flags.iter().any(|f| ed.flag(f) != from.edit.flag(f)) { + let ed = &mut Arc::make_mut(&mut self.doc).edit; + ed.edit_mode = from.edit.edit_mode; + for f in flags { + ed.set_flag(f, from.edit.flag(f)); + } + } + } + /// Record an undo point manually (for interactive gestures committed by the UI). pub fn push_undo(&mut self, label: &str, before: Arc) { if Arc::ptr_eq(&before, &self.doc) { @@ -251,10 +289,19 @@ impl Engine { } } + /// Whether command `id` must wait for the take being recorded: nothing may replace the + /// session or quit under it. + pub fn blocked_while_recording(&self, id: &str) -> bool { + self.transport.recording && matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit") + } + /// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the /// document is restored. pub fn execute(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + if self.blocked_while_recording(id) { + return Err(EngineError::Disabled(id.to_string(), "stop recording first".into())); + } if let Err(reason) = (spec.enabled)(self) { // A call that names its targets (tracks, clips, a range) does not need a selection; // the command itself validates them. @@ -266,7 +313,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 +326,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 +366,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; @@ -374,7 +434,7 @@ fn install_panic_hook() { ONCE.call_once(|| { let prev = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { - log::error!("panic: {info}"); + log::error!("panic: {info}\n{}", std::backtrace::Backtrace::force_capture()); prev(info); })); }); @@ -400,6 +460,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/bounce.rs b/crates/engine/tests/bounce.rs new file mode 100644 index 0000000..ea4cf12 --- /dev/null +++ b/crates/engine/tests/bounce.rs @@ -0,0 +1,87 @@ +//! Bounces stream the main mix or a bus to their file, exactly as rendered in memory. + +use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat}; +use soundcraft_engine::io::{Bounce, bounce_stems}; +use soundcraft_time::Range; +use std::path::PathBuf; + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +#[test] +fn bounces_write_exactly_the_mix_and_report_its_levels() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let dir = scratch("bounce-exact"); + let path = dir.join("Mix.wav"); + let r = Range::new(s.sample_rate.samples(10.0) + 7, s.sample_rate.samples(13.0)); + let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let verb = s.bus_by_name("Verb").unwrap().id; + for bus in [None, Some(verb)] { + let mut mix = match bus { + Some(b) => soundcraft_mix::render_bus(s, b, r, 1024), + None => soundcraft_mix::render_range(s, r, 1024), + }; + assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}"); + for normalize in [false, true] { + let mut bounce = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap(); + let mut progress = vec![bounce.progress()]; + let done = loop { + if let Some(done) = bounce.step(|| false).unwrap() { + break done; + } + progress.push(bounce.progress()); + }; + let last = progress[progress.len() - 1]; + assert!(progress.len() > 100 && progress.is_sorted() && progress[0] < 0.01 && last > 0.99 && last <= 1.0, "{progress:?}"); + if normalize { + soundcraft_dsp::offline::normalize(&mut mix, -0.1, false); + } + let bytes = std::fs::read(&path).unwrap(); + let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap(); + assert_eq!(file.channels, mix, "{bus:?} normalize: {normalize}"); + let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, mix.len()); + lm.process(&mix, mix[0].len()); + let levels = (soundcraft_dsp::offline::peak_db(&mix), lm.integrated_lufs(), lm.true_peak_db()); + assert_eq!((done.bytes, (done.peak_db, done.lufs, done.true_peak_db)), (bytes.len() as u64, levels)); + } + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() { + let e = soundcraft_engine::demo::demo_engine(); + let s = e.session(); + let dir = scratch("bounce-too-big"); + let path = dir.join("Mix.aif"); + std::fs::write(&path, b"old").unwrap(); + let opts = EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, dither: false, bwf: None }; + let err = Bounce::new(e.session_arc(), &path, Range::new(0, s.sample_rate.samples(3.9 * 3600.0)), &opts, None, false, false).err().unwrap(); + assert!(err.to_string().contains("4 GiB"), "{err}"); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let err = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, s.sample_rate.samples(4.1 * 3600.0)), &opts).unwrap_err(); + assert!(err.to_string().contains("4 hours"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() { + let e = soundcraft_engine::demo::demo_engine(); + let dir = scratch("bounce-cancel"); + let path = dir.join("Mix.wav"); + std::fs::write(&path, b"old").unwrap(); + let mut bounce = Bounce::new(e.session_arc(), &path, Range::new(0, 48_000), &EncodeOptions::default(), None, false, false).unwrap(); + assert_eq!(bounce.step(|| false).unwrap(), None); + assert!(dir.join("Mix.wav.part").exists()); + drop(bounce); + assert_eq!(std::fs::read(&path).unwrap(), b"old"); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/tests/clap_e2e.rs b/crates/engine/tests/clap_e2e.rs index 9a559f1..cd554a9 100644 --- a/crates/engine/tests/clap_e2e.rs +++ b/crates/engine/tests/clap_e2e.rs @@ -69,6 +69,17 @@ fn clap_insert_renders_through_the_mix() { assert!(e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "Gain", "value": 1.0})).is_err()); } +#[test] +fn a_refused_state_is_reported() { + install_fixture(); + let gain = "clap:org.soundcraft.test.gain"; + let make = |state: Option<&[u8]>| soundcraft_mix::create_instance(gain, state, 48_000.0, 512, 2).unwrap(); + let saved = make(None).plugin.save_state().unwrap(); + assert!(!make(None).refused_state); + assert!(!make(Some(&saved)).refused_state); + assert!(make(Some(&[0xff; 16])).refused_state, "NaN gain"); +} + #[test] fn clap_state_is_captured_stored_and_restored_before_the_first_block() { install_fixture(); @@ -88,17 +99,15 @@ fn clap_state_is_captured_stored_and_restored_before_the_first_block() { eng.set_external_instances(true); let specs = soundcraft_mix::instance_specs(e.session()); assert_eq!(specs.len(), 1); - let mut p = soundcraft_mix::create_instance(&specs[0].id, None, 48_000.0, 512, specs[0].ch).unwrap(); + let mut p = soundcraft_mix::create_instance(&specs[0].id, None, 48_000.0, 512, specs[0].ch).unwrap().plugin; p.set_param("7", 1.5); let mut io = vec![vec![0.0f32; 64]]; p.process(&mut io, 64); + let mut reader = p.state_reader().unwrap(); eng.adopt(vec![soundcraft_mix::PreparedInstance { track: t, slot: 0, id: gain.into(), ch: 1, plugin: p }]); eng.ensure_synced(e.session()); - let states = eng.capture_states(); - assert_eq!(states.len(), 1); - let (tt, slot, blob) = &states[0]; - assert_eq!((*tt, *slot), (t, 0)); - e.execute("mix.insert_state", &json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(blob)})).unwrap(); + let blob = reader.save_state().unwrap(); + e.execute("mix.insert_state", &json!({"track": t.0, "slot": 0, "state": soundcraft_model::b64::encode(&blob)})).unwrap(); assert_eq!(eng.take_retired().len(), 0); // Offline render (synchronous creation): the state is restored before the first block. 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/edit_modes.rs b/crates/engine/tests/edit_modes.rs new file mode 100644 index 0000000..27d4a64 --- /dev/null +++ b/crates/engine/tests/edit_modes.rs @@ -0,0 +1,415 @@ +use serde_json::{Value, json}; +use soundcraft_engine::Engine; +use soundcraft_engine::edit::SHUFFLE_LOCK; +use soundcraft_model::{AutoParam, Clip, ClipId, EditMode, Source, SourceId, TrackId}; + +fn source(id: u64, frames: u64) -> Source { + Source { + id: SourceId(id), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + } +} + +fn new_track(e: &mut Engine) -> TrackId { + TrackId(e.execute("track.new", &json!({"count": 1})).unwrap()["tracks"][0].as_u64().unwrap()) +} + +fn engine(mode: &str) -> (Engine, TrackId, [ClipId; 2]) { + let mut e = Engine::default(); + let t = new_track(&mut e); + let s = e.session_mut(); + s.sources.push(source(7, 10_000)); + let ids = [s.new_clip_id(), s.new_clip_id()]; + s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips = + vec![Clip::audio(ids[0], "a", SourceId(7), 0, 0, 1000), Clip::audio(ids[1], "b", SourceId(7), 1000, 2000, 1000)]; + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + (e, t, ids) +} + +/// A track of clips (start, length, offset) on a long file, source 8. +fn long(mode: &str, spans: &[(i64, i64, i64)]) -> (Engine, TrackId, Vec) { + let mut e = Engine::default(); + let t = new_track(&mut e); + let s = e.session_mut(); + s.sources.push(source(8, 1_000_000)); + let ids: Vec = spans.iter().map(|_| s.new_clip_id()).collect(); + s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips = + spans.iter().zip(&ids).map(|(&(start, len, offset), id)| Clip::audio(*id, "c", SourceId(8), offset, start, len)).collect(); + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + (e, t, ids) +} + +fn clips(e: &Engine, t: TrackId) -> Vec<(i64, i64)> { + e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect() +} + +/// Each clip's id, length and file offset, in id order: what a move must never change. +fn material(e: &Engine, t: TrackId) -> Vec<(u64, i64, i64)> { + let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.id.0, c.length, c.source_offset())).collect(); + v.sort_unstable(); + v +} + +fn start(e: &Engine, id: ClipId) -> i64 { + e.session().find_clip(id).unwrap().1.start +} + +fn points(e: &Engine, t: TrackId) -> Vec { + e.session().track(t).unwrap().lane(&AutoParam::Volume).map(|l| l.points.iter().map(|p| p.at).collect()).unwrap_or_default() +} + +fn set_points(e: &mut Engine, t: TrackId, at: &[i64]) { + for a in at { + run(e, "automation.set_point", json!({"track": t.0, "param": "volume", "at": a, "value": -6.0})); + } +} + +fn run(e: &mut Engine, id: &str, p: Value) { + e.execute(id, &p).unwrap(); +} + +#[test] +fn insert_silence_pushes_in_shuffle_and_clears_otherwise() { + for (mode, want) in [("shuffle", vec![(0, 500), (700, 500), (2200, 1000)]), ("slip", vec![(0, 500), (700, 300), (2000, 1000)])] { + let (mut e, t, _) = engine(mode); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 500, "end": 700})); + assert_eq!(clips(&e, t), want, "{mode}"); + } +} + +#[test] +fn duplicate_and_repeat_make_room_in_shuffle() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (3000, 1000)]); + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.repeat", json!({"tracks": [t.0], "start": 0, "end": 1000, "count": 2})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000), (4000, 1000)]); + let (mut e, t, _) = engine("slip"); + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); +} + +#[test] +fn shuffle_paste_replaces_the_selection() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.copy", json!({"tracks": [t.0], "start": 0, "end": 1000})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 2000, "end": 2500})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 1000), (3000, 500)]); +} + +#[test] +fn clip_list_drops_land_on_a_clip_boundary_in_shuffle() { + let (mut e, t, [_, b]) = engine("shuffle"); + run(&mut e, "clip.place_source", json!({"source": 7, "track": t.0, "at": 1900})); + assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 10_000), (12_000, 1000)]); + e.session_mut().find_clip_mut(b).unwrap().time_locked = true; + assert!(e.execute("clip.place_source", &json!({"source": 7, "track": t.0, "at": 0})).is_err()); + assert_eq!(clips(&e, t).len(), 3); +} + +#[test] +fn grid_moves_to_a_position_snap_or_keep_their_offset() { + let (mut e, t, [_, b]) = engine("grid"); + let s = e.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": step + 1000})); + assert_eq!(clips(&e, t)[1].0, step); + run(&mut e, "edit.mode", json!({"mode": "relative_grid"})); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 2 * step + 2000})); + assert_eq!(clips(&e, t)[1].0, 2 * step); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "by": 200})); + run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 3 * step})); + assert_eq!(clips(&e, t)[1].0, 3 * step + 200); +} + +#[test] +fn clips_cannot_move_onto_tracks_without_a_playlist() { + let (mut e, t, [a, _]) = engine("slip"); + let aux = e.execute("track.new", &json!({"kind": "aux"})).unwrap()["tracks"][0].clone(); + assert!(e.execute("edit.move_clips", &json!({"clips": [a.0], "by": 0, "track": aux})).is_err()); + assert_eq!(clips(&e, t).len(), 2); +} + +#[test] +fn trim_clip_needs_an_edge_and_an_unlocked_clip() { + let (mut e, t, [a, b]) = engine("slip"); + assert!(e.execute("edit.trim_clip", &json!({"clip": a.0})).is_err()); + e.session_mut().find_clip_mut(b).unwrap().edit_locked = true; + assert!(e.execute("edit.trim_clip", &json!({"clip": b.0, "end": 2500})).is_err()); + run(&mut e, "edit.trim_clip", json!({"clip": a.0, "end": 5000})); + assert_eq!(clips(&e, t), vec![(0, 2000), (2000, 1000)]); +} + +#[test] +fn shuffle_lock_keeps_shuffle_mode_out() { + let (mut e, _, _) = engine("slip"); + run(&mut e, "edit.shuffle_lock", json!({})); + assert!(e.execute("edit.mode", &json!({"mode": "shuffle"})).is_err()); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + run(&mut e, "edit.shuffle_lock", json!({"value": false})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + assert!(e.execute("edit.shuffle_lock", &json!({})).is_err()); +} + +#[test] +fn snap_to_grid_rides_along_with_the_other_modes() { + let (mut e, _, _) = engine("slip"); + let snap = |e: &Engine| e.session().edit.flag(soundcraft_engine::edit::SNAP_TO_GRID); + run(&mut e, "edit.snap_to_grid", json!({})); + assert!(snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "spot", "snap_to_grid": true})); + assert!(snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + assert!(!snap(&e)); + run(&mut e, "edit.mode", json!({"mode": "grid", "snap_to_grid": true})); + assert!(!snap(&e)); + assert!(e.execute("edit.snap_to_grid", &json!({})).is_err()); +} + +#[test] +fn shuffle_edits_that_would_push_a_locked_clip_are_refused() { + let (mut e, t, [_, b]) = engine("shuffle"); + e.session_mut().find_clip_mut(b).unwrap().time_locked = true; + for id in ["edit.insert_silence", "edit.duplicate"] { + let err = e.execute(id, &json!({"tracks": [t.0], "start": 0, "end": 1500})).unwrap_err().to_string(); + assert!(err.contains("locked clip"), "{id}: {err}"); + } + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(1000, 1000), (2000, 1000)]); + run(&mut e, "event.cut_time", json!({"start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]); +} + +#[test] +fn clips_dragged_from_several_tracks_keep_their_tracks_apart() { + let (mut e, t, [a, _]) = engine("slip"); + let new = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone(); + let [t2, t3] = [0, 1].map(|i| TrackId(new[i].as_u64().unwrap())); + let s = e.session_mut(); + let d = s.new_clip_id(); + s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(7), 0, 5000, 500)); + run(&mut e, "edit.move_clips", json!({"clips": [a.0, d.0], "by": 0, "track": t2.0})); + assert_eq!(clips(&e, t), vec![(2000, 1000)]); + assert_eq!(clips(&e, t2), vec![(0, 1000)]); + assert_eq!(clips(&e, t3), vec![(5000, 500)]); + assert!(e.execute("edit.move_clips", &json!({"clips": [a.0, d.0], "by": 0, "track": t3.0})).is_err()); +} + +#[test] +fn relative_grid_pastes_keep_the_offset_from_the_grid() { + let (mut e, t, [_, b]) = engine("relative_grid"); + let s = e.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + run(&mut e, "edit.select", json!({"clips": [b.0]})); + run(&mut e, "edit.copy", json!({})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 3 * step})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t).last(), Some(&(3 * step + 2000, 1000))); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 5 * step})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000))); +} + +#[test] +fn shuffle_moves_keep_crossfade_partners_whole() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let whole = material(&e, t); + let [a, b, c] = [ids[0], ids[1], ids[2]]; + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 5000})); + assert_eq!(material(&e, t), whole); + assert_eq!([start(&e, b), start(&e, c), start(&e, a)], [0, 1100, 2100]); + let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + for by in [2500, -2500] { + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": by})); + assert_eq!(material(&e, t), whole, "{by}"); + assert_eq!(start(&e, ids[1]) - start(&e, ids[0]), 900, "{by}"); + } + assert_eq!([start(&e, ids[0]), start(&e, ids[2])], [0, 2000]); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": 5000})); + assert_eq!(material(&e, t), whole); +} + +#[test] +fn pasting_copied_automation_in_shuffle_leaves_the_clips_alone() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + set_points(&mut e, t, &[200]); + run(&mut e, "edit.copy_all_automation", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + run(&mut e, "edit.select", json!({"clips": [ids[1].0]})); + run(&mut e, "edit.paste", json!({})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); +} + +#[test] +fn automation_follows_shuffle_moves_trims_and_drops() { + let setup = || { + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + set_points(&mut e, t, &[500, 1500, 2500]); + (e, t, ids) + }; + let (mut e, t, ids) = setup(); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 3000})); + assert_eq!(points(&e, t), [500, 1500, 2500]); + assert_eq!([start(&e, ids[1]), start(&e, ids[2]), start(&e, ids[0])], [0, 1000, 2000]); + let (mut e, t, ids) = setup(); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 500})); + assert_eq!(points(&e, t), [1000, 2000]); + let (mut e, t, _) = setup(); + run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 1000})); + assert_eq!(points(&e, t), [500, 1_001_500, 1_002_500]); +} + +#[test] +fn shuffle_automation_stops_at_locked_clips_and_needs_automation_follows_edit() { + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (3000, 1000, 0)]); + e.session_mut().find_clip_mut(ids[2]).unwrap().time_locked = true; + set_points(&mut e, t, &[3500]); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500})); + assert_eq!((start(&e, ids[2]), points(&e, t)), (3000, vec![3500])); + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + set_points(&mut e, t, &[300, 1500, 5000]); + e.session_mut().edit.automation_follows_edit = false; + run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000})); + run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 100})); + assert_eq!(points(&e, t), [300, 1500, 5000]); +} + +#[test] +fn shuffle_clear_moves_automation_with_the_clips() { + let (mut e, t, _) = long("shuffle", &[(0, 10_000, 0), (20_000, 10_000, 0)]); + set_points(&mut e, t, &[0, 9_000, 20_000, 29_000]); + run(&mut e, "edit.clear", json!({"track": t.0, "start": 4_000, "end": 6_000})); + assert_eq!(clips(&e, t), [(0, 4_000), (4_000, 4_000), (18_000, 10_000)]); + assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]); +} + +#[test] +fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() { + let (mut e, t, [a, _]) = engine("relative_grid"); + run(&mut e, "event.tempo", json!({"bpm": 90, "at": 0})); + run(&mut e, "edit.select", json!({"clips": [a.0]})); + run(&mut e, "edit.copy", json!({})); + run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 128_000})); + run(&mut e, "edit.paste", json!({})); + assert!(clips(&e, t).contains(&(128_000, 1000)), "{:?}", clips(&e, t)); + run(&mut e, "edit.paste", json!({"tracks": [t.0], "at": 50_000})); + assert!(clips(&e, t).contains(&(50_000, 1000)), "{:?}", clips(&e, t)); +} + +#[test] +fn start_trims_keep_the_sync_point_on_its_audio() { + let (mut e, t, ids) = long("slip", &[(0, 10_000, 0)]); + let sync = |e: &Engine| e.session().find_clip(ids[0]).unwrap().1.sync_point; + e.session_mut().find_clip_mut(ids[0]).unwrap().sync_point = 5000; + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 2000})); + assert_eq!(sync(&e), 3000); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 8000})); + assert_eq!(sync(&e), 0); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "to": 30_000})); + assert_eq!(clips(&e, t), vec![(30_000, 2000)]); +} + +#[test] +fn imported_time_stamps_count_at_the_session_rate() { + use soundcraft_audio_io::{AudioBuffer, BwfInfo, EncodeOptions, FileFormat}; + let mut e = Engine::default(); + let bwf = BwfInfo { time_reference: 1_587_600_000, ..BwfInfo::default() }; + let opts = EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }; + let bytes = soundcraft_audio_io::encode(&AudioBuffer::new(44_100, 1, 441), &opts).unwrap(); + soundcraft_engine::io::import_audio_bytes(&mut e, "stamped.wav", &bytes, None, None, 0).unwrap(); + assert_eq!(e.session().sources[0].time_reference, 1_728_000_000); +} + +#[test] +fn shuffle_trims_stop_at_a_locked_crossfade_and_stay_inside_the_file() { + let (mut e, t, ids) = long("slip", &[(0, 1000, 500), (1000, 1000, 5000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 950, "end": 1050})); + e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true; + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let before = material(&e, t); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 1500})); + run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 0})); + assert_eq!(material(&e, t), before); + let (mut e, _, [_, b]) = engine("shuffle"); + run(&mut e, "edit.trim_clip", json!({"clip": b.0, "start": 2500, "end": 20_000})); + let c = e.session().find_clip(b).unwrap().1; + assert_eq!((c.source_offset(), c.source_offset() + c.length), (1500, 10_000)); +} + +#[test] +fn duplicate_and_extend_makes_room_in_shuffle() { + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + run(&mut e, "edit.duplicate_extend", json!({"tracks": [t.0], "start": 0, "end": 1000})); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]); + let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]); + e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true; + assert!(e.execute("edit.duplicate_extend", &json!({"tracks": [t.0], "start": 0, "end": 1000})).is_err()); + assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]); +} + +#[test] +fn undo_leaves_the_edit_mode_and_shuffle_lock_alone() { + let (mut e, t, _) = engine("shuffle"); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500})); + run(&mut e, "edit.mode", json!({"mode": "slip"})); + run(&mut e, "edit.shuffle_lock", json!({})); + for step in [Engine::undo, Engine::redo] { + assert!(step(&mut e)); + assert_eq!((e.session().edit.edit_mode, e.session().edit.flag(SHUFFLE_LOCK)), (EditMode::Slip, true)); + } +} + +#[test] +fn shuffle_cuts_reaching_before_the_session_start_keep_the_spacing() { + let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]); + run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": -5000, "end": 500})); + assert_eq!(clips(&e, t), vec![(0, 500), (500, 1000), (1500, 1000)]); +} + +#[test] +fn shuffle_never_pulls_a_crossfade_apart() { + let (mut e, t, ids) = long("slip", &[(0, 9000, 0), (9000, 11_000, 100_000), (20_000, 5000, 200_000)]); + run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 8000, "end": 10_000})); + run(&mut e, "edit.mode", json!({"mode": "shuffle"})); + let err = e.execute("edit.duplicate", &json!({"tracks": [t.0], "start": 5000, "end": 9000})).unwrap_err().to_string(); + assert!(err.contains("crossfade"), "{err}"); + run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 7900})); + assert_eq!(clips(&e, t)[0], (0, 1_000_000)); + assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000_000, 1_008_000]); +} + +#[test] +fn clips_move_to_and_from_hidden_tracks() { + let (mut e, t, [a, _]) = engine("slip"); + let hidden = new_track(&mut e); + run(&mut e, "track.hide", json!({"tracks": [hidden.0], "hidden": true})); + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": hidden.0})); + assert_eq!(clips(&e, hidden), vec![(0, 1000)]); + run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": t.0})); + assert_eq!(clips(&e, t).len(), 2); +} + +#[test] +fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() { + let (mut e, t, _) = engine("shuffle"); + assert!(e.execute("edit.shuffle_lock", &json!({"value": true, "tracks": [t.0]})).is_err()); + run(&mut e, "edit.mode", json!({"mode": "grid"})); + assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err()); +} diff --git a/crates/engine/tests/hostile_sessions.rs b/crates/engine/tests/hostile_sessions.rs index 5a98b02..06b785e 100644 --- a/crates/engine/tests/hostile_sessions.rs +++ b/crates/engine/tests/hostile_sessions.rs @@ -26,6 +26,43 @@ fn demo_json() -> Value { serde_json::to_value(e.session()).unwrap() } +#[test] +fn positions_at_the_ends_of_the_range_do_not_overflow_commands() { + let (max, min) = (serde_json::json!(i64::MAX), serde_json::json!(i64::MIN)); + let kick = "/tracks/0/playlists/0/clips/0"; + let cases = [ + (format!("{kick}/start"), &max, "edit.duplicate"), + (format!("{kick}/start"), &max, "edit.insert_silence"), + (format!("{kick}/length"), &max, "edit.heal"), + (format!("{kick}/length"), &max, "edit.trim_to_file_boundaries"), + (format!("{kick}/content/Audio/offset"), &min, "edit.trim_to_file_boundaries"), + (format!("{kick}/sync_point"), &max, "clip.quantize_to_grid"), + (format!("{kick}/gain_env"), &serde_json::json!([[i64::MIN, 0.0]]), "edit.separate_on_grid"), + ("/tracks/4/automation/0/points/0/at".into(), &min, "automation.thin_all"), + ("/tracks/5/playlists/0/clips/0/content/Midi/sequence/notes/0/start".into(), &max, "event.extract_chords"), + ("/markers/0/start".into(), &max, "event.insert_time"), + ("/markers/1/end".into(), &min, "edit.copy"), + ("/edit/selection/start".into(), &min, "edit.move_selection_left"), + ]; + let (demo, base) = (soundcraft_engine::demo::demo_engine(), demo_json()); + let failed: Vec = cases + .into_iter() + .filter_map(|(path, value, cmd)| { + let mut v = base.clone(); + *v.pointer_mut(&path).unwrap() = value.clone(); + let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap(); + s.pool = demo.session().pool.clone(); + let mut e = soundcraft_engine::Engine::new(s); + if !path.starts_with("/edit") { + e.execute("edit.select_all", &serde_json::json!({})).unwrap(); + } + let r = e.execute(cmd, &serde_json::json!({})); + matches!(r, Err(soundcraft_engine::EngineError::Internal(..))).then(|| format!("{path} = {value}, {cmd}: {r:?}")) + }) + .collect(); + assert!(failed.is_empty(), "{failed:#?}"); +} + proptest! { #![proptest_config(ProptestConfig { cases: 96, .. ProptestConfig::default() })] #[test] diff --git a/crates/engine/tests/long_renders.rs b/crates/engine/tests/long_renders.rs new file mode 100644 index 0000000..5e00f65 --- /dev/null +++ b/crates/engine/tests/long_renders.rs @@ -0,0 +1,57 @@ +//! 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]})); + } +} + +#[test] +fn signal_generator_refuses_a_selection_longer_than_one_render() { + let (mut e, _) = long_clip(48_000); + let end = (MAX_RENDER_SAMPLES / 2 + 1) as i64; + e.execute("edit.select", &json!({"tracks": ["Talk"], "start": 0, "end": end})).unwrap(); + refused(&mut e, "audiosuite.process", &json!({"process": "signal_generator"})); +} 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/random_edits.rs b/crates/engine/tests/random_edits.rs index fc89179..8d90ccf 100644 --- a/crates/engine/tests/random_edits.rs +++ b/crates/engine/tests/random_edits.rs @@ -54,6 +54,8 @@ const CMDS: &[&str] = &[ "event.beat_detective", "automation.thin", "clip.remove_warp", + "clip.time_lock", + "clip.edit_lock", ]; fn params(i: usize, a: i64, b: i64, track: &str) -> Value { @@ -119,7 +121,7 @@ fn check_invariants(e: &Engine) -> Result<(), String> { proptest! { #![proptest_config(ProptestConfig { cases: 48, .. ProptestConfig::default() })] #[test] - fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..48, -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) { + fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..CMDS.len(), -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) { let mut e = soundcraft_engine::demo::demo_engine(); let original = serde_json::to_value(e.session()).unwrap(); let names = ["Kick", "Snare", "Bass", "Pad", "Keys"]; diff --git a/crates/engine/tests/recording.rs b/crates/engine/tests/recording.rs new file mode 100644 index 0000000..fc754fc --- /dev/null +++ b/crates/engine/tests/recording.rs @@ -0,0 +1,21 @@ +//! A take being recorded is never replaced or quit away from. +use serde_json::json; +use soundcraft_engine::{Engine, EngineError}; + +#[test] +fn nothing_replaces_or_quits_a_session_while_it_records() { + let mut e = Engine::default(); + e.transport.recording = true; + for (id, params) in [ + ("session.new", json!({})), + ("session.open", json!({"path": "Song.scraft"})), + ("session.close", json!({})), + ("session.revert", json!({})), + ("app.quit", json!({})), + ] { + assert!(matches!(e.execute(id, ¶ms), Err(EngineError::Disabled(_, why)) if why == "stop recording first"), "{id}"); + } + assert!(e.execute("track.new", &json!({})).is_ok(), "editing goes on"); + e.transport.recording = false; + assert!(e.execute("session.close", &json!({})).is_ok()); +} 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..f5b182f 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -39,9 +39,8 @@ //! //! An instance starts from its stored state; the insert's parameter values are then applied on //! top before the first block (the session's values are kept in step with the plugin, including -//! edits made in its own editor, so both agree; the state carries everything else). -//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session); -//! it calls into the plugins and allocates, so a realtime host runs it only on request. +//! edits made in its own editor, so both agree; the state carries everything else). A host reads +//! states back for saving through each instance's [`Plugin::state_reader`], on its UI thread. //! //! A built-in instrument's parameter values are applied the same way. A sample-playing //! instrument (the Sampler) is handed the decoded audio of its insert's @@ -52,12 +51,14 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; -use soundcraft_dsp::{Plugin, SampleSource, db_to_gain}; +use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; +use soundcraft_midi::CtrlKind; use soundcraft_model::{ AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind, }; -use soundcraft_time::{Range, Samples}; +use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER}; use std::collections::HashMap; +use std::ops::Bound; use std::sync::Arc; /// Default (stereo) main mix width. The actual width is [`main_channels`]. @@ -71,8 +72,8 @@ pub fn main_channels(s: &Session) -> usize { s.main_format().channels().clamp(MAIN_CHANNELS, MAX_CHANNELS) } -/// Slot index used for a track's instrument plugin in [`InstanceSpec`], [`PreparedInstance`] and -/// [`MixEngine::capture_states`] (insert slots use their index). +/// Slot index used for a track's instrument plugin in [`InstanceSpec`] and [`PreparedInstance`] +/// (insert slots use their index). pub const INSTRUMENT_SLOT: usize = usize::MAX; /// Hosted third-party plugin ids (CLAP or VST3): created off the audio thread in realtime use. @@ -128,17 +129,23 @@ pub fn instance_specs(s: &Session) -> Vec { out } +/// A plugin made by [`create_instance`]. +pub struct NewInstance { + pub plugin: Box, + /// The plugin refused the stored state and starts from its defaults. + pub refused_state: bool, +} + /// Creates a plugin (built-in or hosted), restores `state` (if any) and prepares it. Expensive /// for third-party plugins: in realtime use, call it on the control thread. -pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option> { - let mut p = create_plugin(id)?; - if let Some(st) = state - && !p.load_state(st) - { +pub fn create_instance(id: &str, state: Option<&[u8]>, sample_rate: f32, max_block: usize, ch: usize) -> Option { + let mut plugin = create_plugin(id)?; + let refused_state = state.is_some_and(|st| !plugin.load_state(st)); + if refused_state { log::warn!("{id}: the plugin did not accept its stored state"); } - p.prepare(sample_rate, max_block, ch); - Some(p) + plugin.prepare(sample_rate, max_block, ch); + Some(NewInstance { plugin, refused_state }) } /// Peak meter values for one strip (linear, per channel, max over the last block). @@ -150,6 +157,24 @@ pub struct StripMeter { pub peaks: Vec, /// Gain reduction of the first dynamics insert, dB. pub gain_reduction_db: f32, + pub plugin_failures: PluginFailures, +} + +/// How often a strip's plugins were taken out of the signal path for putting out NaN or infinity. +/// Each stays out until the engine is next reset (playback starts or stops). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub struct PluginFailures { + /// It only grows. + pub count: u32, + /// The slot of the latest: an insert index or [`INSTRUMENT_SLOT`]. + pub last: usize, +} + +impl PluginFailures { + fn record(&mut self, slot: usize) { + self.count = self.count.wrapping_add(1); + self.last = slot; + } } impl StripMeter { @@ -158,6 +183,7 @@ impl StripMeter { self.peak = o.peak; self.peaks.clone_from(&o.peaks); self.gain_reduction_db = o.gain_reduction_db; + self.plugin_failures = o.plugin_failures; } fn measure(&mut self, bufs: &[Vec], frames: usize, gr: f32) { @@ -186,6 +212,7 @@ struct PluginSlot { plugin: Box, /// Last values pushed to the plugin, in the insert's parameter-map order. applied: Vec, + failed: bool, } struct Strip { @@ -199,6 +226,8 @@ struct Strip { sample: Option>, /// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek. held: Vec, + /// The instrument's next block starts somewhere new: send it the controllers in effect there. + chase: bool, muted: bool, gr: f32, /// Per-clip effect chains (Clip Effects) and a scratch buffer to render clips into. @@ -212,8 +241,13 @@ struct Strip { align_pre: DelayLine, out_extra: DelayLine, outbuf: Vec>, + failures: PluginFailures, } +/// Most latency delay compensation handles: 2^20 samples, about 22 s at 48 kHz. A strip reports +/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders. +const MAX_LATENCY: usize = 1 << 20; + /// A multichannel delay line (samples), resized only when the delay changes. #[derive(Default)] struct DelayLine { @@ -224,7 +258,7 @@ struct DelayLine { impl DelayLine { fn set(&mut self, len: usize, ch: usize) { - let len = len.min(1 << 20); + let len = len.min(MAX_LATENCY); if len != self.len || self.bufs.len() != ch { self.len = len; self.idx = 0; @@ -251,6 +285,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` @@ -273,6 +310,7 @@ impl Strip { instrument: None, sample: None, held: Vec::new(), + chase: true, muted: false, gr: 0.0, clip_fx: HashMap::new(), @@ -282,6 +320,7 @@ impl Strip { align_pre: DelayLine::default(), out_extra: DelayLine::default(), outbuf: Vec::new(), + failures: PluginFailures::default(), } } } @@ -344,6 +383,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); @@ -358,9 +404,9 @@ impl SlotCtx<'_> { } } else { let state = if third { ins.state_bytes() } else { None }; - create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch) + create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin) }?; - Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new() }) + Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false }) } } @@ -431,24 +477,6 @@ impl MixEngine { out } - /// The state of every live plugin that has one (third-party plugins), as - /// `(track, slot or INSTRUMENT_SLOT, state)`. Calls into the plugins and allocates. - pub fn capture_states(&mut self) -> Vec<(TrackId, usize, Vec)> { - let mut out = Vec::new(); - for (tid, st) in self.strips.iter_mut() { - for (i, slot) in st.plugins.iter_mut().enumerate() { - if let Some(data) = slot.as_mut().and_then(|s| s.plugin.save_state()) { - out.push((*tid, i, data)); - } - } - if let Some(data) = st.instrument.as_mut().and_then(|s| s.plugin.save_state()) { - out.push((*tid, INSTRUMENT_SLOT, data)); - } - } - out.sort_by_key(|(t, s, _)| (*t, *s)); - out - } - pub fn max_block(&self) -> usize { self.max_block } @@ -468,12 +496,18 @@ impl MixEngine { for s in self.strips.values_mut() { for p in s.plugins.iter_mut().flatten() { p.plugin.reset(); + p.failed = false; } if let Some(i) = &mut s.instrument { i.plugin.all_notes_off(); i.plugin.reset(); + i.failed = false; } s.held.clear(); + s.chase = true; + s.align.clear(); + s.align_pre.clear(); + s.out_extra.clear(); } self.meters.clear(); self.main_meter = StripMeter::default(); @@ -511,7 +545,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 +559,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)); @@ -576,6 +612,7 @@ impl MixEngine { i.plugin.all_notes_off(); } st.held.clear(); + st.chase = true; } } self.last_end = pos.saturating_add(frames as i64); @@ -670,6 +707,7 @@ impl MixEngine { } self.order = order; // Master faders process the main mix. + let transport = transport_at(s, pos, !self.monitor_only, self.recording); for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) { if let Some(strip) = self.strips.get_mut(&t.id) { for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) { @@ -681,9 +719,14 @@ impl MixEngine { if let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) && ins.active && !ins.bypass + && !slot.failed { apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); + if contain_failure(slot, &mut strip.buf, frames) { + strip.failures.record(i); + } } } let v0 = volume_db_at(t, pos); @@ -698,7 +741,9 @@ impl MixEngine { } } } - self.meters.entry(t.id).or_default().measure(&self.main, frames, 0.0); + let m = self.meters.entry(t.id).or_default(); + m.measure(&self.main, frames, 0.0); + m.plugin_failures = strip.failures; } } // Metronome: summed after the master fader, so it reaches the speakers without passing @@ -741,7 +786,9 @@ impl MixEngine { /// Send, meter and pan a processed strip into the busses / main mix. fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) { - self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr); + let m = self.meters.entry(t.id).or_default(); + m.measure(&strip.buf, frames, strip.gr); + m.plugin_failures = strip.failures; let src_fmt = strip_format(t, strip.buf.len()); let law = self.pan_law; for (i, snd) in t.mixer.sends.iter().enumerate() { @@ -763,8 +810,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 +820,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; } } } @@ -814,6 +865,7 @@ fn process_strip( for c in strip.buf.iter_mut() { c.iter_mut().take(frames).for_each(|x| *x = 0.0); } + let transport = transport_at(s, pos, !live.monitor_only, live.recording); match t.kind { TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => { // Input monitoring: the track hears its hardware input instead of its clips. @@ -852,7 +904,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) { @@ -885,7 +937,7 @@ fn process_strip( } } TrackKind::Instrument | TrackKind::Midi => { - if let Some(inst) = &mut strip.instrument { + if let Some(inst) = strip.instrument.as_mut().filter(|i| !i.failed) { // #152: push the instrument's stored parameter values into the live plugin // before processing, the same way inserts do below. Without this, the values // loaded from a session (or set via `mix.insert_state`) never reach the plugin @@ -894,6 +946,10 @@ fn process_strip( if let Some(ins) = &t.instrument { apply_params(inst, ins, t, INSTRUMENT_SLOT, pos); } + inst.plugin.set_transport(&transport); + if std::mem::take(&mut strip.chase) { + chase_controllers(s, t, pos, inst); + } schedule_notes(s, t, pos, frames, inst, &mut strip.held); if strip.buf.len() >= 2 { inst.plugin.process(&mut strip.buf, frames); @@ -908,6 +964,9 @@ fn process_strip( *x = 0.5 * (*x + *y); } } + if contain_failure(inst, &mut strip.buf, frames) { + strip.failures.record(INSTRUMENT_SLOT); + } } } _ => {} @@ -922,18 +981,23 @@ fn process_strip( let mut latency = 0usize; for (i, ins) in t.mixer.inserts.iter().enumerate() { let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue }; - if !ins.active || ins.bypass { + if !ins.active || ins.bypass || slot.failed { continue; } apply_params(slot, ins, t, i, pos); + slot.plugin.set_transport(&transport); slot.plugin.process(&mut strip.buf, frames); + if contain_failure(slot, &mut strip.buf, frames) { + strip.failures.record(i); + continue; + } latency = latency.saturating_add(slot.plugin.latency()); if gr == 0.0 { gr = slot.plugin.gain_reduction_db(); } } strip.gr = gr; - strip.latency = latency; + strip.latency = latency.min(MAX_LATENCY); let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5); let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t); strip.muted = t.mixer.mute || auto_mute || soloed_out; @@ -979,20 +1043,64 @@ fn process_strip( } } +/// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the +/// next [`MixEngine::reset`]. True then. +fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec], frames: usize) -> bool { + // A branch-free fold vectorizes; `all` runs about 8 times slower here. + if buf.iter().all(|c| c.get(..frames).unwrap_or(c).iter().fold(true, |ok, x| ok & x.is_finite())) { + return false; + } + for c in buf.iter_mut() { + c.iter_mut().take(frames).for_each(|x| *x = 0.0); + } + slot.failed = true; + true +} + /// 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 +1114,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 +1131,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 +1413,27 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut } } -/// Send note on/offs for MIDI clips overlapping the block. +/// 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 and controllers 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 +1445,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 { @@ -1352,6 +1478,80 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu held.retain(|p| *p != n.pitch); } } + let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1) + ..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1); + for c in &sequence.ctrls { + let tick = c.tick.saturating_add(delay).max(0); + if !near.contains(&tick) { + continue; + } + let at = s.tempo.tick_to_samples(base.saturating_add(tick), sr); + if at >= pos && at < end && at < clip.end() { + inst.plugin.midi(usize::try_from(at - pos).unwrap_or(0), ctrl_message(c.kind, c.value, dp)); + } + } + } +} + +/// A controller event as a MIDI 1.0 message on channel 1 (where the notes go), with `transpose` +/// applied to a poly-pressure key. +fn ctrl_message(kind: CtrlKind, value: i32, transpose: i64) -> [u8; 3] { + let v7 = value.clamp(0, 127) as u8; + match kind { + CtrlKind::Cc(n) => [0xB0, n & 0x7F, v7], + CtrlKind::PitchBend => { + let v = value.saturating_add(8192).clamp(0, 16383); + [0xE0, (v & 0x7F) as u8, (v >> 7) as u8] + } + CtrlKind::ChannelPressure => [0xD0, v7, 0], + CtrlKind::PolyPressure(k) => [0xA0, (i64::from(k) + transpose).clamp(0, 127) as u8, v7], + CtrlKind::Program => [0xC0, v7, 0], + } +} + +/// Before an instrument plays from `pos`: reset its controllers, then send each one the last value +/// it had on this track before `pos`. +fn chase_controllers(s: &Session, t: &Track, pos: Samples, inst: &mut PluginSlot) { + inst.plugin.midi(0, [0xB0, 121, 0]); + const BEND: usize = 128; + const PRESSURE: usize = 129; + const PROGRAM: usize = 130; + let mut last = [(i64::MIN, 0i32); 131]; + let sr = s.sample_rate; + for clip in t.clips() { + let ClipContent::Midi { sequence } = &clip.content else { continue }; + if clip.muted || clip.start >= pos { + continue; + } + let base = s.tempo.samples_to_ticks(clip.start, sr); + let until = pos.min(clip.end()); + for c in &sequence.ctrls { + let slot = match c.kind { + CtrlKind::Cc(n) => usize::from(n & 0x7F), + CtrlKind::PitchBend => BEND, + CtrlKind::ChannelPressure => PRESSURE, + CtrlKind::Program => PROGRAM, + CtrlKind::PolyPressure(_) => continue, + }; + let at = s.tempo.tick_to_samples(base.saturating_add(c.tick), sr); + if at < until + && let Some(l) = last.get_mut(slot) + && at >= l.0 + { + *l = (at, c.value); + } + } + } + for (slot, &(at, value)) in last.iter().enumerate() { + let kind = match slot { + BEND => CtrlKind::PitchBend, + PRESSURE => CtrlKind::ChannelPressure, + PROGRAM => CtrlKind::Program, + n => CtrlKind::Cc(u8::try_from(n).unwrap_or(0)), + }; + if at != i64::MIN { + inst.plugin.midi(0, ctrl_message(kind, value, 0)); + } } } @@ -1400,7 +1600,7 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { apply_params(slot, ins, t, i, pos); latency = latency.saturating_add(slot.plugin.latency()); } - strip.latency = latency; + strip.latency = latency.min(MAX_LATENCY); } fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) { @@ -1436,6 +1636,27 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im } } +fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport { + let sr = s.sample_rate; + let ticks = Some(s.tempo.samples_to_ticks_f(pos, sr)).filter(|t| t.is_finite()).unwrap_or(0.0); + let tick = ticks.floor() as i64; + let meter = s.tempo.meter_at_tick(tick); + let bar = s.tempo.bar_beat_at_tick(tick).bar; + let q = TICKS_PER_QUARTER as f64; + Transport { + playing, + recording, + tempo: s.tempo.tempo_at_tick(tick), + numerator: meter.numerator, + denominator: meter.denominator, + samples: pos, + seconds: pos as f64 / sr.as_f64().max(1.0), + quarters: ticks / q, + bar_start: s.tempo.bar_start_tick(bar) as f64 / q, + bar: bar - 1, + } +} + fn volume_db_at(t: &Track, pos: Samples) -> f32 { if t.mixer.automation_mode.reads() && t.mixer.automation_mode != soundcraft_model::AutomationMode::Write @@ -1458,6 +1679,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 +1945,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 +1967,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; @@ -1734,88 +1977,102 @@ fn apply_matrix(src: &[Vec], frames: usize, dst: &mut [Vec], m: &Matri } } -/// Ceiling on one offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93 -/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory. +/// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about +/// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory; +/// [`RangeRender`] streams any length. pub const MAX_RENDER_SAMPLES: usize = 1 << 29; /// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV /// order). The mix's delay-compensation latency is removed, so the result lines up with the /// timeline. pub fn render_range(s: &Session, range: Range, block: usize) -> Vec> { - let nch = main_channels(s); - let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); - // Probe the latency with one block, then render `len + latency` and drop the head. - let latency = { - let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = probe.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - probe.render(s, range.start, b.min(len.max(1)), &mut tmp); - probe.latency - }; - let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; nch]; - let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - let mut done = 0usize; - while done < total { - let n = (total - done).min(b); - eng.render(s, range.start + done as i64, n, &mut tmp); - for (o, t) in out.iter_mut().zip(tmp.iter()) { - if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { - d.copy_from_slice(sr); - } - } - done += n; - } - if latency > 0 { - for c in &mut out { - c.drain(..latency.min(c.len())); - } - } - out + render_in_memory(s, None, range, block) } /// Offline render of one bus over `range` (planar, the bus format's channel count). /// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline. /// An unknown bus is silent. pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec> { - let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0); - if nch == 0 { - return Vec::new(); - } - let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch); - let latency = { - let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = probe.max_block(); - probe.render(s, range.start, b.min(len.max(1)), &mut []); - probe.track_latency - }; - let total = len.saturating_add(latency); - let mut out = vec![vec![0.0f32; total]; nch]; - let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); - let b = eng.max_block(); - let mut tmp = vec![vec![0.0f32; b]; nch]; - let mut done = 0usize; - while done < total { - let n = (total - done).min(b); - eng.render(s, range.start + done as i64, n, &mut []); - eng.copy_bus(bus, n, &mut tmp); - for (o, t) in out.iter_mut().zip(tmp.iter()) { - if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) { - d.copy_from_slice(sr); - } - } - done += n; - } - if latency > 0 { - for c in &mut out { - c.drain(..latency.min(c.len())); + render_in_memory(s, Some(bus), range, block) +} + +fn render_in_memory(s: &Session, bus: Option, range: Range, block: usize) -> Vec> { + let nch = source_channels(s, bus); + let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1)); + let mut out: Vec> = (0..nch).map(|_| Vec::with_capacity(len)).collect(); + let mut render = RangeRender::new(s, bus, Range::new(range.start, range.start + len as i64), block); + while let Some((chunk, n)) = render.next_block(s) { + for (o, c) in out.iter_mut().zip(chunk) { + o.extend_from_slice(c.get(..n).unwrap_or_default()); } } out } +/// Channels of the main mix, or of `bus` (none for an unknown bus). +pub fn source_channels(s: &Session, bus: Option) -> usize { + match bus { + Some(id) => s.bus(id).map_or(0, |b| b.format.channels()), + None => main_channels(s), + } +} + +/// [`render_range`] or [`render_bus`] of any length, a block at a time. +pub struct RangeRender { + eng: MixEngine, + bus: Option, + tmp: Vec>, + start: Samples, + done: usize, + total: usize, + latency: usize, +} + +impl RangeRender { + /// Renders the main mix over `range`, or `bus` when one is named. + pub fn new(s: &Session, bus: Option, range: Range, block: usize) -> Self { + let nch = source_channels(s, bus); + let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block); + let tmp = vec![vec![0.0f32; eng.max_block()]; nch]; + let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) }; + RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 } + } + + fn render(&mut self, s: &Session, pos: Samples, n: usize) { + match self.bus { + Some(id) => { + self.eng.render(s, pos, n, &mut []); + self.eng.copy_bus(id, n, &mut self.tmp); + } + None => self.eng.render(s, pos, n, &mut self.tmp), + } + } + + /// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) and + /// returns it as `(channels, n)`: the next `n` samples of the range lead each channel (none + /// while the mix's latency is still being skipped). `None` once the range is done. + pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec], usize)> { + if self.done >= self.total { + return None; + } + let n = (self.total - self.done).min(self.eng.max_block()); + self.render(s, self.start + self.done as i64, n); + if self.done == 0 { + // The first block tells the mix's latency: render that much more and drop the head. + self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency }; + self.total = self.total.saturating_add(self.latency); + } + let skip = self.latency.saturating_sub(self.done).min(n); + self.done += n; + if skip > 0 && skip < n { + for c in &mut self.tmp { + c.copy_within(skip..n, 0); + } + } + Some((&self.tmp, n - skip)) + } +} + /// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped; /// Ambisonics play their W channel). Mono/stereo input is returned unchanged. pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec]) -> Vec> { @@ -1852,17 +2109,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); @@ -2018,6 +2272,11 @@ mod tests { fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) { let mut s = Session::default(); + let t = add_dc_track(&mut s, level, frames); + (s, t) + } + + fn add_dc_track(s: &mut Session, level: f32, frames: usize) -> TrackId { let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; frames]] }; s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(buf))); @@ -2025,7 +2284,7 @@ mod tests { if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) { pl.clips.push(Clip::audio(id, "dc", SourceId(900), 0, 0, frames as i64)); } - (s, t) + t } #[test] @@ -2063,12 +2322,56 @@ mod tests { assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); } + #[test] + fn fade_curvature_reaches_the_rendered_audio() { + for fade_in in [true, false] { + for curvature in [-0.5, 0.0, 0.5] { + let (mut s, t) = session_with_dc(1.0, 8192); + s.track_mut(t).unwrap().mixer.pan = vec![-1.0]; + let fade = soundcraft_model::Fade { len: 8192, shape: soundcraft_model::FadeShape::SCurve, curvature }; + let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + let out = render_range(&s, Range::new(0, 8192), 512); + let expected = if curvature < 0.0 { + 0.2 + } else if curvature > 0.0 { + 0.8 + } else { + 0.5 + }; + assert!((out[0][4096] - expected).abs() < 1e-5, "{fade_in}, {curvature}: {} vs {expected}", out[0][4096]); + assert!(out[1][4096].abs() < 1e-6); + assert!(out[0][if fade_in { 0 } else { 8191 }].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); { let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; - c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear }; + c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear, curvature: 0.0 }; c.gain_db = -6.0; } let out = render_range(&s, Range::new(0, 2000), 512); @@ -2136,7 +2439,7 @@ mod tests { let _ = render_range(&s, Range::new(0, 4800), 512); } - /// A built-in gain behind a third-party id, with a state blob and a log of parameter sets. + /// A built-in gain behind a third-party id, with a log of parameter sets. struct Stateful { inner: Box, blob: Vec, @@ -2163,11 +2466,164 @@ mod tests { fn process(&mut self, io: &mut [Vec], frames: usize) { self.inner.process(io, frames); } - fn save_state(&mut self) -> Option> { - Some(self.blob.clone()) + fn state_reader(&mut self) -> Option> { + let blob = self.blob.clone(); + Some(Box::new(move || Some(blob.clone()))) } } + struct Clock { + inner: Box, + seen: Arc>>, + } + + impl Plugin for Clock { + 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 set_transport(&mut self, t: &Transport) { + self.seen.lock().unwrap().push(*t); + } + } + + #[test] + fn plugins_get_the_session_transport() { + let (mut s, t) = session_with_dc(0.5, 48_000 * 4); + s.tempo.set_tempo(0, 90.0).unwrap(); + s.tempo.set_meter(0, 3, 4).unwrap(); + s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.clock")); + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let clock = Clock { inner: soundcraft_dsp::create("gain").unwrap(), seen: Arc::clone(&seen) }; + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.clock".into(), ch: 1, plugin: Box::new(clock) }]); + render_block(&mut eng, &s, 96_000); + eng.monitor_only = true; + render_block(&mut eng, &s, 96_000); + let seen = seen.lock().unwrap(); + let t0 = seen[0]; + assert!(t0.playing && !t0.recording); + assert_eq!((t0.tempo, t0.numerator, t0.denominator, t0.samples, t0.bar), (90.0, 3, 4, 96_000, 1)); + assert!((t0.seconds - 2.0).abs() < 1e-9 && (t0.quarters - 3.0).abs() < 1e-9 && (t0.bar_start - 3.0).abs() < 1e-9); + assert!(!seen[1].playing); + } + + struct Ears { + inner: Box, + heard: Arc>>, + } + + impl Plugin for Ears { + 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 midi(&mut self, offset: usize, msg: [u8; 3]) { + self.heard.lock().unwrap().push((offset, msg)); + } + } + + #[test] + fn controllers_play_at_their_offsets_and_are_chased_after_a_seek() { + 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.ears")); + let cc = |tick, kind, value| soundcraft_midi::CtrlEvent { tick, channel: 0, kind, value }; + let ctrls = vec![cc(4, CtrlKind::Cc(64), 127), cc(960, CtrlKind::PitchBend, -8192), cc(1920, CtrlKind::Cc(64), 0)]; + let seq = soundcraft_midi::Sequence { ctrls, ..Default::default() }; + let cid = s.new_clip_id(); + s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000 * 4, seq)); + let heard = Arc::new(std::sync::Mutex::new(Vec::new())); + let ears = Ears { 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.ears".into(), ch: 2, plugin: Box::new(ears) }]); + render_block(&mut eng, &s, 0); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (100, [0xB0, 64, 127])]); + render_block(&mut eng, &s, 36_000); + assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (0, [0xB0, 64, 127]), (0, [0xE0, 0, 0])]); + render_block(&mut eng, &s, 36_512); + assert!(heard.lock().unwrap().is_empty()); + } + + 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); @@ -2175,7 +2631,7 @@ mod tests { } #[test] - fn external_instances_are_adopted_retired_and_captured() { + fn external_instances_are_adopted_and_retired() { let (mut s, t) = session_with_dc(0.5, 48_000); let mut ins = Insert::new("clap:test.fake"); ins.params.insert("gain".into(), -6.0); @@ -2191,7 +2647,7 @@ mod tests { let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let mut gain = soundcraft_dsp::create("gain").unwrap(); gain.prepare(48_000.0, 512, 1); - let fake = Stateful { inner: gain, blob: b"live state".to_vec(), sets: Arc::clone(&sets) }; + let fake = Stateful { inner: gain, blob: Vec::new(), sets: Arc::clone(&sets) }; // An instance for the wrong channel count is retired, not used. let wrong = Stateful { inner: soundcraft_dsp::create("gain").unwrap(), blob: Vec::new(), sets: Arc::clone(&sets) }; eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 2, plugin: Box::new(wrong) }]); @@ -2204,13 +2660,11 @@ mod tests { } assert!((wet / dry - 0.501).abs() < 0.01, "{wet} vs {dry}"); assert!(sets.load(std::sync::atomic::Ordering::Relaxed) > 0, "no stored state: session params are pushed"); - assert_eq!(eng.capture_states(), vec![(t, 0, b"live state".to_vec())]); assert!(eng.take_retired().is_empty()); // Removing the insert retires the instance instead of dropping it here. s.track_mut(t).unwrap().mixer.inserts[0] = None; render_block(&mut eng, &s, 20 * 512); assert_eq!(eng.take_retired().len(), 1); - assert!(eng.capture_states().is_empty()); // Instances nobody claims are retired too; drain_plugins empties the engine. eng.adopt(vec![PreparedInstance { track: t, slot: 3, id: "clap:x".into(), ch: 1, plugin: soundcraft_dsp::create("gain").unwrap() }]); render_block(&mut eng, &s, 21 * 512); @@ -2218,6 +2672,99 @@ mod tests { assert!(eng.drain_plugins().is_empty()); } + struct Nan(Box); + + impl Plugin for Nan { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.0.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.0.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.0.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.0.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.0.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + io.iter_mut().for_each(|c| c.iter_mut().take(frames).for_each(|x| *x = f32::NAN)); + } + } + + fn nan(track: TrackId, slot: usize, ch: usize) -> Vec { + vec![PreparedInstance { track, slot, id: "clap:test.nan".into(), ch, plugin: Box::new(Nan(soundcraft_dsp::create("gain").unwrap())) }] + } + + #[test] + fn an_insert_that_puts_out_nan_is_taken_out_until_the_next_reset() { + let (mut s, a) = session_with_dc(0.5, 48_000); + let b = add_dc_track(&mut s, 0.5, 48_000); + s.track_mut(a).unwrap().mixer.inserts[2] = Some(Insert::new("clap:test.nan")); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + let both = render_block(&mut eng, &s, 0); + eng.adopt(nan(a, 2, 1)); + let only_b = render_block(&mut eng, &s, 512); + assert!(only_b > 0.3 && (only_b - both / 2.0).abs() < 1e-6, "{only_b} vs {both}"); + assert_eq!(eng.meters[&a].plugin_failures, PluginFailures { count: 1, last: 2 }); + assert_eq!(eng.meters[&b].plugin_failures, PluginFailures::default()); + assert_eq!(render_block(&mut eng, &s, 1024), both); + eng.reset(); + assert_eq!(render_block(&mut eng, &s, 1536), only_b); + assert_eq!(eng.meters[&a].plugin_failures.count, 2); + } + + #[test] + fn nan_from_an_instrument_or_a_master_insert_is_contained() { + let mut s = synth_session(-6.0); + let synth = s.tracks[0].id; + s.track_mut(synth).unwrap().instrument = Some(Insert::new("clap:test.nan")); + let b = add_dc_track(&mut s, 0.5, 48_000); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + eng.adopt(nan(synth, INSTRUMENT_SLOT, 2)); + let only_b = render_block(&mut eng, &s, 0); + assert!(only_b > 0.3, "{only_b}"); + assert_eq!(eng.meters[&synth].plugin_failures, PluginFailures { count: 1, last: INSTRUMENT_SLOT }); + assert_eq!(eng.meters[&b].plugin_failures, PluginFailures::default()); + let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None); + s.track_mut(m).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.nan")); + eng.adopt(nan(m, 0, 2)); + assert_eq!(render_block(&mut eng, &s, 512), 0.0); + assert_eq!(eng.meters[&m].plugin_failures, PluginFailures { count: 1, last: 0 }); + assert_eq!(render_block(&mut eng, &s, 1024), only_b); + } + + #[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!(eng.take_retired().is_empty()); + 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); + let mut retired = eng.take_retired(); + assert_eq!(retired.len(), 1, "the old instance is retired"); + assert_eq!(retired[0].save_state(), Some(b"old".to_vec()), "the new instance stays"); + } + #[test] fn stored_state_comes_first_then_session_params() { let (mut s, t) = session_with_dc(0.5, 48_000); @@ -2287,6 +2834,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() } @@ -2334,6 +2902,81 @@ mod tests { assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0])); } + #[test] + fn offline_renders_line_up_with_the_timeline_in_any_block_size() { + let mut s = Session::default(); + let mut imp = vec![0.0f32; 48_000]; + imp[1000] = 1.0; + s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] }))); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + let bus = s.add_bus("Stem", ChannelFormat::Stereo); + let cid = s.new_clip_id(); + let tr = s.track_mut(t).unwrap(); + tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(900), 0, 0, 48_000)); + tr.mixer.inserts[0] = Some(Insert::new("maximizer")); + let mut send = soundcraft_model::SendSlot::new(Route::Bus(bus)); + send.level_db = 0.0; + tr.mixer.sends[0] = Some(send); + let latency = soundcraft_dsp::create("maximizer").unwrap().latency(); + assert!(latency > 16); + for source in [None, Some(bus)] { + for block in [16, 100, 512] { + let mut got = Vec::new(); + let mut skipped = 0; + let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block); + while let Some((chunk, n)) = render.next_block(&s) { + assert!(n <= block); + skipped += usize::from(n == 0); + got.extend_from_slice(&chunk[0][..n]); + } + assert_eq!(got.len(), 2000, "{source:?} block {block}"); + assert_eq!(skipped, latency / block, "{source:?} block {block}"); + let peak = got.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i); + assert_eq!(peak, Some(100), "{source:?} block {block}"); + } + } + } + + #[test] + fn a_plugins_bogus_latency_is_capped() { + struct Latent(Box); + impl Plugin for Latent { + fn info(&self) -> &'static soundcraft_dsp::PluginInfo { + self.0.info() + } + fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { + self.0.prepare(sr, mb, ch); + } + fn reset(&mut self) { + self.0.reset(); + } + fn set_param(&mut self, id: &str, v: f32) -> bool { + self.0.set_param(id, v) + } + fn param(&self, id: &str) -> Option { + self.0.param(id) + } + fn process(&mut self, io: &mut [Vec], frames: usize) { + self.0.process(io, frames); + } + fn latency(&self) -> usize { + u32::MAX as usize + } + } + let (mut s, t) = session_with_dc(0.5, 48_000); + let aux = s.add_track(TrackKind::Aux, ChannelFormat::Mono, None); + let mut eng = MixEngine::new(48_000.0, 512); + eng.set_external_instances(true); + for track in [t, aux] { + s.track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.latent")); + let mut gain = soundcraft_dsp::create("gain").unwrap(); + gain.prepare(48_000.0, 512, 1); + eng.adopt(vec![PreparedInstance { track, slot: 0, id: "clap:test.latent".into(), ch: 1, plugin: Box::new(Latent(gain)) }]); + } + render_block(&mut eng, &s, 0); + assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY)); + } + #[test] fn input_monitoring_replaces_clips() { let (mut s, t) = session_with_dc(0.5, 4800); 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..850cf04 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -1,8 +1,8 @@ //! Clips: windows onto audio sources or MIDI sequences, placed on a playlist. -use crate::{ClipId, SourceId}; +use crate::{ClipId, MAX_NAME_CHARS, SourceId, truncate_chars}; use soundcraft_midi::Sequence; -use soundcraft_time::{Range, Samples}; +use soundcraft_time::{MAX_POSITION, Range, Samples}; /// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] @@ -47,10 +47,37 @@ impl FadeShape { } /// A fade at a clip boundary, `len` samples long. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)] pub struct Fade { pub len: Samples, pub shape: FadeShape, + /// Continuous bend (-1..1); zero preserves the original shape exactly. + #[serde(default)] + pub curvature: f32, +} + +impl Fade { + /// The envelope used by both rendering and audio, with fixed zero/unity endpoints. + pub fn gain(self, t: f32) -> f32 { + let base = self.shape.gain(t); + let curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + if curvature == 0.0 || base <= 0.0 || base >= 1.0 { + return base; + } + let weighted = base * (curvature * 4.0).exp2(); + weighted / (weighted + 1.0 - base) + } + + /// Invert the bend at the midpoint so a vertical drag follows the pointer. + pub fn curvature_for_midpoint_gain(self, gain: f32) -> f32 { + let gain = if gain.is_finite() { gain.clamp(0.001, 0.999) } else { self.gain(0.5) }; + let base = self.shape.gain(0.5); + (((gain / (1.0 - gain)).log2() - (base / (1.0 - base)).log2()) / 4.0).clamp(-1.0, 1.0) + } + + pub fn sanitize(&mut self) { + self.curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + } } /// What a clip plays. @@ -105,6 +132,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 +162,7 @@ impl Clip { color: None, stretch: 1.0, group: None, + loop_id: None, } } @@ -189,17 +221,23 @@ impl Clip { /// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades. pub fn gain_at(&self, rel: Samples) -> f32 { + self.gain_at_with_fades(rel, self.fade_in, self.fade_out) + } + + /// Evaluate temporary fades while retaining clip gain and its envelope. + /// UI previews can use this without modifying the clip or rebuilding media. + pub fn gain_at_with_fades(&self, rel: Samples, fade_in: Fade, fade_out: Fade) -> f32 { let mut db = self.gain_db; if !self.gain_env.is_empty() { db += env_value(&self.gain_env, rel); } let mut g = db_to_gain(db); - if self.fade_in.len > 0 && rel < self.fade_in.len { - g *= self.fade_in.shape.gain(rel as f32 / self.fade_in.len as f32); + if fade_in.len > 0 && rel < fade_in.len { + g *= fade_in.gain(rel as f32 / fade_in.len as f32); } - let from_end = self.length - rel; - if self.fade_out.len > 0 && from_end <= self.fade_out.len { - g *= self.fade_out.shape.gain(from_end as f32 / self.fade_out.len as f32); + let from_end = self.length.saturating_sub(rel); + if fade_out.len > 0 && from_end <= fade_out.len { + g *= fade_out.gain(from_end as f32 / fade_out.len as f32); } g } @@ -214,14 +252,24 @@ impl Clip { // MIDI note ticks are re-anchored by the engine, which knows the tempo map. self.length -= delta; self.start = new_start; + if self.sync_point != 0 { + self.sync_point = self.sync_point.saturating_sub(delta); + } + self.drop_stray_sync(); 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`. pub fn trim_end_to(&mut self, new_end: Samples) { let new_end = new_end.max(self.start + 1); self.length = new_end - self.start; + self.drop_stray_sync(); self.clamp_fades(); } @@ -230,6 +278,48 @@ impl Clip { self.fade_in.len = self.fade_in.len.clamp(0, self.length); self.fade_out.len = self.fade_out.len.clamp(0, self.length); } + + /// A sync point (0 means none) that a trim left outside the clip no longer marks anything. + fn drop_stray_sync(&mut self) { + if !(0..self.length).contains(&self.sync_point) { + self.sync_point = 0; + } + } + + /// Repair a clip read from a file: positions and lengths within ±[`MAX_POSITION`], as commands + /// keep theirs, fades within the clip, a name that can be drawn, and a usable gain and stretch. + pub fn sanitize(&mut self) { + bound(&mut self.start); + self.length = self.length.clamp(1, MAX_POSITION); + bound(&mut self.sync_point); + self.gain_env.iter_mut().for_each(|(at, _)| bound(at)); + match &mut self.content { + ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => bound(offset), + ClipContent::Midi { sequence } => bound_sequence(sequence), + } + self.clamp_fades(); + self.fade_in.sanitize(); + self.fade_out.sanitize(); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + if !self.gain_db.is_finite() { + self.gain_db = 0.0; + } + if !self.stretch.is_finite() || self.stretch <= 0.0 { + self.stretch = 1.0; + } + } +} + +pub(crate) fn bound(at: &mut Samples) { + *at = (*at).clamp(-MAX_POSITION, MAX_POSITION); +} + +pub(crate) fn bound_sequence(s: &mut Sequence) { + for n in &mut s.notes { + bound(&mut n.start); + n.length = n.length.clamp(1, MAX_POSITION); + } + s.ctrls.iter_mut().for_each(|c| bound(&mut c.tick)); } fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { @@ -237,7 +327,11 @@ fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 { for &(t, v) in env { if t >= at { return match prev { - Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32), + Some((pt, pv)) if t > pt => { + let elapsed = i128::from(at).saturating_sub(i128::from(pt)); + let span = i128::from(t).saturating_sub(i128::from(pt)); + pv + (v - pv) * (elapsed as f32 / span as f32) + } _ => v, }; } @@ -264,11 +358,78 @@ mod tests { } } + #[test] + fn fade_curvature_is_bounded_monotonic_and_inverts_at_the_midpoint() { + for shape in FadeShape::ALL { + let original = Fade { len: 1000, shape, curvature: 0.0 }; + for i in 0..=128 { + let t = i as f32 / 128.0; + assert_eq!(original.gain(t), shape.gain(t), "legacy curves must be unchanged"); + } + for curvature in [-1.0, -0.5, 0.0, 0.5, 1.0] { + let fade = Fade { curvature, ..original }; + assert_eq!(fade.gain(0.0), 0.0); + assert_eq!(fade.gain(1.0), 1.0); + let mut previous = 0.0; + for i in 0..=128 { + let gain = fade.gain(i as f32 / 128.0); + assert!(gain.is_finite() && (0.0..=1.0).contains(&gain)); + assert!(gain >= previous, "{shape:?}, {curvature}"); + previous = gain; + } + assert!((fade.curvature_for_midpoint_gain(fade.gain(0.5)) - curvature).abs() < 1e-5); + } + assert!((Fade { curvature: 0.5, ..original }).gain(0.5) > original.gain(0.5)); + assert!((Fade { curvature: -0.5, ..original }).gain(0.5) < original.gain(0.5)); + } + } + + #[test] + fn fade_curvature_loads_legacy_data_and_sanitizes_hostile_values() { + let fade: Fade = serde_json::from_str(r#"{"len":100,"shape":"SCurve"}"#).unwrap(); + assert_eq!(fade.curvature, 0.0); + for curvature in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -10.0, 10.0] { + let mut fade = Fade { curvature, ..fade }; + assert!(fade.gain(0.5).is_finite()); + fade.sanitize(); + assert!(fade.curvature.is_finite() && (-1.0..=1.0).contains(&fade.curvature)); + let roundtrip: Fade = serde_json::from_str(&serde_json::to_string(&fade).unwrap()).unwrap(); + assert_eq!(roundtrip, fade); + } + } + + #[test] + fn temporary_fades_keep_clip_gain_and_envelope_without_mutating_the_clip() { + let mut clip = Clip::audio(ClipId(1), "preview", SourceId(1), 0, 0, 1000); + clip.gain_db = -6.0; + clip.gain_env = vec![(0, 0.0), (1000, 6.0)]; + clip.fade_in = Fade { len: 100, shape: FadeShape::SCurve, curvature: 0.25 }; + clip.fade_out = Fade { len: 100, shape: FadeShape::Logarithmic, curvature: -0.1 }; + let before = clip.clone(); + let fade_in = Fade { len: 1000, shape: FadeShape::Linear, curvature: 0.5 }; + let fade_out = Fade { len: 1000, shape: FadeShape::Linear, curvature: -0.5 }; + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + let mut committed = clip.clone(); + committed.fade_in = fade_in; + committed.fade_out = fade_out; + for rel in [0, 50, 500, 999, 1000] { + assert_eq!(clip.gain_at_with_fades(rel, fade_in, fade_out), committed.gain_at(rel)); + } + for rel in [i64::MIN, i64::MAX] { + assert!(clip.gain_at(rel).is_finite()); + assert!(clip.gain_at_with_fades(rel, fade_in, fade_out).is_finite()); + } + assert_eq!(clip, before); + clip.gain_env = vec![(i64::MIN, 0.0), (i64::MAX, 6.0)]; + assert!((clip.gain_at(500) - db_to_gain(-3.0)).abs() < 1e-6); + assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6); + } + #[test] fn gain_with_fades() { let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); - c.fade_in = Fade { len: 100, shape: FadeShape::Linear }; - c.fade_out = Fade { len: 100, shape: FadeShape::Linear }; + c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; assert!(c.gain_at(0).abs() < 1e-6); assert!((c.gain_at(50) - 0.5).abs() < 1e-6); assert!((c.gain_at(500) - 1.0).abs() < 1e-6); @@ -282,6 +443,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); @@ -293,7 +475,7 @@ mod tests { assert_eq!(c.length, 1); // Regression: trimming the start must not leave a fade-out longer than the clip. let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 1000); - c.fade_out = Fade { len: 800, shape: FadeShape::Linear }; + c.fade_out = Fade { len: 800, shape: FadeShape::Linear, curvature: 0.0 }; c.trim_start_to(900); assert!(c.fade_out.len <= c.length); } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 55d605d..61596b7 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -22,7 +22,10 @@ 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, MAX_COMMENT_CHARS, MAX_NAME_CHARS, SESSION_EXTENSION, Session, SessionError, Tool, + TrackView, ZoomState, ZoomToggle, truncate_chars, +}; 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..449d82d 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -1,5 +1,6 @@ //! The session: the whole document. +use crate::clip::{bound, bound_sequence}; use crate::{ Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool, TRACK_COLORS, Track, TrackId, TrackKind, VideoSource, @@ -9,6 +10,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}")] @@ -148,6 +152,14 @@ impl Default for ZoomState { } } +/// The view Zoom Toggle goes back to. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ZoomToggle { + pub samples_per_px: f64, + pub scroll: Samples, + pub heights: Vec<(TrackId, crate::TrackHeight)>, +} + /// Edit-window state saved with the session (selection, tools, modes, view settings). #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct EditState { @@ -172,6 +184,8 @@ pub struct EditState { pub main_counter: TimeFormat, pub sub_counter: Option, pub zoom: ZoomState, + /// Set while Zoom Toggle is on. + pub zoom_toggle: Option, /// Visible rulers. pub rulers: Vec, pub link_timeline_edit: bool, @@ -245,6 +259,7 @@ impl Default for EditState { main_counter: TimeFormat::MinSecs, sub_counter: Some(TimeFormat::Samples), zoom: ZoomState::default(), + zoom_toggle: None, rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()], link_timeline_edit: true, link_track_edit: false, @@ -421,6 +436,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]) @@ -432,14 +462,15 @@ impl Session { if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") { return base.to_string(); } - let mut i = 1; - loop { - let cand = format!("{base} {i}"); - if !taken(&cand) { - return cand; - } - i += 1; - } + let prefix = format!("{base} "); + let used: std::collections::HashSet = self + .tracks + .iter() + .filter_map(|t| t.name.strip_prefix(&prefix)) + .filter_map(|n| n.parse().ok().filter(|i: &u64| i.to_string() == n)) + .collect(); + let i = (1..).find(|i| !used.contains(i)).unwrap_or(1); + format!("{base} {i}") } /// Add a track with defaults; returns its id. @@ -528,8 +559,12 @@ impl Session { .max() .unwrap_or(0); self.next_id = self.next_id.max(max_id.saturating_add(1)); + truncate_chars(&mut self.name, MAX_NAME_CHARS); + truncate_chars(&mut self.comments, MAX_COMMENT_CHARS); for t in &mut self.tracks { t.mixer.sanitize(); + truncate_chars(&mut t.name, MAX_NAME_CHARS); + truncate_chars(&mut t.comments, MAX_COMMENT_CHARS); if t.playlists.is_empty() { t.playlists.push(crate::Playlist::new(t.name.clone())); } @@ -537,24 +572,42 @@ impl Session { t.active_playlist = 0; } for p in &mut t.playlists { + truncate_chars(&mut p.name, MAX_NAME_CHARS); p.clips.retain(|c| c.length > 0); - for c in &mut p.clips { - c.fade_in.len = c.fade_in.len.clamp(0, c.length); - c.fade_out.len = c.fade_out.len.clamp(0, c.length); - if !c.gain_db.is_finite() { - c.gain_db = 0.0; - } - if !c.stretch.is_finite() || c.stretch <= 0.0 { - c.stretch = 1.0; - } - } + p.clips.iter_mut().for_each(Clip::sanitize); p.sort(); } for l in &mut t.automation { l.points.retain(|p| p.value.is_finite()); + l.points.iter_mut().for_each(|p| bound(&mut p.at)); l.points.sort_by_key(|p| p.at); } } + for m in &mut self.markers { + bound(&mut m.start); + bound(&mut m.end); + truncate_chars(&mut m.name, MAX_NAME_CHARS); + truncate_chars(&mut m.comments, MAX_COMMENT_CHARS); + } + for (at, key) in &mut self.key_signatures { + bound(at); + truncate_chars(key, MAX_NAME_CHARS); + } + self.midi_originals.values_mut().for_each(bound_sequence); + let e = &mut self.edit; + for at in [ + &mut e.selection.start, + &mut e.selection.end, + &mut e.timeline_selection.start, + &mut e.timeline_selection.end, + &mut e.playhead, + &mut e.pre_roll, + &mut e.post_roll, + &mut e.zoom.scroll, + &mut self.timecode_start, + ] { + bound(at); + } for v in &mut self.videos { if !v.frame_rate.is_finite() || v.frame_rate < 0.0 { v.frame_rate = 0.0; @@ -562,7 +615,12 @@ impl Session { if !v.duration.is_finite() || v.duration < 0.0 { v.duration = 0.0; } + truncate_chars(&mut v.name, MAX_NAME_CHARS); } + self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS)); + self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS)); + self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS)); + self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS)); // Drop performances whose clip no longer exists anywhere (any playlist). let clip_ids: std::collections::BTreeSet = self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect(); @@ -570,6 +628,23 @@ impl Session { if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 { self.edit.zoom.samples_per_px = 1024.0; } + if SampleRate::new(self.sample_rate.hz()).is_err() { + self.sample_rate = SampleRate::default(); + } + self.tempo.sanitize(); + } +} + +/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing +/// a name of a million characters overflows the renderer's buffers. +pub const MAX_NAME_CHARS: usize = 256; +/// The most characters kept of a comment read from a file. +pub const MAX_COMMENT_CHARS: usize = 4096; + +/// Cut `s` to at most `max` characters. +pub fn truncate_chars(s: &mut String, max: usize) { + if let Some((end, _)) = s.char_indices().nth(max) { + s.truncate(end); } } @@ -641,6 +716,39 @@ mod tests { assert_eq!(c.source_offset(), 580); } + #[test] + fn unique_track_names_take_the_first_free_number() { + let mut s = Session::default(); + for name in ["Piano", "Piano 1", "Piano 3", "Piano 02", "Pianos 2"] { + s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name)); + } + assert_eq!(s.unique_track_name("Piano"), "Piano 2"); + assert_eq!(s.unique_track_name("Organ"), "Organ"); + assert_eq!(s.unique_track_name("Audio"), "Audio 1"); + } + + #[test] + fn names_from_a_file_are_cut_to_a_length_that_can_be_drawn() { + let mut s = Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None); + if let Some(tr) = s.track_mut(t) { + tr.name = "é".repeat(1_000); + } + s.comments = "c".repeat(10_000); + let back = Session::from_json(&s.to_json().unwrap()).unwrap(); + assert_eq!(back.track(t).map(|tr| tr.name.chars().count()), Some(MAX_NAME_CHARS)); + assert_eq!(back.comments.len(), MAX_COMMENT_CHARS); + } + + #[test] + fn a_bad_sample_rate_or_tempo_is_repaired_on_load() { + let mut v = serde_json::to_value(Session::default()).unwrap(); + v["sample_rate"] = serde_json::json!(0); + v["tempo"]["tempos"] = serde_json::json!([{"tick": 0, "bpm": 4e9}]); + let s = Session::from_json(&v.to_string()).unwrap(); + assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default())); + } + #[test] fn rejects_garbage_and_future_versions() { assert!(Session::from_json("not json").is_err()); diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs index aa2b64f..9df78c3 100644 --- a/crates/model/src/track.rs +++ b/crates/model/src/track.rs @@ -85,6 +85,8 @@ impl TrackKind { /// Track heights (View › Track Height presets). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)] pub enum TrackHeight { + /// Any height from Micro to Extreme, in points. + Custom(u16), Micro, Mini, Small, @@ -93,7 +95,8 @@ pub enum TrackHeight { Large, Jumbo, Extreme, - Fit, + /// Fit To Window: this many points. + Fit(u16), } impl TrackHeight { @@ -108,6 +111,7 @@ impl TrackHeight { ]; pub fn points(self) -> f32 { match self { + TrackHeight::Custom(points) => f32::from(points).clamp(TrackHeight::Micro.points(), TrackHeight::Extreme.points()), TrackHeight::Micro => 16.0, TrackHeight::Mini => 24.0, TrackHeight::Small => 46.0, @@ -115,11 +119,12 @@ impl TrackHeight { TrackHeight::Large => 132.0, TrackHeight::Jumbo => 220.0, TrackHeight::Extreme => 440.0, - TrackHeight::Fit => 88.0, + TrackHeight::Fit(points) => f32::from(points.max(16)), } } pub fn label(self) -> &'static str { match self { + TrackHeight::Custom(_) => "custom", TrackHeight::Micro => "micro", TrackHeight::Mini => "mini", TrackHeight::Small => "small", @@ -127,12 +132,24 @@ impl TrackHeight { TrackHeight::Large => "large", TrackHeight::Jumbo => "jumbo", TrackHeight::Extreme => "extreme", - TrackHeight::Fit => "fit to window", + TrackHeight::Fit(_) => "fit to window", } } pub fn from_id(s: &str) -> Option { TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s)) } + /// The preset this many points tall, else a custom height clamped to Micro..Extreme. + pub fn from_points(points: f32) -> TrackHeight { + let p = points.max(TrackHeight::Micro.points()).min(TrackHeight::Extreme.points()).round() as u16; + TrackHeight::ALL.into_iter().find(|h| h.points() == f32::from(p)).unwrap_or(TrackHeight::Custom(p)) + } + /// The next preset taller (or shorter) than this height; Extreme (or Micro) stays. + pub fn step(self, taller: bool) -> TrackHeight { + let p = self.points(); + let next = + if taller { TrackHeight::ALL.into_iter().find(|h| h.points() > p) } else { TrackHeight::ALL.into_iter().rev().find(|h| h.points() < p) }; + next.unwrap_or(self) + } } /// The default track colour palette (original, chosen for SoundCraft). @@ -337,4 +354,20 @@ mod tests { assert_eq!(TrackKind::from_id(k.id()), Some(k)); } } + + #[test] + fn custom_heights_round_to_presets_and_step_through_them() { + assert_eq!(TrackHeight::from_points(88.0), TrackHeight::Medium); + assert_eq!(TrackHeight::from_points(57.4), TrackHeight::Custom(57)); + assert_eq!(TrackHeight::from_points(9999.0), TrackHeight::Extreme); + assert_eq!(TrackHeight::from_points(f32::NAN), TrackHeight::Micro); + assert_eq!(TrackHeight::Custom(u16::MAX).points(), 440.0); + assert_eq!(TrackHeight::Custom(57).step(true), TrackHeight::Medium); + assert_eq!(TrackHeight::Custom(57).step(false), TrackHeight::Small); + assert_eq!(TrackHeight::Extreme.step(true), TrackHeight::Extreme); + assert_eq!(TrackHeight::Micro.step(false), TrackHeight::Micro); + let json = serde_json::to_string(&TrackHeight::Custom(57)).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), TrackHeight::Custom(57)); + assert_eq!(serde_json::from_str::("\"Large\"").unwrap(), TrackHeight::Large); + } } diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index bd63c38..f52d3c9 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -15,26 +15,29 @@ //! 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 -//! 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 +//! 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 editors are driven, and plugin states for saving read +//! ([`Player::capture_states`]), on the UI thread //! through handles taken from each instance before it leaves ([`Player::open_editor`]). #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod record; -use soundcraft_dsp::{Plugin, PluginEditor}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginStateReader}; use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels}; use soundcraft_model::{Session, TrackId}; use soundcraft_time::{Range, Samples}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap, HashSet}; 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,8 +58,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), - /// Read every plugin state and reply. - CaptureStates(Sender), + /// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees). + OldSessions(SyncSender>), Input(Arc), Recording(bool), Play { @@ -76,6 +79,73 @@ struct Shared { meters: Mutex, } +/// What stopped the audio output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputTrouble { + /// A block panicked; the output stays silent. + Bug, + /// The device went away. + Gone, + /// The device stopped asking for audio while playing. + Stalled, +} + +/// How the audio output is doing, for the UI. +#[derive(Default)] +struct Fault { + /// A block panicked: output stays silent from then on. + failed: AtomicBool, + gone: AtomicBool, + /// Blocks so far, to tell a device that stopped asking for audio. + beats: std::sync::atomic::AtomicU64, + /// The UI has been told. + reported: AtomicBool, +} + +impl Fault { + /// What stopped the output, once. + fn trouble(&self, stalled: bool) -> Option { + let trouble = if self.failed.load(Ordering::Relaxed) { + OutputTrouble::Bug + } else if self.gone.load(Ordering::Relaxed) { + OutputTrouble::Gone + } else if stalled { + OutputTrouble::Stalled + } else { + return None; + }; + (!self.reported.swap(true, Ordering::Relaxed)).then_some(trouble) + } +} + +/// How long a playing output may go without a block before it counts as stalled. +#[cfg(not(target_arch = "wasm32"))] +const STALL: std::time::Duration = std::time::Duration::from_secs(2); + +/// True once the block count `beats` has stood still for `STALL` while playing; `seen` is the +/// count last seen and since when. +#[cfg(not(target_arch = "wasm32"))] +fn stalled(seen: &mut Option<(u64, std::time::Instant)>, beats: u64, playing: bool) -> bool { + match *seen { + Some((b, since)) if playing && b == beats => since.elapsed() >= STALL, + _ => { + *seen = Some((beats, std::time::Instant::now())); + false + } + } +} + +/// Fill one block with `fill`. If it panics, the block and every later one are silent and the +/// transport stops, rather than the panic taking the app down. +fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) { + fault.beats.fetch_add(1, Ordering::Relaxed); + if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() { + fault.failed.store(true, Ordering::Relaxed); + shared.playing.store(false, Ordering::Relaxed); + out.fill(0.0); + } +} + /// State owned by the audio callback. struct AudioState { rx: Receiver, @@ -97,6 +167,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 +183,7 @@ impl AudioState { let nch = main_channels(&session); AudioState { garbage, + old_sessions: None, fold: fold_gains(&session), rx, shared, @@ -136,10 +208,6 @@ impl AudioState { match c { Cmd::Input(r) => self.input = Some(r), Cmd::Recording(r) => self.recording = r, - Cmd::CaptureStates(reply) => { - self.mix.ensure_synced(&self.session); - let _ = reply.send(self.mix.capture_states()); - } Cmd::Session(s, instances) => { changed = true; if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 { @@ -155,8 +223,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,12 +248,13 @@ impl AudioState { self.pos = at; self.pending_len = 0; self.read = 0.0; + self.mix.reset(); } } } if changed { - // Place adopted instances now (not only once playback starts), so state captures and - // editors see them; `sync` creates no third-party plugins here. + // Place adopted instances now (not only once playback starts), so editors see them; + // `sync` creates no third-party plugins here. self.mix.ensure_synced(&self.session); } self.send_garbage(); @@ -325,19 +398,44 @@ struct Side { rate: f32, /// Editor handles of the live instances. editors: HashMap<(TrackId, usize), Box>, + /// State readers of the live instances. + states: BTreeMap<(TrackId, usize), Box>, + /// Instances that refused their stored state: saves keep that state until the plugin's editor + /// is opened. + refused: HashSet<(TrackId, usize)>, garbage: Receiver>>, + old_sessions: Option>>, } impl Side { - /// Drops retired instances (closing their editors first, inside the plugin's drop). + fn new(rate: f32, garbage: Receiver>>) -> Side { + Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None } + } + + /// 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)) { self.shadow.remove(&key); + self.states.remove(&key); + self.refused.remove(&key); if let Some(mut e) = self.editors.remove(&key) { e.close(); } @@ -347,6 +445,10 @@ impl Side { /// The transport and audio device. pub struct Player { shared: Arc, + fault: Arc, + /// The block count last seen while playing, and since when. + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex>, tx: Sender, side: Mutex, #[cfg(not(target_arch = "wasm32"))] @@ -365,18 +467,32 @@ 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 } + /// What stopped the audio output, once: a bug in the engine, or the device went away or + /// stopped asking for audio while playing. + pub fn trouble(&self) -> Option { + #[cfg(not(target_arch = "wasm32"))] + let stalled = { + let playing = self.shared.playing.load(Ordering::Relaxed) && !self.silent; + stalled(&mut self.watch.lock().unwrap_or_else(PoisonError::into_inner), self.fault.beats.load(Ordering::Relaxed), playing) + }; + #[cfg(target_arch = "wasm32")] + let stalled = false; + self.fault.trouble(stalled) + } + fn side(&self) -> std::sync::MutexGuard<'_, Side> { self.side.lock().unwrap_or_else(PoisonError::into_inner) } 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::new(session.sample_rate.as_f64() as f32, grx)); let shared = Arc::new(Shared { position: AtomicI64::new(0), playing: AtomicBool::new(false), @@ -391,10 +507,16 @@ impl Player { let rate = config.sample_rate.0; let channels = usize::from(config.channels); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared)); // cpal may call this on the audio thread (on ALSA in a loop while the device // stays broken): marked, so the logger hands the record off instead of writing it. - let err_fn = |e: cpal::StreamError| { + let gone = Arc::clone(&fault); + let err_fn = move |e: cpal::StreamError| { mark_audio_thread(); + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + gone.gone.store(true, Ordering::Relaxed); + } log::warn!("audio stream error: {e}"); }; use cpal::traits::{DeviceTrait, StreamTrait}; @@ -402,7 +524,7 @@ impl Player { &config, move |data: &mut [f32], _: &cpal::OutputCallbackInfo| { mark_audio_thread(); - state.fill(data, channels); + guarded_block(&failed, &guard, data, |d| state.fill(d, channels)); }, err_fn, None, @@ -414,6 +536,9 @@ impl Player { log::info!("audio device: {name} @ {rate} Hz, {channels} ch"); Player { shared, + fault, + #[cfg(not(target_arch = "wasm32"))] + watch: Mutex::new(None), tx, side, _stream: Some(stream), @@ -465,6 +590,8 @@ impl Player { ) -> Player { let rate = session.sample_rate.hz(); let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx); + let fault = Arc::new(Fault::default()); + let failed = Arc::clone(&fault); let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || { let mut buf = vec![0.0f32; 4096]; let mut last = std::time::Instant::now(); @@ -475,7 +602,8 @@ impl Player { last = now; let n = (frames * 2).min(buf.len()); if let Some(b) = buf.get_mut(..n) { - state.fill(b, 2); + let shared = Arc::clone(&state.shared); + guarded_block(&failed, &shared, b, |b| state.fill(b, 2)); } if Arc::strong_count(&state.shared) <= 1 { break; @@ -487,6 +615,8 @@ impl Player { } Player { shared, + fault, + watch: Mutex::new(None), tx, side, _stream: None, @@ -508,6 +638,7 @@ impl Player { ) -> Player { Player { shared, + fault: Arc::default(), tx, side, _stream: None, @@ -525,22 +656,9 @@ impl Player { let _ = self.tx.send(Cmd::Session(s, instances)); } - /// Reads the state of every live third-party plugin (for saving the session). Waits briefly - /// for the audio thread to answer between two blocks; empty when it does not answer. + /// Reads the state of every live third-party plugin, for saving the session. pub fn capture_states(&self) -> PluginStates { - self.side().collect_garbage(); - #[cfg(not(target_arch = "wasm32"))] - { - let (tx, rx) = channel(); - if self.tx.send(Cmd::CaptureStates(tx)).is_err() { - return Vec::new(); - } - rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap_or_default() - } - #[cfg(target_arch = "wasm32")] - { - Vec::new() - } + self.side().capture_states() } /// Per-UI-frame housekeeping: drops retired instances and services plugin editors. Returns @@ -564,10 +682,7 @@ impl Player { /// Opens the plugin's own editor window. Main thread only. pub fn open_editor(&self, track: TrackId, slot: usize) -> Result<(), String> { - match self.side().editors.get_mut(&(track, slot)) { - Some(e) => e.open(), - None => Err("this plugin has no editor (or it is not loaded)".into()), - } + self.side().open_editor((track, slot)) } pub fn close_editor(&self, track: TrackId, slot: usize) { @@ -656,6 +771,17 @@ fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) { } impl Side { + fn capture_states(&mut self) -> PluginStates { + self.states.iter_mut().filter(|(k, _)| !self.refused.contains(*k)).filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect() + } + + fn open_editor(&mut self, key: (TrackId, usize)) -> Result<(), String> { + let e = self.editors.get_mut(&key).ok_or_else(|| "this plugin has no editor (or it is not loaded)".to_string())?; + e.open()?; + self.refused.remove(&key); + Ok(()) + } + /// Creates the third-party instances `s` needs that the audio thread does not have yet. fn prepare(&mut self, s: &Session) -> Vec { self.collect_garbage(); @@ -681,11 +807,18 @@ 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(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made { if let Some(e) = plugin.editor() { self.editors.insert(key, e); } + if let Some(r) = plugin.state_reader() { + self.states.insert(key, r); + } + if refused_state { + self.refused.insert(key); + } out.push(PreparedInstance { track: spec.track, slot: spec.slot, id: spec.id, ch: spec.ch, plugin }); } } @@ -755,7 +888,7 @@ mod tests { let keep = TrackId(1); let stale = TrackId(2); let mut source = MeterSnapshot::default(); - source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 }); + source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0, ..StripMeter::default() }); source.main.peaks = vec![0.75, 0.5]; let mut out = MeterSnapshot::default(); out.tracks.insert(stale, StripMeter::default()); @@ -765,6 +898,46 @@ mod tests { assert_eq!(out.main, source.main); } + #[test] + fn a_block_that_panics_goes_silent_and_is_reported_once() { + let shared = Shared { + position: AtomicI64::new(0), + playing: AtomicBool::new(true), + speed: AtomicU32::new(1000), + meters: Mutex::new(MeterSnapshot::default()), + }; + let fault = Fault::default(); + let mut out = [0.5f32; 8]; + guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); + assert_eq!((out, fault.trouble(false)), ([0.25; 8], None)); + guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix")); + guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25)); + assert_eq!(out, [0.0; 8]); + assert!(!shared.playing.load(Ordering::Relaxed)); + assert_eq!((fault.trouble(false), fault.trouble(false)), (Some(OutputTrouble::Bug), None)); + assert_eq!(fault.beats.load(Ordering::Relaxed), 3); + } + + #[test] + fn a_lost_or_stalled_device_is_reported_once() { + let ago = |seen: &mut Option<(u64, std::time::Instant)>| { + if let Some(s) = seen { + s.1 = std::time::Instant::now().checked_sub(STALL).unwrap(); + } + }; + let mut seen = None; + assert!(!stalled(&mut seen, 5, true)); + ago(&mut seen); + assert!(stalled(&mut seen, 5, true)); + assert!(!stalled(&mut seen, 6, true), "it asked again"); + ago(&mut seen); + assert!(!stalled(&mut seen, 6, false), "stopped, so nothing to ask for"); + assert_eq!(Fault::default().trouble(true), Some(OutputTrouble::Stalled)); + let fault = Fault::default(); + fault.gone.store(true, Ordering::Relaxed); + assert_eq!((fault.trouble(false), fault.trouble(true)), (Some(OutputTrouble::Gone), None)); + } + #[test] fn audio_state_plays_and_stops_at_end() { let s = Arc::new(soundcraft_model::Session::default()); @@ -837,32 +1010,6 @@ mod tests { assert_eq!(shared.meters.lock().unwrap().main.peaks.len(), 6); } - struct Stateful(Box); - - impl Plugin for Stateful { - fn info(&self) -> &'static soundcraft_dsp::PluginInfo { - self.0.info() - } - fn prepare(&mut self, sr: f32, mb: usize, ch: usize) { - self.0.prepare(sr, mb, ch); - } - fn reset(&mut self) { - self.0.reset(); - } - fn set_param(&mut self, id: &str, v: f32) -> bool { - self.0.set_param(id, v) - } - fn param(&self, id: &str) -> Option { - self.0.param(id) - } - fn process(&mut self, io: &mut [Vec], frames: usize) { - self.0.process(io, frames); - } - fn save_state(&mut self) -> Option> { - Some(b"blob".to_vec()) - } - } - #[test] fn instances_arrive_with_the_session_and_leave_through_the_return_channel() { use soundcraft_model::{ChannelFormat, Insert, TrackKind}; @@ -879,14 +1026,10 @@ mod tests { let (tx, rx) = channel(); let (gtx, grx) = channel(); let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx); - let plugin: Box = Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())); + let plugin = soundcraft_dsp::create("gain").unwrap(); tx.send(Cmd::Session(Arc::clone(&s), vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }])).unwrap(); - let (rtx, rrx) = channel(); - tx.send(Cmd::CaptureStates(rtx)).unwrap(); - // Stopped: the callback only drains commands (adopting the instance) and answers. let mut out = vec![0.0f32; 256]; st.fill(&mut out, 2); - assert_eq!(rrx.try_recv().unwrap(), vec![(t, 1, b"blob".to_vec())]); assert!(grx.try_recv().is_err(), "nothing retired yet"); // A session without the insert retires the instance to the return channel. tx.send(Cmd::Session(Arc::new(soundcraft_model::Session::default()), Vec::new())).unwrap(); @@ -895,13 +1038,7 @@ mod tests { // A sample-rate change rebuilds the engine without dropping plugins on this thread. tx.send(Cmd::Session( Arc::clone(&s), - vec![PreparedInstance { - track: t, - slot: 1, - id: "clap:test.fake".into(), - ch: 2, - plugin: Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())), - }], + vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin: soundcraft_dsp::create("gain").unwrap() }], )) .unwrap(); st.fill(&mut out, 2); @@ -912,11 +1049,51 @@ mod tests { assert_eq!(grx.try_recv().unwrap().len(), 1); } + #[test] + fn plugin_states_are_read_without_the_audio_thread() { + let mut side = Side::new(48_000.0, channel().1); + let (a, b) = (TrackId(1), TrackId(2)); + side.states.insert((b, 0), Box::new(|| Some(b"b0".to_vec()))); + side.states.insert((a, soundcraft_mix::INSTRUMENT_SLOT), Box::new(|| Some(b"synth".to_vec()))); + side.states.insert((a, 3), Box::new(|| Some(b"a3".to_vec()))); + side.states.insert((a, 4), Box::new(|| None)); + let want = vec![(a, 3, b"a3".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec()), (b, 0, b"b0".to_vec())]; + assert_eq!(side.capture_states(), want); + side.forget((a, 3)); + assert_eq!(side.capture_states(), want[1..]); + } + + struct Window; + + impl PluginEditor for Window { + fn open(&mut self) -> Result<(), String> { + Ok(()) + } + fn close(&mut self) {} + fn is_open(&self) -> bool { + false + } + fn idle(&mut self) -> Vec<(String, f32)> { + Vec::new() + } + } + + #[test] + fn a_refused_state_is_kept_until_the_editor_opens() { + let mut side = Side::new(48_000.0, channel().1); + let k = (TrackId(1), 0); + side.states.insert(k, Box::new(|| Some(b"defaults".to_vec()))); + side.editors.insert(k, Box::new(Window)); + side.refused.insert(k); + assert!(side.capture_states().is_empty()); + side.open_editor(k).unwrap(); + assert_eq!(side.capture_states(), vec![(TrackId(1), 0, b"defaults".to_vec())]); + } + #[test] 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::new(48_000.0, channel().1); 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 +1107,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::new(48_000.0, grx); + 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 +1154,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/playback/src/record.rs b/crates/playback/src/record.rs index ec784ac..76279b5 100644 --- a/crates/playback/src/record.rs +++ b/crates/playback/src/record.rs @@ -8,8 +8,8 @@ use std::fs::File; use std::io::{Seek, SeekFrom, Write}; use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use std::time::{Duration, Instant}; use soundcraft_model::TrackId; @@ -17,7 +17,29 @@ use soundcraft_model::TrackId; /// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met. pub type Takes = (Vec<(TrackId, PathBuf)>, Option); +/// Frames each block of a [`LiveTake`] sums up. +pub const LIVE_BLOCK: usize = 128; +/// Blocks a live take keeps per channel at most (three hours at 48 kHz); its file records on. +const LIVE_BLOCKS: usize = 1 << 22; + +/// A take while it records, summed up for drawing: per channel, the (min, max) of each +/// [`LIVE_BLOCK`] frames so far. +pub type LiveTake = Vec>; + const RING_SECONDS: usize = 4; +/// How long a recording input may go without a callback before it counts as gone. +const STALL: Duration = Duration::from_secs(2); +const GONE: u8 = 1; +const BROKEN: u8 = 2; + +/// Why the input can't go on. +#[derive(Debug, Clone, PartialEq)] +pub enum Trouble { + /// The device went away or stopped sending audio, or its callback hit a bug. + Device(String), + /// Writing a take failed (a full disk, say). + Disk(String), +} /// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots /// are atomics, so it needs no lock and no `unsafe`. @@ -57,19 +79,44 @@ impl CaptureRing { } } +/// An input sample, or silence for NaN and infinities, which would poison the take and the mix. +fn finite(x: f32) -> f32 { + if x.is_finite() { x } else { 0.0 } +} + struct Shared { armed: AtomicBool, ring: CaptureRing, /// Samples lost while the ring was full. dropped: AtomicUsize, + /// Input callbacks so far, to tell a device that stopped sending. + beats: AtomicU64, + /// Set from the input stream when it can't go on: `GONE` or `BROKEN`. + fault: AtomicU8, + /// The first failure writing a take, until it's reported. + failed: Mutex>, } impl Shared { + fn new(ring: CaptureRing) -> Shared { + let (beats, fault, failed) = (AtomicU64::new(0), AtomicU8::new(0), Mutex::new(None)); + Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0), beats, fault, failed } + } + + /// Run the input callback's `work`. After a panic in it, the input is broken and does + /// nothing more. + fn guarded(&self, work: impl FnOnce()) { + self.beats.fetch_add(1, Ordering::Relaxed); + if self.fault.load(Ordering::Relaxed) != BROKEN && std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).is_err() { + self.fault.store(BROKEN, Ordering::Relaxed); + } + } + /// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued /// as silence before the next block, so the take keeps its timing. fn capture(&self, data: &[f32], owed: &mut usize) { *owed -= self.ring.push(*owed, |_| 0.0); - let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 }; + let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().map_or(0.0, finite)) } else { 0 }; *owed = owed.saturating_add(data.len() - kept); self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed); } @@ -84,7 +131,10 @@ pub struct Recorder { pub device_name: String, pub sample_rate: u32, pub channels: usize, - writer: Option>, + /// The take writer, and what each track's take holds so far. + writer: Option<(std::thread::JoinHandle, Vec<(TrackId, Arc>)>)>, + /// The callback count last seen, and since when. + seen: (u64, Instant), } impl Recorder { @@ -103,8 +153,8 @@ impl Recorder { let config = cfg.config(); let channels = usize::from(config.channels).max(1); let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25)); - let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) }); - let sh = Arc::clone(&shared); + let shared = Arc::new(Shared::new(ring)); + let (sh, errors) = (Arc::clone(&shared), Arc::clone(&shared)); let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0); let mon = Arc::clone(&monitor); let mut owed = 0; @@ -113,23 +163,29 @@ impl Recorder { &config, move |data: &[f32], _: &cpal::InputCallbackInfo| { crate::mark_audio_thread(); - mon.push(data); - if sh.armed.load(Ordering::Acquire) { - sh.capture(data, &mut owed); - } else { - owed = 0; - } + sh.guarded(|| { + mon.push(data); + if sh.armed.load(Ordering::Acquire) { + sh.capture(data, &mut owed); + } else { + owed = 0; + } + }); }, - |e| { + move |e| { // Possibly the audio thread (see `crate::mark_audio_thread`). crate::mark_audio_thread(); + if matches!(e, cpal::StreamError::DeviceNotAvailable) { + let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed); + } log::warn!("input stream error: {e}"); }, None, ) .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; - Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None }) + let seen = (0, Instant::now()); + Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen }) } #[cfg(target_os = "freebsd")] @@ -145,12 +201,14 @@ impl Recorder { } let takes = takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::, _>>()?; + let live = takes.iter().map(|t| (t.id, Arc::clone(&t.live))).collect(); let ring = &self.shared.ring; ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release); + *self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = None; self.shared.armed.store(true, Ordering::Release); let (shared, channels) = (Arc::clone(&self.shared), self.channels); match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) { - Ok(w) => self.writer = Some(w), + Ok(w) => self.writer = Some((w, live)), Err(e) => { self.shared.armed.store(false, Ordering::Release); return Err(e.to_string()); @@ -159,9 +217,40 @@ impl Recorder { Ok(()) } + /// What the take recording onto `track` holds so far. + pub fn live_take(&self, track: TrackId) -> Option> { + let (_, take) = self.writer.as_ref()?.1.iter().find(|(id, _)| *id == track)?; + Some(take.lock().unwrap_or_else(PoisonError::into_inner)) + } + + /// Why the input can't go on, if it can't: the device went away or, while recording, stopped + /// sending audio; its callback hit a bug; or writing a take failed (reported once). + pub fn trouble(&mut self) -> Option { + if let Some(e) = self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner).take() { + return Some(Trouble::Disk(e)); + } + let name = &self.device_name; + match self.shared.fault.load(Ordering::Relaxed) { + GONE => return Some(Trouble::Device(format!("“{name}” was disconnected"))), + BROKEN => return Some(Trouble::Device("the audio input hit an internal error (see the log)".into())), + _ => {} + } + let beats = self.shared.beats.load(Ordering::Relaxed); + if beats != self.seen.0 || !self.shared.armed.load(Ordering::Relaxed) { + self.seen = (beats, Instant::now()); + } + (self.seen.1.elapsed() >= STALL).then(|| Trouble::Device(format!("“{name}” stopped sending audio"))) + } + + /// Input the current take has lost to a slow disk so far, in milliseconds. + pub fn lost_ms(&self) -> u64 { + let frames = self.shared.dropped.load(Ordering::Relaxed) / self.channels.max(1); + (frames as u64).saturating_mul(1000) / u64::from(self.sample_rate.max(1)) + } + pub fn finish(&mut self) -> Takes { self.shared.armed.store(false, Ordering::Release); - let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default(); + let (takes, problem) = self.writer.take().and_then(|(w, _)| w.join().ok()).unwrap_or_default(); let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1); let slow = (lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize)); @@ -176,7 +265,7 @@ impl Drop for Recorder { } fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> Takes { - let (mut block, mut header_written) = (Vec::new(), Instant::now()); + let (mut block, mut header_written, mut reported) = (Vec::new(), Instant::now(), false); loop { let stopping = !shared.armed.load(Ordering::Acquire); // The ring holds interleaved samples and a pop can end mid-frame: write whole frames and @@ -185,6 +274,10 @@ fn write_takes(shared: &Shared, mut takes: Vec, channels: usize) -> let whole = block.len() - block.len() % channels.max(1); takes.iter_mut().for_each(|t| t.write(&block[..whole], channels)); block.drain(..whole); + if !reported && let Some(e) = takes.iter().find_map(|t| t.error.clone()) { + *shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(e); + reported = true; + } if stopping { break; } @@ -216,13 +309,15 @@ struct TakeWriter { rate: u32, frames: u64, error: Option, + live: Arc>, } impl TakeWriter { fn create(id: TrackId, inputs: Vec, path: PathBuf, rate: u32) -> Result { let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path)); let file = file.map_err(|e| format!("{}: {e}", path.display()))?; - let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None }; + let live = Arc::new(Mutex::new(vec![Vec::new(); inputs.len()])); + let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None, live }; take.write_header(); take.error.take().map_or(Ok(take), Err) } @@ -238,11 +333,30 @@ impl TakeWriter { .flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes())) .collect(); match self.file.write_all(&bytes) { - Ok(()) => self.frames += (block.len() / channels) as u64, + Ok(()) => { + self.summarize(block, channels); + self.frames += (block.len() / channels) as u64; + } Err(e) => self.fail(e), } } + /// Add this take's channels of `block` to its live take. + fn summarize(&self, block: &[f32], channels: usize) { + let mut live = self.live.lock().unwrap_or_else(PoisonError::into_inner); + for (f, n) in block.chunks_exact(channels).zip(self.frames..) { + let at = (n / LIVE_BLOCK as u64) as usize; + for (blocks, &i) in live.iter_mut().zip(&self.inputs) { + let v = f.get(i).copied().unwrap_or(0.0); + match blocks.get_mut(at) { + Some(b) => *b = (b.0.min(v), b.1.max(v)), + None if at < LIVE_BLOCKS => blocks.push((v, v)), + None => {} + } + } + } + } + fn write_header(&mut self) { let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) { Ok(h) => h, @@ -269,7 +383,7 @@ mod tests { #[test] fn a_full_ring_owes_the_lost_samples_as_silence() { - let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) }; + let shared = Shared::new(CaptureRing::new(8)); let (mut owed, mut out) = (0, Vec::new()); shared.capture(&[1.0; 6], &mut owed); shared.capture(&[2.0; 4], &mut owed); @@ -280,6 +394,75 @@ mod tests { assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2)); } + #[test] + fn nan_and_infinite_input_records_as_silence() { + let shared = Shared::new(CaptureRing::new(8)); + let (mut owed, mut out) = (0, Vec::new()); + shared.capture(&[0.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5], &mut owed); + shared.ring.pop_all(&mut out); + assert_eq!(out, [0.5, 0.0, 0.0, 0.0, -0.5]); + } + + fn recorder(shared: &Arc) -> Recorder { + Recorder { + shared: Arc::clone(shared), + monitor: InputRing::new(1, 48_000), + #[cfg(not(target_os = "freebsd"))] + _stream: None, + device_name: "Mic In".into(), + sample_rate: 48_000, + channels: 1, + writer: None, + seen: (0, Instant::now()), + } + } + + #[test] + fn a_bug_a_stall_or_a_lost_device_is_trouble() { + let shared = Arc::new(Shared::new(CaptureRing::new(64))); + let mut r = recorder(&shared); + shared.armed.store(true, Ordering::Release); + assert_eq!(r.trouble(), None); + r.seen.1 = Instant::now().checked_sub(STALL).unwrap(); + assert_eq!(r.trouble(), Some(Trouble::Device("“Mic In” stopped sending audio".into()))); + shared.guarded(|| {}); + assert_eq!(r.trouble(), None, "it sent again"); + shared.armed.store(false, Ordering::Release); + r.seen.1 = Instant::now().checked_sub(STALL).unwrap(); + assert_eq!(r.trouble(), None, "a quiet input that isn't recording is fine"); + shared.guarded(|| std::panic::panic_any("a bug in the input")); + let mut ran = false; + shared.guarded(|| ran = true); + assert!(!ran); + assert_eq!(r.trouble(), Some(Trouble::Device("the audio input hit an internal error (see the log)".into()))); + let gone = Arc::new(Shared::new(CaptureRing::new(64))); + gone.fault.store(GONE, Ordering::Relaxed); + assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” was disconnected".into()))); + } + + #[test] + fn a_failed_write_is_trouble_while_still_recording() { + let dir = std::env::temp_dir().join(format!("soundcraft-failed-take-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut take = TakeWriter::create(TrackId(1), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap(); + take.file = File::open(&take.path).unwrap(); + let shared = Arc::new(Shared::new(CaptureRing::new(1 << 12))); + shared.armed.store(true, Ordering::Release); + let mut r = recorder(&shared); + let sh = Arc::clone(&shared); + let writer = std::thread::spawn(move || write_takes(&sh, vec![take], 1)); + shared.capture(&[0.5; 480], &mut 0); + let trouble = (0..300).find_map(|_| { + std::thread::sleep(Duration::from_millis(10)); + r.trouble() + }); + assert!(matches!(&trouble, Some(Trouble::Disk(e)) if e.contains("Vox_01.wav")), "{trouble:?}"); + assert_eq!(r.trouble(), None, "reported once"); + shared.armed.store(false, Ordering::Release); + assert!(writer.join().unwrap().1.is_some()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn takes_stream_into_their_files() { let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id())); @@ -292,7 +475,8 @@ mod tests { vox.write(&[0.25; 150], 3); assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen"); - let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) }); + let shared = Arc::new(Shared::new(CaptureRing::new(1 << 16))); + shared.armed.store(true, Ordering::Release); let sh = Arc::clone(&shared); let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3)); let input: Vec = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect(); @@ -306,6 +490,19 @@ mod tests { assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]); let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn a_take_sums_itself_up_for_drawing_while_it_records() { + let dir = std::env::temp_dir().join(format!("soundcraft-live-take-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut take = TakeWriter::create(TrackId(4), vec![2, 0], dir.join("Gtr_01.wav"), 48_000).unwrap(); + let input: Vec = (0..300).flat_map(|f| [f as f32, -1.0, -(f as f32)]).collect(); + take.write(&input[..3 * 100], 3); + take.write(&input[3 * 100..], 3); + let live = take.live.lock().unwrap().clone(); + assert_eq!(live, [[(-127.0, 0.0), (-255.0, -128.0), (-299.0, -256.0)], [(0.0, 127.0), (128.0, 255.0), (256.0, 299.0)]]); + let _ = std::fs::remove_dir_all(&dir); + } } /// Ring state behind the lock: interleaved input frames at the input device's rate, plus the @@ -342,7 +539,7 @@ impl InputRing { pub fn push(&self, samples: &[f32]) { if let Ok(mut s) = self.state.try_lock() { - s.data.extend(samples.iter().copied()); + s.data.extend(samples.iter().copied().map(finite)); while s.data.len() > self.cap { s.data.pop_front(); } @@ -437,4 +634,14 @@ mod ring_tests { ring.pop_into(&mut out, 16, 48_000); assert!(out[0].iter().all(|v| *v == 0.0)); } + + /// NaN and infinities from the device monitor as silence. + #[test] + fn monitor_silences_nan_and_infinite_input() { + let ring = InputRing::new(1, 48_000); + ring.push(&[0.5, f32::NAN, f32::INFINITY, -0.5, 0.0, 0.0, 0.0, 0.0]); + let mut out = vec![vec![1.0f32; 4]]; + ring.pop_into(&mut out, 4, 48_000); + assert_eq!(out[0], [0.5, 0.0, 0.0, -0.5]); + } } diff --git a/crates/time/src/format.rs b/crates/time/src/format.rs index a5f4a1a..bdd2fcb 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 = @@ -133,14 +142,35 @@ mod tests { (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30) } + #[test] + fn huge_typed_positions_parse_without_overflowing() { + let (sr, map, rate) = ctx(); + for (text, fmt) in [ + ("999999999999999999:0", TimeFormat::Timecode), + ("100000000000000:00:00:00", TimeFormat::Timecode), + ("-9223372036854775808", TimeFormat::BarsBeats), + ] { + let _ = parse_position(text, fmt, sr, &map, rate, 0, 0); + } + } + #[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 +178,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 +188,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..b4e56a5 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; @@ -123,7 +141,13 @@ impl GridValue { pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples { match *self { GridValue::Note { value, dotted, triplet } => { - let t = map.samples_to_ticks_f(pos, sr).floor() as i64; + // Float ticks can land a hair either side of a line's own tick. + let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64; + if map.tick_to_samples(t, sr) > pos { + t = t.saturating_sub(1); + } else if map.tick_to_samples(t.saturating_add(1), sr) <= pos { + t = t.saturating_add(1); + } // Grid lines restart at each bar line so odd meters stay aligned. let bb = map.bar_beat_at_tick(t); let bar_tick = map.bar_start_tick(bb.bar); @@ -133,7 +157,7 @@ impl GridValue { } _ => { let step = self.step_samples(pos, sr, map, rate); - pos.div_euclid(step) * step + pos.div_euclid(step).saturating_mul(step) } } } @@ -167,6 +191,19 @@ impl GridValue { mod tests { use super::*; + #[test] + fn grid_lines_are_their_own_line_at_odd_tempos() { + let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30); + for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] { + let map = TempoMap::new(bpm, 4, 4).unwrap(); + let mut line = 0; + for _ in 0..64 { + assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm"); + line = g.next_line(line, sr, &map, rate); + } + } + } + #[test] fn sixteenth_grid_at_120() { let g = GridValue::default(); @@ -199,4 +236,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/lib.rs b/crates/time/src/lib.rs index 4b68934..7e9d4b9 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -19,6 +19,10 @@ pub use timecode::{FrameRate, Timecode}; /// A position or length in samples. pub type Samples = i64; +/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz). +/// Clamping there keeps every later addition and subtraction on positions far away from overflow. +pub const MAX_POSITION: Samples = 1 << 40; + /// Errors from parsing or converting time values. #[derive(Debug, Clone, PartialEq, thiserror::Error)] pub enum TimeError { diff --git a/crates/time/src/tempo.rs b/crates/time/src/tempo.rs index 5b87a3a..6d16471 100644 --- a/crates/time/src/tempo.rs +++ b/crates/time/src/tempo.rs @@ -55,6 +55,10 @@ fn valid_bpm(bpm: f64) -> Result { if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) } } +fn valid_meter(numerator: u32, denominator: u32) -> bool { + (1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator) +} + impl TempoMap { pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result { let mut m = TempoMap::default(); @@ -70,6 +74,25 @@ impl TempoMap { &self.meters } + /// Repair a map read from a file: only valid tempos and meters, in tick order, with one of each + /// at tick 0. + pub fn sanitize(&mut self) { + self.tempos.retain(|e| e.tick >= 0 && valid_bpm(e.bpm).is_ok()); + self.tempos.sort_by_key(|e| e.tick); + self.tempos.dedup_by_key(|e| e.tick); + if self.tempos.first().is_none_or(|e| e.tick > 0) { + let bpm = self.tempos.first().map_or(120.0, |e| e.bpm); + self.tempos.insert(0, TempoEvent { tick: 0, bpm }); + } + self.meters.retain(|m| m.tick >= 0 && valid_meter(m.numerator, m.denominator)); + self.meters.sort_by_key(|m| m.tick); + self.meters.dedup_by_key(|m| m.tick); + if self.meters.first().is_none_or(|m| m.tick > 0) { + let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator)); + self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator }); + } + } + /// Insert or replace the tempo at `tick`. pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> { let bpm = valid_bpm(bpm)?; @@ -96,13 +119,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) { + if !valid_meter(numerator, 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 +151,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 +246,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 +334,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(); @@ -317,6 +370,22 @@ mod tests { assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 }); } + #[test] + fn sanitize_keeps_what_the_setters_accept() { + let tempo = |tick, bpm| TempoEvent { tick, bpm }; + let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator }; + let mut m = TempoMap { + tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)], + meters: vec![meter(0, 0, 4), meter(3840, 3, 4)], + }; + m.sanitize(); + assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]); + assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]); + let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() }; + empty.sanitize(); + assert_eq!(empty, TempoMap::default()); + } + #[test] fn rejects_bad_values() { 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/bounce.rs b/crates/ui-egui/src/bounce.rs new file mode 100644 index 0000000..4368b4e --- /dev/null +++ b/crates/ui-egui/src/bounce.rs @@ -0,0 +1,84 @@ +//! Bounce Mix renders a slice per frame behind a progress window, so the app keeps drawing and the +//! bounce can be cancelled. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use serde_json::Value; + +/// Starts the bounce `file.bounce_mix` runs for `params`; [`step`] renders it. +pub fn start(app: &mut SoundApp, params: &Value) { + if app.bounce.is_some() { + app.ui.status = "A bounce is already running".into(); + return; + } + // Third-party plugins keep state the session can't see: store it first. + app.capture_plugin_states(); + match soundcraft_engine::cmd::bounce_job(&app.engine, params) { + Ok(job) => app.bounce = Some(job), + Err(e) => app.ui.status = e.to_string(), + } +} + +/// Renders the running bounce for a moment. Called from `logic`, which eframe keeps calling while +/// the window is hidden as long as a repaint is requested, so a bounce doesn't pause then. +pub fn step(app: &mut SoundApp, ctx: &egui::Context) { + let Some(job) = &mut app.bounce else { return }; + app.ui.status = match job.step(slice()) { + Ok(None) => { + ctx.request_repaint(); + return; + } + Ok(Some(done)) => { + format!("Bounced {} (peak {:.1} dBFS, {:.1} LUFS)", job.path().file_name().unwrap_or_default().to_string_lossy(), done.peak_db, done.lufs) + } + Err(e) => e.to_string(), + }; + app.bounce = None; +} + +/// Refuses to replace the session while a bounce holds its own copy: two large sessions could run +/// out of memory. True when refused; the status line says why. +pub fn refuses_session_switch(app: &mut SoundApp) -> bool { + if app.bounce.is_some() { + app.ui.status = "Finish or cancel the bounce first".into(); + } + app.bounce.is_some() +} + +/// Whether to render another block in this call: for 30 ms. +#[cfg(not(target_arch = "wasm32"))] +fn slice() -> impl FnMut() -> bool { + let until = std::time::Instant::now() + std::time::Duration::from_millis(30); + move || std::time::Instant::now() < until +} + +/// The browser has no `Instant` (it panics there), so a call renders 32 blocks. +#[cfg(target_arch = "wasm32")] +fn slice() -> impl FnMut() -> bool { + let mut blocks = 32; + move || { + blocks -= 1; + blocks > 0 + } +} + +/// Shows how far the running bounce got, with Cancel (or Esc). +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + let Some(job) = &app.bounce else { return }; + let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + egui::Modal::new(egui::Id::new("bounce_progress")).show(ctx, |ui| { + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + ui.set_width(320.0); + let name = job.path().file_name().unwrap_or_default().to_string_lossy(); + ui.label(egui::RichText::new(format!("Bouncing {name}")).font(bold(13.0))); + ui.add(egui::ProgressBar::new(job.progress())); + ui.horizontal(|ui| { + cancel |= ui.button("Cancel").clicked(); + ui.label(egui::RichText::new(format!("{:.0}%", job.progress() * 100.0)).color(Tokens::current().text_dim)); + }); + }); + if cancel { + app.bounce = None; + app.ui.status = "Bounce cancelled".into(); + } +} 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..f916c26 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -15,10 +15,12 @@ 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 }, TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler + Spot(crate::spot::Spot), Fades { shape: String }, StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 }, Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> }, @@ -42,10 +44,12 @@ 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", Dialog::TempoChange { .. } => "tempo_change", + Dialog::Spot(_) => "spot", Dialog::Fades { .. } => "fades", Dialog::StripSilence { .. } => "strip_silence", Dialog::Group { .. } => "group", @@ -63,6 +67,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 }; @@ -85,7 +93,13 @@ impl Dialogs { } "session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false }, "file.bounce_mix" => { - Dialog::Bounce { path: format!("{home}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } + let dir = e + .path + .as_deref() + .and_then(|p| std::path::Path::new(p).parent()) + .filter(|d| !d.as_os_str().is_empty()) + .map_or(home.clone(), |d| d.display().to_string()); + Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false } } "file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() }, "file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() }, @@ -250,6 +264,7 @@ fn default_dir() -> String { /// Show the open dialog, if any. pub fn show(app: &mut SoundApp, ctx: &egui::Context) { audiosuite_window(app, ctx); + crate::bounce::show(app, ctx); let Some(mut d) = app.dialogs.open.take() else { return }; let mut keep = true; let mut action: Option<(String, Value)> = None; @@ -261,9 +276,11 @@ 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)", + Dialog::Spot(_) => "Spot", Dialog::Fades { .. } => "Fades", Dialog::StripSilence { .. } => "Strip Silence", Dialog::Group { .. } => "Create Group", @@ -339,7 +356,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { Dialog::Bounce { path, format, bit_depth, normalize } => { ui.horizontal(|ui| { ui.label("File"); - ui.add(egui::TextEdit::singleline(path).desired_width(360.0)); + ui.add(egui::TextEdit::singleline(path).desired_width(420.0)); + if let Some(pick) = &app.services.pick_save + && ui.button("Browse…").clicked() + && let Some(p) = pick("file.bounce_mix", path) + { + *path = p; + match std::path::Path::new(path.as_str()).extension().map(|x| x.to_string_lossy().to_lowercase()).as_deref() { + Some("wav") => *format = "wav".into(), + Some("aif" | "aiff") => *format = "aiff".into(), + Some("flac") => *format = "flac".into(), + _ => {} + } + } }); ui.horizontal(|ui| { egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| { @@ -361,10 +390,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { ); if buttons(ui, "Bounce", enter) { let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() }); - action = Some(( - "file.bounce_mix".into(), - json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), - )); + crate::bounce::start( + app, + &json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}), + ); + keep = false; } } Dialog::RenameTrack { id, name } => { @@ -379,6 +409,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()); @@ -445,6 +481,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) { action = Some(tempo_change_action(*at, *bpm)); } } + Dialog::Spot(d) => { + crate::spot::fields(ui, app.engine.session(), d); + if buttons(ui, "OK", enter) { + keep = false; + action = d.command(app.engine.session()); + } + } Dialog::Fades { shape } => { for s in soundcraft_model::FadeShape::ALL { ui.radio_value(shape, s.label().to_lowercase(), s.label()); @@ -657,7 +700,12 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) { } } ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone())); - ui.label(egui::RichText::new("Processes the selected clips and replaces them with rendered audio.").small()); + let hint = if soundcraft_dsp::is_generator(&process) { + "Fills the selection on the selected tracks with a new clip." + } else { + "Processes the selected clips and replaces them with rendered audio." + }; + ui.label(egui::RichText::new(hint).small()); if ui.button(egui::RichText::new("Render").strong()).clicked() { let p: serde_json::Map = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect(); let _ = app.run("audiosuite.process", json!({"process": process, "params": p})); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f13bc78..9cb1a9e 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1,27 +1,48 @@ //! The Edit window: toolbar, rulers, track headers and playlists. +use crate::dialogs::Dialog; +use crate::spot::Spot; 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_engine::edit as ops; +use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, EditMode, Session, Tool, Track, TrackHeight, TrackId, TrackKind}; use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position}; +use std::collections::HashSet; +use std::sync::Arc; -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; @@ -32,6 +53,8 @@ pub struct EditLayout { pub rows: Vec<(u64, [f32; 4])>, pub scroll_y: f32, pub content_h: f32, + /// While Zoom Toggle is on: the vertical scroll to go back to. + zoom_back: Option, /// Follow-hold: the view was moved by something other than the playhead /// follow (a manual pan, scrollbar, universe jump, or programmatic /// scroll) during playback, so timeline auto-scroll stays paused until @@ -157,7 +180,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { let t = Tokens::current(); let r = ui.max_rect(); let s = app.engine.session(); - let left = format!( + let mut left = format!( "{} · {} Hz · {} · {} tracks · {}", s.name, s.sample_rate.hz(), @@ -169,6 +192,7 @@ fn status_bar(app: &mut SoundApp, ui: &mut Ui) { s.tracks.len(), app.ui.status ); + soundcraft_model::truncate_chars(&mut left, 4 * soundcraft_model::MAX_NAME_CHARS); ui.painter().text(pos2(r.min.x + 10.0, r.center().y), Align2::LEFT_CENTER, left, regular(11.0), t.text_dim); let undo = app.engine.undo_label().map_or(String::new(), |l| format!("Undo {l}")); ui.painter().text( @@ -186,7 +210,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,16 +224,21 @@ 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)); app.edit_layout.timeline = [tl.min.x, tl.min.y, tl.max.x, tl.max.y]; + follow_zoom_toggle(app); 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 +256,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; @@ -256,7 +290,26 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) { } fn in_edit_window(s: &Session, t: &Track) -> bool { - !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open) + ops::shown(s, t) +} + +/// Pro Tools' rule for track height: Alt reaches every shown track, Alt+Shift the selected ones too. +pub(crate) fn height_targets(s: &Session, m: egui::Modifiers, own: &[TrackId]) -> Vec { + let extra = |t: &Track| if m.shift { s.edit.selected_tracks.contains(&t.id) } else { in_edit_window(s, t) }; + s.tracks.iter().filter(|t| own.contains(&t.id) || (m.alt && extra(t))).map(|t| t.id.0).collect() +} + +/// How many folders this track sits in, for the name's indent (at most 6 shown). +fn folder_depth(s: &Session, track: &Track) -> usize { + let mut current = track.folder; + let mut depth = 0; + while let Some(folder) = current.and_then(|folder| s.track(folder)) + && depth < 6 + { + depth += 1; + current = folder.folder; + } + depth } /// Pro Tools' track-name click: Shift adds the tracks up to the nearest selected one, Cmd/Ctrl adds or removes one. @@ -335,6 +388,28 @@ fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) { } } +/// Zoom Toggle sized to the track area on screen. +pub fn zoom_toggle(app: &mut SoundApp) { + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0})); +} + +/// Zoom Toggle scrolls the first track it fitted to the top, and back when it turns off. +fn follow_zoom_toggle(app: &mut SoundApp) { + let l = &mut app.edit_layout; + match (&app.engine.session().edit.zoom_toggle, l.zoom_back) { + (Some(z), None) => { + l.zoom_back = Some(l.scroll_y); + let content_top = l.rows.first().map_or(0.0, |(_, r)| r[1]); + if let Some((_, r)) = l.rows.iter().find(|(id, _)| z.heights.iter().any(|(t, _)| t.0 == *id)) { + l.scroll_y = r[1] - content_top; + } + } + (None, Some(y)) => (l.scroll_y, l.zoom_back) = (y, None), + _ => {} + } +} + // ---- rulers ---------------------------------------------------------------------------------- fn ruler_label(id: &str) -> &'static str { @@ -417,7 +492,7 @@ fn nice_step(spp: f64, sr: f64, min_px: f64) -> i64 { fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[String]) { let t = Tokens::current(); - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); ui.painter().rect_filled(area, 0.0, t.ruler_bg); let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x)); let sr = s.sample_rate; @@ -425,7 +500,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); @@ -502,9 +577,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } else { soundcraft_time::FrameRate::Fps25 }; - let mut s2 = s.clone(); - s2.frame_rate = alt; - timebase_ruler(ui, &painter, &s2, tl, row, view, TimeFormat::Timecode, i + 1 == rulers.len()); + timebase_ruler(ui, &painter, &s, tl, row, view, TimeFormat::Timecode, alt, i + 1 == rulers.len()); } "markers" | "markers2" | "markers3" | "markers4" | "markers5" | "chords" => { let lane: u8 = match id.as_str() { @@ -535,18 +608,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})); } @@ -554,7 +632,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ } _ => { if let Some(f) = fmt { - timebase_ruler(ui, &painter, &s, tl, row, view, f, i + 1 == rulers.len()); + timebase_ruler(ui, &painter, &s, tl, row, view, f, s.frame_rate, i + 1 == rulers.len()); // Clicking a timebase ruler locates. let resp = ui.interact(row, ui.id().with(("ruler", id.as_str())), Sense::click_and_drag()); if (resp.clicked() || resp.dragged()) @@ -576,7 +654,17 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[ ui.painter().line_segment([pos2(area.min.x, area.max.y - 1.0), pos2(area.max.x, area.max.y - 1.0)], Stroke::new(1.0, t.border)); } -fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: Rect, view: Range, f: TimeFormat, bottom: bool) { +fn timebase_ruler( + _ui: &Ui, + painter: &egui::Painter, + s: &Session, + tl: Rect, + row: Rect, + view: Range, + f: TimeFormat, + rate: soundcraft_time::FrameRate, + bottom: bool, +) { let t = Tokens::current(); let sr = s.sample_rate; let spp = s.edit.zoom.samples_per_px; @@ -586,7 +674,7 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: // Bars every n so labels are ≥ 60 px apart. let bar_px = { let b = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; - b.step_samples(view.start.max(0), sr, &s.tempo, s.frame_rate) as f64 / spp + b.step_samples(view.start.max(0), sr, &s.tempo, rate) as f64 / spp }; let every = [1i64, 2, 4, 8, 16, 32, 64, 128].into_iter().find(|n| bar_px * *n as f64 >= 60.0).unwrap_or(256); let first = s.tempo.bar_beat_at(view.start.max(0), sr).bar; @@ -658,10 +746,10 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row: 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) + format_position(at, f, sr, &s.tempo, rate, s.timecode_start, s.feet_frames_start()) } } - _ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start), + _ => format_position(at, f, sr, &s.tempo, rate, s.timecode_start, s.feet_frames_start()), }; painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text); } else { @@ -673,46 +761,100 @@ 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 s = app.engine.session_arc(); + let Some(track) = s.track(id) 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)); + let lane = lane_rect(track, row, tl); + 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 }); - let s = app.engine.session().clone(); - grid_lines(&painter, &s, tl, lane); + painter.rect_filled(lane, 0.0, lane_bg); + let s = app.engine.session_arc(); + // 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() { if !clip.range().overlaps(&view) { continue; } - draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some()); + draw_clip(app, &painter, &s, track, clip, tl, lane, auto_view.is_some()); + } + draw_live_take(app, &painter, &s, track, tl, lane); + crate::video_track::draw_lane(app, &painter, &s, track, tl, lane); + if s.edit.grid_lines { + grid_lines(&painter, &s, tl, lane); } - crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane); if let Some(p) = &auto_view { - draw_automation(&painter, &s, &track, p, tl, lane); + draw_automation(&painter, &s, track, p, tl, lane); } if track.kind == TrackKind::Master || track.kind == TrackKind::Aux || track.kind == TrackKind::Vca { let p = auto_view.clone().unwrap_or(AutoParam::Volume); - draw_automation(&painter, &s, &track, &p, tl, lane); + draw_automation(&painter, &s, track, &p, tl, lane); } // Selection highlight on this track. let sel = s.edit.selection; @@ -723,16 +865,16 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) painter.rect_filled(Rect::from_min_max(pos2(x0, lane.min.y), pos2(x1, lane.max.y)), 0.0, t.selection); } } - lane_interaction(app, ui, &track, lane, tl); + lane_interaction(app, ui, track, lane, tl); if track.view == "playlists" { - playlist_lanes(app, ui, &track, row, main_h, tl); + playlist_lanes(app, ui, track, row, main_h, tl); } } /// Alternate playlists as lanes under the main one; clicking a clip promotes it (comping). fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, main_h: f32, tl: Rect) { let t = Tokens::current(); - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); let mut y = row.min.y + main_h; for (i, pl) in track.playlists.iter().enumerate() { if i == track.active_playlist { @@ -744,9 +886,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim); let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); for c in &pl.clips { - let r = Rect::from_min_max(pos2(x_of(&s, tl, c.start), lane.min.y + 3.0), pos2(x_of(&s, tl, c.end()), lane.max.y - 3.0)); - painter.rect(r, 2.0, rgb(track.color).gamma_multiply(0.45), Stroke::new(1.0, Color32::BLACK), StrokeKind::Inside); - painter.text(pos2(r.min.x + 4.0, r.min.y + 7.0), Align2::LEFT_CENTER, &c.name, regular(9.5), t.text); + draw_clip(app, &painter, &s, track, c, tl, lane, false); } let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click()); if resp.clicked() @@ -768,7 +908,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). @@ -777,16 +917,28 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele if eresp.hovered() || eresp.dragged() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - if eresp.drag_stopped() + if eresp.drag_started() { + ui.data_mut(|d| d.insert_temp(eresp.id, track.height)); + } + if eresp.dragged() && let Some(p) = ui.ctx().pointer_latest_pos() + && let Some(origin) = ui.input(|i| i.pointer.press_origin()).filter(|o| o.y != p.y) { - let want = (p.y - head.min.y).max(10.0); - let best = TrackHeight::ALL.into_iter().min_by_key(|h| ((h.points() - want).abs() * 10.0) as i64).unwrap_or(TrackHeight::Medium); - if best != track.height { - let _ = app.run("track.height", json!({"tracks": [id.0], "height": best.label()})); + let start = ui.data(|d| d.get_temp::(eresp.id)).unwrap_or(track.height); + let want = start.points() + p.y - origin.y; + let m = ui.input(|i| i.modifiers); + let best = if m.command { + TrackHeight::from_points(want) + } else { + TrackHeight::ALL.into_iter().min_by_key(|h| ((h.points() - want).abs() * 10.0) as i64).unwrap_or(start) + }; + let s = app.engine.session(); + let tracks = height_targets(s, m, &[id]); + if tracks.iter().any(|t| s.track(TrackId(*t)).is_some_and(|tr| tr.height != best)) { + let _ = app.engine.execute_merged("track.height", &json!({"tracks": tracks, "height": best.points()}), &format!("track_height:{}", id.0)); + ui.ctx().request_repaint(); } } - 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 +946,62 @@ 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 indent = folder_depth(app.engine.session(), track) as f32 * 10.0; + let name_r = at(16.0 + indent, 1.5, 97.0 - indent, 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 +1033,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 +1054,239 @@ 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")) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .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 +1294,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 +1408,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"), @@ -1093,21 +1422,47 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { ("Move to New Folder...", "track.move_to_new_folder"), ("Group...", "track.group"), ]; + let selection: Vec = { + let selected = &app.engine.session().edit.selected_tracks; + if selected.contains(&id) { selected.iter().map(|track| track.0).collect() } else { vec![tid] } + }; for (label, cmd) in items { if ui.button(label).clicked() { if cmd == "ui.rename_track" { let name = app.engine.session().track(id).map(|t| t.name.clone()).unwrap_or_default(); app.dialogs.open_rename_track(id, &name); } else { - let _ = app.run(cmd, json!({"tracks": [tid]})); + let _ = app.run(cmd, json!({"tracks": selection})); } } } + let folders: Vec<(u64, String)> = app + .engine + .session() + .tracks + .iter() + .filter(|track| track.is_folder() && !selection.contains(&track.id.0)) + .map(|track| (track.id.0, track.name.clone())) + .collect(); + if !folders.is_empty() { + ui.menu_button("Move to Folder", |ui| { + for (folder, name) in &folders { + if ui.button(name).clicked() { + let _ = app.run("track.move_to_folder", json!({"tracks": selection, "folder": folder})); + } + } + }); + } + if app.engine.session().track(id).is_some_and(|track| track.folder.is_some()) && ui.button("Remove from Folder").clicked() { + let _ = app.run("track.remove_from_folder", json!({"tracks": selection})); + } ui.separator(); ui.menu_button("Track Height", |ui| { for h in TrackHeight::ALL { if ui.button(h.label()).clicked() { - let _ = app.run("track.height", json!({"tracks": [tid], "height": h.label()})); + let own: Vec = selection.iter().map(|&t| TrackId(t)).collect(); + let tracks = height_targets(app.engine.session(), ui.input(|i| i.modifiers), &own); + let _ = app.run("track.height", json!({"tracks": tracks, "height": h.label()})); } } }); @@ -1116,7 +1471,7 @@ fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let (r, resp) = ui.allocate_exact_size(vec2(120.0, 14.0), Sense::click()); ui.painter().rect_filled(r, 2.0, rgb(*c)); if resp.clicked() { - let _ = app.run("track.color", json!({"tracks": [tid], "color": i})); + let _ = app.run("track.color", json!({"tracks": selection, "color": i})); } } }); @@ -1130,22 +1485,103 @@ 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)); } } } } +fn clip_name_height(s: &Session, lane: Rect) -> f32 { + if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 } +} + +fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32, body: Rect, pixels_per_point: f32) -> Vec { + let scale = if pixels_per_point.is_finite() { pixels_per_point.clamp(0.5, 4.0) } else { 1.0 }; + let extent = (b - a).abs().max(body.height().abs()) * scale; + let extent = if extent.is_finite() { extent.clamp(64.0, 4096.0) } else { 64.0 }; + // Physical-pixel sampling avoids visible corners, even for steep bends. + // Keep an exact midpoint and cap allocations for extreme zoom/clip sizes. + let segments = (extent.ceil() as usize).div_ceil(2) * 2; + (0..=segments) + .map(|i| { + let k = i as f32 / segments as f32; + pos2(a + (b - a) * k, body.max.y - body.height() * fade.gain(if fade_in { k } else { 1.0 - k })) + }) + .collect() +} + +fn fade_control_position(fade: soundcraft_model::Fade, a: f32, b: f32, body: Rect) -> Pos2 { + let padding = body.height().clamp(0.0, 8.0) * 0.5; + let y = body.max.y - body.height() * fade.gain(0.5); + // Keep the whole handle inside the body when a bend approaches an edge. + pos2((a + b) * 0.5, y.max(body.min.y + padding).min(body.max.y - padding)) +} + +fn clip_preview_fades(clip: &Clip, gesture: Option<&Gesture>) -> (soundcraft_model::Fade, soundcraft_model::Fade) { + let (mut fade_in, mut fade_out) = (clip.fade_in, clip.fade_out); + match gesture { + Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) if *id == clip.id => { + if let Some(fade) = fade_drag_preview(clip, *side, *to) { + if *side { + fade_in = fade; + } else { + fade_out = fade; + } + } + } + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if *id == clip.id => { + if *side { + fade_in.curvature = *curvature; + } else { + fade_out.curvature = *curvature; + } + } + _ => {} + } + (fade_in, fade_out) +} + +/// The take `track` is recording, drawn in red as it grows, as in Pro Tools. +fn draw_live_take(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, tl: Rect, lane: Rect) { + let Some(rec) = &app.recorder else { return }; + let Some(take) = rec.live_take(track.id) else { return }; + let block_len = soundcraft_playback::record::LIVE_BLOCK as f64 * s.sample_rate.as_f64() / f64::from(rec.sample_rate.max(1)); + let len = soundcraft_time::to_samples(take.first().map_or(0, Vec::len) as f64 * block_len); + let sel = s.edit.selection; + let (start, pass) = if s.edit.loop_record && !sel.is_empty() { (sel.start, Some(sel.len())) } else { (app.record_start, None) }; + let Some((origin, span)) = soundcraft_engine::io::live_span(start, pass, app.punch, len) else { return }; + let r = Rect::from_min_max(pos2(x_of(s, tl, span.start), lane.min.y + 1.0), pos2(x_of(s, tl, span.end), lane.max.y - 1.0)); + let t = Tokens::current(); + let (body, _, wave) = clip_colors([t.rec.r(), t.rec.g(), t.rec.b()], false); + painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); + if track.view != "blocks" { + crate::waveform::draw_live(&painter.with_clip_rect(r.intersect(lane)), s, tl, origin as f64, block_len, &take, r, wave); + } +} + +/// 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); @@ -1163,7 +1599,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let body = if s.edit.flag("view.clip.transparency") { body.gamma_multiply(0.55) } else { body }; painter.rect(r, CornerRadius::same(2), body, Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); - let name_h = if lane.height() >= 40.0 && s.edit.flag("view.clip.name") { 13.0 } else { 0.0 }; + let name_h = clip_name_height(s, lane); if name_h > 0.0 { let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h)); painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar }); @@ -1178,59 +1614,26 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max); let vis = body_r.intersect(lane); + // The waveform and fade curve share the same temporary envelope every frame. + let (fade_in, fade_out) = clip_preview_fades(clip, app.gesture.as_ref().filter(|_| !dim)); match &clip.content { - ClipContent::Audio { source, offset } => { + _ if track.view == "blocks" => {} + ClipContent::Audio { source, .. } => { if let Some(audio) = s.pool.get(*source) { - let nch = track.channels().min(audio.peaks.len().max(1)).max(1); - let ch_h = body_r.height() / nch as f32; - let zoom = s.edit.zoom.waveform_zoom; - let rectified = s.edit.flag("waveform.rectified"); - let power = s.edit.flag("waveform.power"); - let outlines = s.edit.flag("waveform.outlines"); - let cols = vis.width().max(0.0) as usize; - if cols > 0 && vis.height() > 2.0 { - let a = (offset + sample_at(s, tl, vis.min.x) - clip.start) as f64; - let b = (offset + sample_at(s, tl, vis.max.x) - clip.start) as f64; - for ch in 0..nch { - let Some(pk) = audio.peaks.get(ch % audio.peaks.len().max(1)) else { continue }; - let cy = body_r.min.y + ch_h * (ch as f32 + 0.5); - let half = ch_h * 0.46; - let colsv = pk.columns(a, b, cols); - let mut mesh = egui::Mesh::default(); - for (i, (mn, mx)) in colsv.iter().enumerate() { - let x = vis.min.x + i as f32; - let rel = sample_at(s, tl, x) - clip.start; - let g = clip.gain_at(rel.clamp(0, clip.length)); - let (mut top, mut bot) = if rectified { - let a = (mx.abs().max(mn.abs()) * g * zoom).clamp(0.0, 1.0); - let b = cy + half; - (b - a * half * 2.0, b) - } else { - (cy - (mx * g * zoom).clamp(-1.0, 1.0) * half, cy - (mn * g * zoom).clamp(-1.0, 1.0) * half) - }; - if power { - // Power view: compress peaks logarithmically around the centre. - let k = |y: f32| cy + (y - cy).signum() * ((y - cy).abs() / half).sqrt() * half; - top = k(top); - bot = k(bot); - } - if outlines { - bot = top + 1.0; - } - let bot = if (bot - top) < 1.0 { top + 1.0 } else { bot }; - let idx = mesh.vertices.len() as u32; - mesh.colored_vertex(pos2(x, top), wave); - mesh.colored_vertex(pos2(x + 1.0, top), wave); - mesh.colored_vertex(pos2(x + 1.0, bot), wave); - mesh.colored_vertex(pos2(x, bot), wave); - mesh.add_triangle(idx, idx + 1, idx + 2); - mesh.add_triangle(idx, idx + 2, idx + 3); - } - painter.with_clip_rect(vis).add(Shape::mesh(mesh)); - if nch > 1 && ch + 1 < nch { - let y = body_r.min.y + ch_h * (ch as f32 + 1.0); - painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); - } + if vis.width() > 0.0 && vis.height() > 2.0 { + // Drawn with the fades being dragged, so the waveform follows them before release. + let preview; + let shown = if (fade_in, fade_out) == (clip.fade_in, clip.fade_out) { + clip + } else { + preview = Clip { fade_in, fade_out, ..clip.clone() }; + &preview + }; + crate::waveform::draw(&painter.with_clip_rect(vis), s, tl, shown, audio, track.channels(), body_r, wave); + let n = track.channels().min(audio.peaks.len()).max(1); + for k in 1..n { + let y = body_r.min.y + body_r.height() / n as f32 * k as f32; + painter.line_segment([pos2(vis.min.x, y), pos2(vis.max.x, y)], Stroke::new(1.0, body.gamma_multiply(0.7))); } } } else { @@ -1254,7 +1657,11 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } let y = body_r.max.y - 3.0 - (f32::from(n.pitch.saturating_sub(lo)) / span) * (body_r.height() - 6.0); let nh = ((body_r.height() - 6.0) / span).clamp(1.5, 6.0); - let vcol = Color32::from_rgb(20, 20 + n.velocity, 40); + let vcol = if crate::theme::is_light() { + Color32::from_rgb(20, 20 + n.velocity, 40) + } else { + wave.gamma_multiply(0.5 + f32::from(n.velocity) / 254.0) + }; p.rect_filled( Rect::from_min_max(pos2(nx0, y - nh * 0.5), pos2(nx1, y + nh * 0.5)), 1.0, @@ -1263,6 +1670,21 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track } } } + let prev = crossfade_partner(track, clip); + if s.edit.flag("waveform.overlapped_crossfades") + && track.view != "blocks" + && let Some(prev) = prev + && let ClipContent::Audio { source, .. } = &prev.content + && let Some(audio) = s.pool.get(*source) + { + let xf = Rect::from_min_max(pos2(x0, body_r.min.y), pos2(x_of(s, tl, prev.end()).min(x1), body_r.max.y)).intersect(vis); + let tail = if prev.muted || dim { + Color32::from_rgb(70, 70, 70) + } else { + clip_colors(prev.color.unwrap_or(track.color), false).2.gamma_multiply(0.6) + }; + crate::waveform::draw(&painter.with_clip_rect(xf), s, tl, prev, audio, track.channels(), body_r, tail); + } // Clip gain line. let show_gain = s.edit.flag("view.clip.gain_line") && (!clip.gain_env.is_empty() || clip.gain_db.abs() > 0.01 || track.view == "clip_gain"); if show_gain && body_r.height() > 12.0 { @@ -1287,28 +1709,28 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col); painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); } - // Fades. let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); - if clip.fade_in.len > 0 { - let fx = x_of(s, tl, clip.start + clip.fade_in.len); - let pts: Vec = (0..=16) - .map(|i| { - let k = i as f32 / 16.0; - pos2(x0 + (fx - x0) * k, body_r.max.y - body_r.height() * clip.fade_in.shape.gain(k)) - }) - .collect(); - painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); - painter.line_segment([pos2(x0, body_r.min.y), pos2(fx, body_r.min.y)], Stroke::new(1.0, fade_col)); + for (side, fade) in [(true, fade_in), (false, fade_out)] { + if fade.len <= 0 { + continue; + } + let (a, b) = + if side { (x0, x_of(s, tl, clip.start.saturating_add(fade.len))) } else { (x_of(s, tl, clip.end().saturating_sub(fade.len)), x1) }; + let pts = fade_curve_points(fade, side, a, b, body_r, painter.ctx().pixels_per_point()); + painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); + let midpoint = fade_control_position(fade, a, b, body_r); + let active = matches!(app.gesture, Some(Gesture::FadeCurve { clip: id, fade_in, .. }) if id == clip.id && fade_in == side); + let hovered = painter.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|p| near_fade_midpoint(p, midpoint)); + if !dim && s.edit.tool == Tool::Smart && AutoParam::parse(&track.view).is_none() && b - a >= 32.0 && vis.contains(midpoint) { + let col = Color32::from_rgba_unmultiplied(255, 255, 255, if active || hovered { 230 } else { 200 }); + painter.with_clip_rect(vis).circle(midpoint, 3.5, body, Stroke::new(1.0, col)); + } } - if clip.fade_out.len > 0 { - let fx = x_of(s, tl, clip.end() - clip.fade_out.len); - let pts: Vec = (0..=16) - .map(|i| { - let k = i as f32 / 16.0; - pos2(fx + (x1 - fx) * k, body_r.max.y - body_r.height() * clip.fade_out.shape.gain(1.0 - k)) - }) - .collect(); - painter.add(Shape::line(pts, Stroke::new(1.2, fade_col))); + // The fade-out of the clip this one crossfades with, drawn over the overlap. + if let Some(c) = prev.filter(|c| c.fade_out.len > 0) { + let (a, b) = (x_of(s, tl, c.end().saturating_sub(c.fade_out.len)), x_of(s, tl, c.end())); + let pts = fade_curve_points(c.fade_out, false, a, b, body_r, painter.ctx().pixels_per_point()); + painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); } // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16); @@ -1325,16 +1747,17 @@ 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( - i64::try_from(src.time_reference).unwrap_or(0) + clip.source_offset(), + i64::try_from(src.time_reference).unwrap_or(0).saturating_add(clip.source_offset()).saturating_sub(s.timecode_start), TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, 0, + 0, ) }) } else { @@ -1364,6 +1787,15 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let _ = app; } +/// The earlier clip of a crossfade pair: the one before `clip` in its playlist, overlapping its start. +fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> { + let (p, i) = track.playlists.iter().find_map(|p| { + let i = p.clips.partition_point(|c| c.start < clip.start); + p.clips.get(i).is_some_and(|c| c.id == clip.id).then_some((p, i)) + })?; + p.clips.get(i.checked_sub(1)?).filter(|c| c.end() > clip.start) +} + fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) { let t = Tokens::current(); let (lo, hi, _) = p.range(); @@ -1426,13 +1858,59 @@ fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) { // ---- interaction ----------------------------------------------------------------------------- fn snap(s: &Session, at: Samples) -> Samples { - if s.edit.edit_mode.is_grid() { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } + let on = s.edit.edit_mode.is_grid() || s.edit.flag(ops::SNAP_TO_GRID); + if on { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } } fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { track.clips().iter().rev().find(|c| c.range().contains(at)) } +/// The unity-gain end of each existing fade, at the top of the clip body. +fn fade_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Option { + if clip.length <= 0 || clip.end() <= clip.start { + return None; + } + let y = lane.min.y + 1.0 + clip_name_height(s, lane); + [(true, clip.fade_in), (false, clip.fade_out)] + .into_iter() + .filter(|(_, fade)| fade.len > 0) + .filter_map(|(fade_in, fade)| { + let len = fade.len.min(clip.length); + let at = if fade_in { clip.start.saturating_add(len).min(clip.end()) } else { clip.end().saturating_sub(len).max(clip.start) }; + let head = pos2(x_of(s, tl, at), y); + let delta = p - head; + (lane.contains(head) && delta.x.abs() <= 8.0 && delta.y.abs() <= 8.0).then_some((fade_in, delta.length_sq())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(fade_in, _)| fade_in) +} + +fn near_fade_midpoint(p: Pos2, midpoint: Pos2) -> bool { + let delta = p - midpoint; + delta.x.abs() <= 8.0 && delta.y.abs() <= 8.0 +} + +fn fade_midpoint(s: &Session, tl: Rect, lane: Rect, clip: &Clip, fade_in: bool) -> Option { + let fade = if fade_in { clip.fade_in } else { clip.fade_out }; + if fade.len <= 0 || clip.length <= 0 { + return None; + } + let len = fade.len.min(clip.length); + let (a, b) = if fade_in { (clip.start, clip.start.saturating_add(len)) } else { (clip.end().saturating_sub(len), clip.end()) }; + let (a, b) = (x_of(s, tl, a), x_of(s, tl, b)); + if b - a < 32.0 { + return None; + } + let body = Rect::from_min_max(pos2(a, lane.min.y + 1.0 + clip_name_height(s, lane)), pos2(b, lane.max.y - 1.0)); + let p = fade_control_position(fade, a, b, body); + lane.contains(p).then_some(p) +} + +fn fade_curve_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Option { + [true, false].into_iter().find(|side| fade_midpoint(s, tl, lane, clip, *side).is_some_and(|midpoint| near_fade_midpoint(p, midpoint))) +} + pub const CLIP_FADER_TRAVEL: f32 = 200.0; /// Bottom-left of an audio clip's visible part, when Clip Gain Info is on or the clip has gain and it fits. @@ -1480,20 +1958,40 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, && let Some(p) = ui.ctx().pointer_latest_pos() { let s = app.engine.session(); - let at = snap(s, sample_at(s, tl, p.x).max(0)); - let _ = app.run("clip.place_source", json!({"source": src.0, "track": track.id.0, "at": at})); + let at = drag_snap(s, sample_at(s, tl, p.x).max(0), ui.input(|i| i.modifiers.command)); + let spot = Spot::source(s, soundcraft_model::SourceId(src.0), track.id, at); + spot_or_run(app, spot); } if resp.dnd_hover_payload::().is_some() { ui.painter().rect_stroke(lane, 0.0, Stroke::new(2.0, Tokens::current().accent), StrokeKind::Inside); } - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); let tool = s.edit.tool; let mods = ui.input(|i| i.modifiers); let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return }; let at = sample_at(&s, tl, p.x).max(0); let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p }; let at_origin = sample_at(&s, tl, origin.x).max(0); - let hit = clip_at(track, at_origin).cloned(); + let auto_view = AutoParam::parse(&track.view); + let body_hit = clip_at(track, at_origin); + // Check fade endpoints before clip bodies: a full-length fade can end exactly + // at the next clip's start, where `clip_at` would otherwise choose that neighbour. + let fade_handle = if tool == Tool::Smart && auto_view.is_none() { + track + .clips() + .iter() + .rev() + .filter(|c| body_hit.is_none_or(|front| front.id == c.id || front.start == c.end())) + .find_map(|c| fade_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + } else { + None + }; + let curve_handle = if tool == Tool::Smart && auto_view.is_none() && fade_handle.is_none() { + body_hit.and_then(|c| fade_curve_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + } else { + None + }; + let hit = fade_handle.or(curve_handle).map(|(c, _)| c).or(body_hit).cloned(); let upper = origin.y < lane.center().y; let near_edge = |c: &Clip| -> Option { let xs = x_of(&s, tl, c.start); @@ -1534,21 +2032,22 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, }; ui.ctx().set_cursor_icon(icon); } - let auto_view = AutoParam::parse(&track.view); let fade_corner = if tool == Tool::Smart { - hit.as_ref().and_then(|c| { - let xs = x_of(&s, tl, c.start); - let xe = x_of(&s, tl, c.end()); - let top = origin.y < lane.min.y + lane.height() * 0.28; - if !top { - None - } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { - Some(true) - } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { - Some(false) - } else { - None - } + fade_handle.map(|(_, side)| side).or_else(|| { + hit.as_ref().and_then(|c| { + let xs = x_of(&s, tl, c.start); + let xe = x_of(&s, tl, c.end()); + let top = origin.y < lane.min.y + lane.height() * 0.28; + if !top { + None + } else if (origin.x - xs) > 0.0 && (origin.x - xs) < 14.0 { + Some(true) + } else if (xe - origin.x) > 0.0 && (xe - origin.x) < 14.0 { + Some(false) + } else { + None + } + }) }) } else { None @@ -1556,6 +2055,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if fade_corner.is_some() && resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); } + if curve_handle.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } let gain_icon_at = |q: Pos2| { let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?; clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) @@ -1563,7 +2065,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.hovered() && gain_icon_at(p).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let selector_press = eff == Tool::Selector && fade_corner.is_none() && gain_icon_at(origin).is_none(); + let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { place_insertion(app, ui, &s, track.id, at, mods.shift); } @@ -1579,10 +2081,24 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let fader_bottom = pos2(origin.x.max(screen.min.x + 26.0), y); let fine = mods.command || mods.ctrl; app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (origin.y, pos), fine, fader_bottom }); + } else if resp.drag_started() + && let Some((c, fade_in)) = curve_handle + { + let fade = if fade_in { c.fade_in } else { c.fade_out }; + let anchor = (origin.y, fade.gain(0.5)); + let height = (lane.height() - 2.0 - clip_name_height(&s, lane)).max(1.0); + app.gesture = Some(Gesture::FadeCurve { clip: c.id, fade_in, curvature: fade_curve_drag_value(fade, anchor, height, p.y), anchor, height }); + ui.ctx().request_repaint(); } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { - app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); + app.gesture = fade_drag_range(c, fade_in, at).map(|range| Gesture::Fade { + clip: c.id, + track: track.id, + fade_in, + to: if fade_in { range.end } else { range.start }, + }); + ui.ctx().request_repaint(); } else if resp.drag_started() { app.gesture = match eff { Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }), @@ -1596,6 +2112,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, _ => Gesture::TrimEnd { clip: c.id, track: track.id, to: c.end() }, }), Tool::Scrubber => Some(Gesture::Scrub { last: at }), + Tool::Pencil if auto_view.is_none() && track.kind.is_midi() => { + hit.is_none().then_some(Gesture::MidiClip { track: track.id, from: at_origin, to: at_origin }) + } Tool::Pencil => Some(Gesture::Pencil { track: track.id, points: Vec::new() }), Tool::Zoom => Some(Gesture::ZoomBox { start: origin.x }), }; @@ -1618,13 +2137,22 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } - if resp.clicked() { + if resp.clicked() && curve_handle.is_none() { match eff { Tool::Grabber => { if let Some(c) = &hit { let mut ids = if mods.shift { s.edit.selected_clips.iter().map(|x| x.0).collect::>() } else { Vec::new() }; ids.push(c.id.0); let _ = app.run("edit.select", json!({"clips": ids})); + if s.edit.edit_mode == EditMode::Spot { + app.dialogs.open = Spot::clips(&s, vec![c.id], None, 0).map(Dialog::Spot); + } + } + } + Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => { + if let Some(c) = &hit { + let start = at - c.start < c.end() - at; + app.dialogs.open = Spot::trim(&s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot); } } Tool::Zoom => { @@ -1633,12 +2161,20 @@ 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", json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}), ); + } else if track.kind.is_midi() && hit.is_none() { + new_midi_clip(app, &s, track.id, at, at); } } _ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift), @@ -1646,6 +2182,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } if resp.double_clicked() + && curve_handle.is_none() && eff != Tool::Pencil && let Some(c) = &hit { @@ -1672,6 +2209,33 @@ fn place_insertion(app: &mut SoundApp, ui: &Ui, s: &Session, track: TrackId, at: } } +/// The same bounded range drives the preview and the committed fade. +fn fade_drag_range(clip: &Clip, fade_in: bool, to: Samples) -> Option { + let end = clip.end(); + if clip.length <= 0 || end <= clip.start { + return None; + } + Some(if fade_in { + Range::new(clip.start, to.clamp(clip.start.saturating_add(1), end)) + } else { + Range::new(to.clamp(clip.start, end.saturating_sub(1)), end) + }) +} + +fn fade_drag_preview(clip: &Clip, fade_in: bool, to: Samples) -> Option { + let range = fade_drag_range(clip, fade_in, to)?; + let original = if fade_in { clip.fade_in } else { clip.fade_out }; + Some(soundcraft_model::Fade { + len: range.end.saturating_sub(range.start), + shape: if original.len > 0 { original.shape } else { soundcraft_model::FadeShape::SCurve }, + curvature: if original.len > 0 { original.curvature } else { 0.0 }, + }) +} + +fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32, f32), height: f32, y: f32) -> f32 { + fade.curvature_for_midpoint_gain(anchor.1 + (anchor.0 - y) / height.max(1.0)) +} + /// Runs before anything is painted, against the last frame's layout. fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { @@ -1689,6 +2253,7 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { let rows = &app.edit_layout.rows; let gap = |r: &[f32; 4]| (r[1] - inside.y).max(inside.y - r[3]).max(0.0); let to_track = rows.iter().min_by(|a, b| gap(&a.1).total_cmp(&gap(&b.1))).map(|(i, _)| TrackId(*i)); + let mut invalid_fade = false; match &mut app.gesture { Some(Gesture::Select { track, tracks, .. }) => { let row_of = |t: TrackId| rows.iter().position(|(i, _)| *i == t.0); @@ -1696,23 +2261,34 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { *tracks = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| TrackId(*i)).collect(); } } - Some(Gesture::MoveClips { grab_at, delta, from_track, to_track: tt, .. }) => { - let raw = at - *grab_at; - *delta = if s.edit.edit_mode.is_grid() { - let base = s.track(*from_track).and_then(|t| clip_at(t, *grab_at)).map_or(*grab_at, |c| c.start); - snap(&s, base + raw) - base - } else { - raw - }; - if s.edit.edit_mode == soundcraft_model::EditMode::Spot { - *delta = 0; - } + Some(Gesture::MoveClips { clips, grab_at, delta, from_track, to_track: tt }) => { if let Some(t2) = to_track { *tt = t2; } + let plan = drag_plan(ctx, &s, app.engine.revision, clips, (*from_track, *tt), *grab_at); + *delta = move_delta(&plan, &s, *from_track, at - *grab_at, ctx.input(|i| i.modifiers.command)); + } + Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = drag_snap(&s, at, ctx.input(|i| i.modifiers.command)), + Some(Gesture::MidiClip { to, .. }) => *to = at, + Some(Gesture::Fade { clip, fade_in, to, .. }) => { + if let Some(range) = s.find_clip(*clip).and_then(|(_, c)| fade_drag_range(c, *fade_in, at)) { + *to = if *fade_in { range.end } else { range.start }; + } else { + invalid_fade = true; + } + } + Some(Gesture::FadeCurve { clip, fade_in, curvature, anchor, height }) => { + if let Some((_, c)) = s.find_clip(*clip).filter(|(_, c)| c.length > 0) { + let fade = if *fade_in { c.fade_in } else { c.fade_out }; + if fade.len > 0 { + *curvature = fade_curve_drag_value(fade, *anchor, *height, p.y); + } else { + invalid_fade = true; + } + } else { + invalid_fade = true; + } } - Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), - Some(Gesture::Fade { to, .. }) => *to = at, Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1739,6 +2315,9 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { } _ => {} } + if invalid_fade { + app.gesture = None; + } if let Some(Gesture::Select { anchor, tracks, .. }) = &app.gesture { let r = Range::new(*anchor, snap(&s, at)); if *tracks != s.edit.selected_tracks || r != s.edit.selection { @@ -1759,25 +2338,31 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { match app.gesture.take() { - Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => { - let ids: Vec = clips.iter().map(|c| c.0).collect(); - let mut params = json!({"clips": ids, "by": delta}); - if to_track != from_track { - params["track"] = json!(to_track.0); - } - if delta != 0 || to_track != from_track { - let _ = app.run("edit.move_clips", params); + Some(Gesture::MoveClips { clips, delta, from_track, to_track, .. }) => drop_clips(app, clips, delta, from_track, to_track), + Some(Gesture::Fade { clip, fade_in, to, .. }) => { + let to = p.map_or(to, |p| sample_at(s, tl, p.x).max(0)); + if let Some(fade) = s.find_clip(clip).and_then(|(_, c)| fade_drag_preview(c, fade_in, to)) { + let _ = app.run( + "edit.fades_create", + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": fade.len, "shape": fade.shape.label(), "curvature": fade.curvature}), + ); } } - Some(Gesture::Fade { clip, track, fade_in, to }) => { - if let Some((_, c)) = s.find_clip(clip) { - // Not clamp: it panics on a zero-length clip. - let (a, b) = if fade_in { (c.start, to.max(c.start + 1).min(c.end())) } else { (to.max(c.start).min(c.end() - 1), c.end()) }; - let _ = app.run("edit.fades_create", json!({"tracks": [track.0], "start": a, "end": b, "shape": "s-curve"})); + Some(Gesture::FadeCurve { clip, fade_in, curvature, anchor, height }) => { + if let Some((_, c)) = s.find_clip(clip).filter(|(_, c)| c.length > 0) { + let fade = if fade_in { c.fade_in } else { c.fade_out }; + let curvature = p.map_or(curvature, |p| fade_curve_drag_value(fade, anchor, height, p.y)); + if fade.len > 0 && (fade.curvature - curvature).abs() > 1e-6 { + let _ = app.run( + "edit.fades_create", + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": fade.len, "shape": fade.shape.label(), "curvature": curvature}), + ); + } } } - Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), - Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), + Some(Gesture::TrimStart { clip, to, .. }) => trim_clip(app, clip, to, true), + Some(Gesture::TrimEnd { clip, to, .. }) => trim_clip(app, clip, to, false), + Some(Gesture::MidiClip { track, from, to }) => new_midi_clip(app, s, track, from, to), Some(Gesture::Pencil { track, points }) => { if let Some(param) = s.track(track).and_then(|t| AutoParam::parse(&t.view)) { let pid = automation_id(¶m); @@ -1813,22 +2398,120 @@ fn automation_id(p: &AutoParam) -> String { } } -fn trim_clip(app: &mut SoundApp, clip: ClipId, track: TrackId, to: Samples, start: bool) { - let cmd = if start { "edit.trim_start_to_insertion" } else { "edit.trim_end_to_insertion" }; - // Trims through the command layer: select the clip's track and position the insertion. - let sel_before = app.engine.session().edit.selection; - let tracks_before: Vec = app.engine.session().edit.selected_tracks.iter().map(|x| x.0).collect(); - let c = app.engine.session().find_clip(clip).map(|(_, c)| c.clone()); - if let Some(c) = c { - if start && to < c.start || !start && to > c.end() { - // Extending: use trim to fill selection with handles. - let r = if start { Range::new(to, c.end()) } else { Range::new(c.start, to) }; - let _ = app.run("edit.trim_to_fill_selection", json!({"clips": [clip.0], "start": r.start, "end": r.end})); - } else { - let _ = app.run(cmd, json!({"tracks": [track.0], "at": to})); - } +/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip. +fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples { + if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at } +} + +/// What a clip drag works out once per target track rather than every frame: where the tracks go +/// (None when the clips can't land there), each destination's earliest clip and Shuffle slots, +/// the clips that move (track, start, length) and the clip Grid snaps. +struct DragPlan { + dest: Option>, + groups: Vec<(TrackId, Samples, Vec)>, + moving: Vec<(TrackId, Samples, Samples)>, + grabbed: Option, +} + +/// The [`DragPlan`] for `clips` grabbed at `grab_at` on track `from` and dragged onto `to`, kept +/// until the document, the Edit mode or the target changes. +fn drag_plan(ctx: &egui::Context, s: &Session, revision: u64, clips: &[ClipId], (from, to): (TrackId, TrackId), grab_at: Samples) -> Arc { + type Key = (u64, EditMode, Vec, TrackId, TrackId, Samples); + let id = egui::Id::new("clip_drag_plan"); + let key: Key = (revision, s.edit.edit_mode, clips.to_vec(), from, to, grab_at); + if let Some((k, plan)) = ctx.data(|d| d.get_temp::<(Key, Arc)>(id)) + && k == key + { + return plan; + } + let dest = if to == from { Some(Vec::new()) } else { ops::destinations(s, clips, from, to) }; + let groups = match &dest { + Some(d) if s.edit.edit_mode == EditMode::Shuffle => { + ops::shuffle_groups(s, clips, d).into_iter().map(|(t, first)| (t, first, ops::shuffle_slots(s, t, clips))).collect() + } + _ => Vec::new(), + }; + let ids: HashSet = clips.iter().copied().collect(); + let movable = |c: &&Clip| ids.contains(&c.id) && !ops::locked(c); + let moving = s.tracks.iter().flat_map(|t| t.clips().iter().filter(movable).map(|c| (t.id, c.start, c.length))).collect(); + let mine: Vec<&Clip> = s.track(from).map(|t| t.clips().iter().filter(movable).collect()).unwrap_or_default(); + let grabbed = mine.iter().rev().find(|c| c.range().contains(grab_at)).or_else(|| mine.iter().min_by_key(|c| c.start)).map(|c| (*c).clone()); + let plan = Arc::new(DragPlan { dest, groups, moving, grabbed }); + ctx.data_mut(|d| d.insert_temp(id, (key, Arc::clone(&plan)))); + plan +} + +/// How far a Grabber drag by `raw` from track `from` takes the clips in the current Edit mode. +/// Shuffle lands the earliest clip bound for the target track on a slot there, as +/// `edit.move_clips` does; Grid snaps the grabbed clip. +fn move_delta(plan: &DragPlan, s: &Session, from: TrackId, raw: Samples, flip: bool) -> Samples { + let mode = s.edit.edit_mode; + if mode == EditMode::Shuffle { + let to = plan.dest.as_ref().map_or(from, |d| ops::lands(d, from)); + plan.groups + .iter() + .find(|g| g.0 == to) + .map_or(raw, |(_, first, slots)| ops::nearest_slot(slots, first.saturating_add(raw)).saturating_sub(*first)) + } else if mode.is_grid() != flip + && let Some(c) = &plan.grabbed + { + ops::grid_delta(s, c, raw, mode == EditMode::GridRelative) + } else { + raw + } +} + +/// The dragged clips with one from the grabbed track first: `edit.move_clips` sends that track to +/// the target and the others along with it. +fn grabbed_first(s: &Session, clips: &[ClipId], from: TrackId) -> Vec { + let mut v = clips.to_vec(); + v.sort_by_key(|c| s.find_clip(*c).is_none_or(|(t, _)| t != from)); + v +} + +fn drop_clips(app: &mut SoundApp, clips: Vec, delta: Samples, from: TrackId, to: TrackId) { + let s = app.engine.session(); + let spot = Spot::clips(s, grabbed_first(s, &clips, from), (to != from).then_some(to), delta); + spot_or_run(app, spot); +} + +fn trim_clip(app: &mut SoundApp, clip: ClipId, to: Samples, start: bool) { + let spot = Spot::trim(app.engine.session(), clip, start, to); + spot_or_run(app, spot); +} + +/// Spot mode asks for exact positions first; the other modes make the edit straight away. +fn spot_or_run(app: &mut SoundApp, spot: Option) { + let Some(spot) = spot else { return }; + if app.engine.session().edit.edit_mode == EditMode::Spot { + app.dialogs.open = Some(Dialog::Spot(spot)); + } else if let Some((id, params)) = spot.command(app.engine.session()) { + let _ = app.run(&id, params); + } +} + +/// Whole bars from `from` to `to` (at least one), inside the gap between clips where `from` is. +fn midi_clip_span(s: &Session, track: TrackId, from: Samples, to: Samples) -> Option { + let clips = s.track(track)?.clips(); + let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }; + let start = bar.line_at_or_before(from.min(to), s.sample_rate, &s.tempo, s.frame_rate); + let last = from.max(to); + let mut end = bar.line_at_or_before(last, s.sample_rate, &s.tempo, s.frame_rate); + if end < last || end <= start { + end = bar.next_line(end, s.sample_rate, &s.tempo, s.frame_rate); + } + let prev = clips.iter().map(Clip::end).filter(|&e| e <= from).max().unwrap_or(start); + let next = clips.iter().map(|c| c.start).filter(|&b| b > from).min().unwrap_or(end); + let r = Range::new(start.max(prev), end.min(next)); + (!r.is_empty()).then_some(r) +} + +fn new_midi_clip(app: &mut SoundApp, s: &Session, track: TrackId, from: Samples, to: Samples) { + if let Some(r) = midi_clip_span(s, track, from, to) + && let Ok(v) = app.run("midi.new_clip", json!({"track": track.0, "start": r.start, "end": r.end})) + { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); } - let _ = app.engine.execute("edit.select", &json!({"tracks": tracks_before, "start": sel_before.start, "end": sel_before.end})); } fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option) { @@ -1966,12 +2649,20 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { scroll_to = follow_to; } // Ghost of a clip move. - if let Some(Gesture::MoveClips { clips, delta, to_track, .. }) = &app.gesture { - let row = app.edit_layout.rows.iter().find(|(i, _)| *i == to_track.0).map(|(_, r)| *r); - for c in clips { - if let (Some((_, clip)), Some(r)) = (s.find_clip(*c), row) { - let x0 = x_of(s, tl, clip.start + delta); - let x1 = x_of(s, tl, clip.end() + delta); + if let Some(Gesture::MoveClips { clips, delta, from_track, to_track, grab_at }) = &app.gesture + && let plan = drag_plan(ui.ctx(), s, app.engine.revision, clips, (*from_track, *to_track), *grab_at) + && let Some(dest) = &plan.dest + { + for &(t, start, len) in &plan.moving { + let d = ops::lands(dest, t); + let start = plan + .groups + .iter() + .find(|g| g.0 == d) + .map_or(start + delta, |(_, first, slots)| ops::nearest_slot(slots, first + delta) + (start - first)); + if let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == d.0) { + let x0 = x_of(s, tl, start); + let x1 = x_of(s, tl, start + len); painter.rect_stroke( Rect::from_min_max(pos2(x0, r[1] + 1.0), pos2(x1, r[3] - 1.0)), 2.0, @@ -1981,6 +2672,13 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } } } + if let Some(Gesture::MidiClip { track, from, to }) = &app.gesture + && let Some(r) = midi_clip_span(s, *track, *from, *to) + && let Some((_, row)) = app.edit_layout.rows.iter().find(|(i, _)| *i == track.0) + { + let ghost = Rect::from_min_max(pos2(x_of(s, tl, r.start), row[1] + 1.0), pos2(x_of(s, tl, r.end), row[3] - 1.0)); + painter.rect_stroke(ghost, 2.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Inside); + } if let Some(Gesture::TrimStart { to, clip, .. } | Gesture::TrimEnd { to, clip, .. }) = &app.gesture && let Some((tid, _)) = s.find_clip(*clip) && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) @@ -1988,19 +2686,14 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { let x = x_of(s, tl, *to); painter.line_segment([pos2(x, r[1]), pos2(x, r[3])], Stroke::new(2.0, Color32::WHITE)); } - if let Some(Gesture::Fade { clip, fade_in, to, .. }) = &app.gesture - && let Some((tid, c)) = s.find_clip(*clip) - && let Some((_, r)) = app.edit_layout.rows.iter().find(|(i, _)| *i == tid.0) - { - let (a, b) = if *fade_in { (c.start, *to) } else { (*to, c.end()) }; - let (x0, x1) = (x_of(s, tl, a), x_of(s, tl, b)); - let (y0, y1) = (r[1] + 2.0, r[3] - 2.0); - let line = if *fade_in { [pos2(x0, y1), pos2(x1, y0)] } else { [pos2(x0, y0), pos2(x1, y1)] }; - painter.line_segment(line, Stroke::new(2.0, Color32::WHITE)); - } if let Some(at) = scroll_to { let _ = app.engine.execute("view.scroll", &json!({"to": at})); } + if matches!(app.gesture, Some(Gesture::Fade { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } else if matches!(app.gesture, Some(Gesture::FadeCurve { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } } #[cfg(test)] @@ -2055,7 +2748,9 @@ mod tests { fn harness_with(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { let app = SoundApp::new(engine, None, crate::Services::default()); - let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + let builder = Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0); + let builder = if std::env::var_os("SOUNDCRAFT_FADE_SHOTS").is_some() { builder.wgpu() } else { builder }; + let mut h = builder.build_ui_state( |ui, app: &mut SoundApp| { let ctx = ui.ctx().clone(); app.logic(&ctx); @@ -2068,6 +2763,15 @@ mod tests { h } + #[test] + fn a_file_time_stamp_at_the_end_of_the_range_draws() { + let mut engine = soundcraft_engine::demo::demo_engine(); + let s = engine.session_mut(); + s.edit.flags.insert("view.clip.time.original".into()); + s.sources.iter_mut().for_each(|src| src.time_reference = i64::MAX as u64); + harness_with(engine).run_steps(2); + } + fn smart_demo() -> Harness<'static, SoundApp> { let mut engine = soundcraft_engine::demo::demo_engine(); engine.execute("edit.tool", &json!({"tool": "smart"})).unwrap(); @@ -2325,6 +3029,706 @@ mod tests { assert_eq!(after.start - clip.start, by, "the clip trails the pointer"); } + #[test] + fn fade_drag_ranges_handle_empty_short_and_saturated_clips() { + let mut c = Clip::audio(ClipId(1), "test", soundcraft_model::SourceId(1), 0, 100, 1); + for side in [true, false] { + for to in [i64::MIN, 100, 101, i64::MAX] { + assert_eq!(fade_drag_range(&c, side, to), Some(Range::new(100, 101))); + } + c.length = 0; + assert_eq!(fade_drag_range(&c, side, 100), None); + c.length = -1; + assert_eq!(fade_drag_range(&c, side, 100), None); + c.length = 1; + } + c.start = i64::MAX - 1; + c.length = 10; + for side in [true, false] { + assert_eq!(fade_drag_range(&c, side, i64::MAX), Some(Range::new(i64::MAX - 1, i64::MAX))); + c.start = i64::MAX; + assert_eq!(fade_drag_range(&c, side, i64::MAX), None); + c.start = i64::MAX - 1; + } + } + + fn fade_shot(h: &mut Harness, name: &str) { + if let Some(dir) = std::env::var_os("SOUNDCRAFT_FADE_SHOTS") { + let dir = std::path::PathBuf::from(dir); + std::fs::create_dir_all(&dir).unwrap(); + h.render().unwrap().save(dir.join(format!("{name}.png"))).unwrap(); + } + } + + fn fade_curves(h: &Harness, clip_rect: Rect) -> Vec<(egui::epaint::PathShape, Rect)> { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Path(path) + if path.stroke == egui::epaint::PathStroke::new(1.2, Color32::from_rgba_unmultiplied(255, 255, 255, 90)) + && path.points.iter().all(|point| clip_rect.contains(*point)) => + { + Some((path.clone(), shape.clip_rect)) + } + _ => None, + }) + .collect() + } + + fn waveform_geometry(h: &Harness, clip_rect: Rect) -> Vec> { + h.output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + // The waveform's columns snap to physical pixels and fade out over half of one. + Shape::Mesh(mesh) if !mesh.vertices.is_empty() && mesh.vertices.iter().all(|vertex| clip_rect.expand(1.0).contains(vertex.pos)) => { + Some(mesh.vertices.iter().map(|vertex| vertex.pos).collect()) + } + _ => None, + }) + .collect() + } + + #[test] + fn waveforms_follow_fade_drags_before_release_and_match_the_committed_waveform() { + for channels in [1, 2] { + for fade_in in [true, false] { + for bend in [false, true] { + for mode in ["peak", "power", "rectified", "outlines"] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + let neighbour = h + .state() + .engine + .session() + .track(tid) + .unwrap() + .clips() + .iter() + .find(|c| c.id != clip.id && c.start >= clip.end()) + .unwrap() + .clone(); + let source = clip.source().unwrap(); + if channels == 2 { + let audio = h.state().engine.session().pool.get(source).unwrap().clone(); + let left = audio.buffer.channels[0].clone(); + let right = left.iter().map(|sample| sample * 0.5).collect(); + let audio = soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { + sample_rate: audio.buffer.sample_rate, + channels: vec![left, right], + }); + let s = h.state_mut().engine.session_mut(); + s.pool.insert(source, std::sync::Arc::new(audio)); + s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo; + } + if mode == "power" { + h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap(); + } + h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap(); + h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap(); + h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap(); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.gain_db = -3.0; + c.gain_env = vec![(0, -3.0), (c.length, 3.0)]; + let target = soundcraft_model::Fade { + len: if bend { c.length * 2 / 3 } else { c.length / 4 }, + shape: soundcraft_model::FadeShape::SCurve, + curvature: 0.0, + }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + let audio = before.pool.get(source).unwrap().clone(); + let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end())); + let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl); + let neighbour_rect = Rect::from_min_max( + pos2(x_of(&before, tl, neighbour.start), lane.min.y), + pos2(x_of(&before, tl, neighbour.end()), lane.max.y), + ) + .intersect(tl); + let original = waveform_geometry(&h, clip_rect); + let neighbour_wave = waveform_geometry(&h, neighbour_rect); + assert_eq!(original.len(), channels); + let side = if fade_in { "in" } else { "out" }; + let operation = if bend { "bend" } else { "length" }; + let screenshot = channels == 1 && mode == "peak"; + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-before")); + } + let (press, to) = if bend { + let c = before.find_clip(clip.id).unwrap().1; + let midpoint = fade_midpoint(&before, tl, lane, c, fade_in).unwrap(); + (midpoint, midpoint - vec2(0.0, 16.0)) + } else { + ( + pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0), + pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0), + ) + }; + h.hover_at(press); + h.drag_at(press); + h.hover_at(to); + h.step(); + h.step(); + assert!(if bend { + matches!(h.state().gesture, Some(Gesture::FadeCurve { .. })) + } else { + matches!(h.state().gesture, Some(Gesture::Fade { .. })) + }); + let preview = waveform_geometry(&h, clip_rect); + assert_eq!(preview.len(), channels); + for channel in 0..channels { + assert_ne!( + preview[channel], original[channel], + "the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}" + ); + } + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.session(), &before); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + assert!( + std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio), + "preview must retain cached media and peaks" + ); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview")); + } + h.drop_at(to); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump"); + assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio)); + if screenshot { + fade_shot(&mut h, &format!("waveform-{side}-{operation}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + h.step(); + assert_eq!(waveform_geometry(&h, clip_rect), original); + } + } + } + } + } + + #[test] + fn fade_drag_previews_and_commits_stay_inside_the_clip() { + for fade_in in [true, false] { + for release_outside in [false, true] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let corner = pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0); + let before = s.clone(); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { fade_in: side, .. }) if side == fade_in)); + let name = if fade_in { "fade-in" } else { "fade-out" }; + for (label, x) in [("left", xs - 60.0), ("right", xe + 60.0), ("inside", (xs + xe) / 2.0)] { + h.hover_at(pos2(x, corner.y)); + h.step(); + let s = h.state().engine.session(); + let range = fade_drag_range(&clip, fade_in, sample_at(s, tl, x)).unwrap(); + let curves = fade_curves(&h, Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y))); + assert_eq!(curves.len(), 1, "only one fade curve should be painted"); + let (curve, clip_rect) = &curves[0]; + let points = &curve.points; + assert_eq!(points.first().unwrap().x, x_of(s, tl, range.start)); + assert_eq!(points.last().unwrap().x, x_of(s, tl, range.end)); + assert!(points.iter().all(|p| p.x >= xs && p.x <= xe && lane.contains(*p))); + assert!(clip_rect.min.x >= xs && clip_rect.max.x <= xe); + assert_eq!(h.state().engine.session(), &before, "drag must only preview"); + if !release_outside { + fade_shot(&mut h, &format!("{name}-{label}")); + } + } + // Releasing at a new point without an intervening move must use that point. + let release = pos2(if release_outside { if fade_in { xe + 60.0 } else { xs - 60.0 } } else { xs + (xe - xs) * 0.3 }, corner.y); + h.drop_at(release); + h.step(); + assert!(h.state().gesture.is_none()); + let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, release.x)).unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if release_outside { + assert_eq!(fade.len, clip.length); + fade_shot(&mut h, &format!("{name}-committed")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); + } + } + } + + #[test] + fn a_fade_drag_replaces_the_old_curve_and_matches_the_released_curve() { + for fade_in in [true, false] { + for show_name in [true, false] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_in = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.0 }; + c.fade_out = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::EqualPower, curvature: 0.0 }; + h.step(); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)); + let original = fade_curves(&h, clip_rect); + assert_eq!(original.len(), 2); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let before = s.clone(); + let corner = pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0); + start_drag(&mut h, corner); + let to = pos2(if fade_in { xs + (xe - xs) * 0.65 } else { xs + (xe - xs) * 0.35 }, corner.y); + h.hover_at(to); + h.step(); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2, "one curve per side, without the old curve underneath"); + assert_ne!(preview[edited], original[edited]); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(h.state().engine.session(), &before); + assert!(!h.output().shapes.iter().any(|shape| { + matches!(&shape.shape, Shape::LineSegment { points, stroke } if stroke.width == 2.0 && stroke.color == Color32::WHITE && points.iter().all(|p| clip_rect.contains(*p))) + }), "the straight preview must be gone"); + let side = if fade_in { "in" } else { "out" }; + if show_name { + fade_shot(&mut h, &format!("fade-{side}-curve-dragging")); + } + h.drop_at(to); + h.step(); + assert_eq!(fade_curves(&h, clip_rect), preview, "curve geometry, thickness and colour must be identical after release"); + if show_name { + fade_shot(&mut h, &format!("fade-{side}-curve-released")); + } + } + } + } + + #[test] + fn fade_curve_sampling_keeps_steep_bends_below_a_quarter_pixel_error() { + for (width, height) in [(420.0, 180.0), (75.0, 320.0), (10_000.0, 80.0)] { + let body = Rect::from_min_size(Pos2::ZERO, vec2(width, height)); + for scale in [1.0, 2.0, 4.0] { + for shape in soundcraft_model::FadeShape::ALL { + for curvature in [-1.0, 1.0] { + for fade_in in [true, false] { + let fade = soundcraft_model::Fade { len: 1000, shape, curvature }; + let points = fade_curve_points(fade, fade_in, 0.0, width, body, scale); + assert!(points.len() > 17 && points.len() <= 4097); + assert_eq!((points.len() - 1) % 2, 0); + let segments = (points.len() - 1) as f32; + for (i, pair) in points.windows(2).enumerate() { + let line = pair[1] - pair[0]; + for fraction in [0.25, 0.5, 0.75] { + let t = (i as f32 + fraction) / segments; + let actual = pos2(width * t, height - height * fade.gain(if fade_in { t } else { 1.0 - t })); + let along = ((actual - pair[0]).dot(line) / line.length_sq()).clamp(0.0, 1.0); + let distance = actual.distance(pair[0] + line * along) * scale; + assert!( + distance < 0.25, + "{width}x{height}, scale={scale}, {shape:?}, curve={curvature}, side={fade_in}, error={distance}" + ); + } + } + } + } + } + } + } + let body = Rect::from_min_size(Pos2::ZERO, vec2(1e20, 1e20)); + for scale in [f32::NAN, f32::INFINITY, 1.0] { + let points = fade_curve_points(soundcraft_model::Fade::default(), true, 0.0, 1e20, body, scale); + assert!(points.len() <= 4097); + } + } + + fn painted_fade_control(h: &Harness, clip_rect: Rect) -> Pos2 { + let controls: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + Shape::Circle(circle) if circle.radius == 3.5 && clip_rect.contains(circle.center) => { + let radius = circle.radius + circle.stroke.width * 0.5; + for offset in [vec2(radius, 0.0), vec2(-radius, 0.0), vec2(0.0, radius), vec2(0.0, -radius)] { + assert!(shape.clip_rect.contains(circle.center + offset), "the complete control must remain visible"); + } + Some(circle.center) + } + _ => None, + }) + .collect(); + assert_eq!(controls.len(), 1, "the midpoint must stay visible without hover or a held mouse button"); + controls[0] + } + + #[test] + fn fade_midpoint_handles_remain_visible_and_can_be_grabbed_again() { + for fade_in in [true, false] { + for show_name in [true, false] { + for shape in [soundcraft_model::FadeShape::SCurve, soundcraft_model::FadeShape::Exponential] { + for height in ["micro", "jumbo"] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, _) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + let spp = h.state().engine.session().edit.zoom.samples_per_px * 0.5; + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.state_mut().engine.execute("track.height", &json!({"tracks": [tid.0], "height": height})).unwrap(); + h.state_mut().engine.execute("view.zoom_set", &json!({"samples_per_px": spp})).unwrap(); + h.state_mut().engine.execute("view.scroll", &json!({"to": clip.start - clip.length / 5})).unwrap(); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: c.length * 2 / 3, shape, curvature: 0.0 }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + h.step(); + let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == tid.0).unwrap().1; + let lane = lane_rect(h.state().engine.session().track(tid).unwrap(), rect(row), tl); + let s = h.state().engine.session(); + let clip_rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); + let control = painted_fade_control(&h, clip_rect); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, control); + let outside = control + vec2(0.0, 500.0); + h.hover_at(outside); + h.step(); + h.drop_at(outside); + h.step(); + let c = h.state().engine.session().find_clip(clip.id).unwrap().1; + assert_eq!(if fade_in { c.fade_in.curvature } else { c.fade_out.curvature }, -1.0); + assert!(h.state().gesture.is_none()); + painted_fade_control(&h, clip_rect); + let side = if fade_in { "in" } else { "out" }; + let screenshot = show_name && height == "jumbo" && shape == soundcraft_model::FadeShape::SCurve; + if screenshot { + fade_shot(&mut h, &format!("fade-{side}-smooth-released")); + } + for iteration in 0..2 { + let before = h.state().engine.session().clone(); + let control = painted_fade_control(&h, clip_rect); + h.hover_at(control); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + start_drag(&mut h, control); + let to = control - vec2(0.0, (lane.height() - 2.0 - clip_name_height(&before, lane)) * 0.1); + h.hover_at(to); + h.step(); + assert!( + matches!(h.state().gesture, Some(Gesture::FadeCurve { clip: id, fade_in: side, .. }) if id == clip.id && side == fade_in) + ); + h.drop_at(to); + h.step(); + let c = h.state().engine.session().find_clip(clip.id).unwrap().1; + let current = if fade_in { c.fade_in } else { c.fade_out }; + let previous = before.find_clip(clip.id).unwrap().1; + let previous = if fade_in { previous.fade_in } else { previous.fade_out }; + assert!(current.curvature > previous.curvature); + assert_eq!((current.len, current.shape), (previous.len, previous.shape)); + painted_fade_control(&h, clip_rect); + assert_eq!(h.state().engine.undo_history().len(), undo_len + iteration + 2); + if screenshot && iteration == 1 { + fade_shot(&mut h, &format!("fade-{side}-smooth-regrabbed")); + } + let after = h.state().engine.session().clone(); + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &after); + } + } + } + } + } + } + + #[test] + fn fade_midpoints_drag_vertically_without_moving_the_endpoints() { + for fade_in in [true, false] { + for show_name in [true, false] { + for dy in [-16.0, 16.0] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let clip_rect = + Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); + let original = fade_curves(&h, clip_rect); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let middle = original[edited].0.points.len() / 2; + let midpoint = original[edited].0.points[middle]; + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(midpoint); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + click(&mut h, midpoint); + assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); + h.drag_at(midpoint); + let to = midpoint + vec2(0.0, dy); + h.hover_at(to); + h.step(); + h.step(); // Paint the frame requested when the drag gesture starts. + let curvature = match h.state().gesture { + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, + _ => panic!("the midpoint must start a curve gesture"), + }; + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); + assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); + assert_eq!(preview[edited].0.points[middle].x, midpoint.x); + assert!( + (preview[edited].0.points[middle].y - (midpoint.y + dy)).abs() < 0.001, + "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", + preview[edited].0.points[middle], + h.state().gesture + ); + assert_eq!(h.state().engine.session(), &before); + let side = if fade_in { "in" } else { "out" }; + let direction = if dy < 0.0 { "up" } else { "down" }; + if show_name { + fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-preview")); + } + h.drop_at(to); + h.step(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + if fade_in { + c.fade_in.curvature = curvature; + } else { + c.fade_out.curvature = curvature; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); + let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; + let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); + let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); + assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if show_name { + fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); + } + } + } + } + + #[test] + fn fade_curve_drags_cancel_safely_or_return_to_the_original_curve() { + for invalidate in [0, 1, 2] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_out = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + h.step(); + let midpoint = + fade_midpoint(h.state().engine.session(), tl, lane, h.state().engine.session().find_clip(clip.id).unwrap().1, false).unwrap(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, midpoint); + h.hover_at(midpoint - vec2(0.0, 16.0)); + h.step(); + assert!(matches!(h.state().gesture, Some(Gesture::FadeCurve { .. }))); + if invalidate == 1 { + h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap().length = 0; + } else if invalidate == 2 { + let (tid, _) = before.find_clip(clip.id).unwrap(); + h.state_mut().engine.session_mut().track_mut(tid).unwrap().playlist_mut().unwrap().clips.retain(|c| c.id != clip.id); + } + h.hover_at(midpoint); + h.step(); + h.drop_at(midpoint); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + if invalidate == 0 { + assert_eq!(h.state().engine.session(), &before); + } + } + } + + #[test] + fn existing_fades_drag_from_their_upper_curve_endpoints() { + for fade_in in [true, false] { + for show_name in [true, false] { + for full_length in [false, true] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let len = if full_length { clip.length } else { clip.length * 2 / 3 }; + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.35 }; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + h.step(); + let s = h.state().engine.session(); + let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); + let curves = fade_curves(&h, Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y))); + assert_eq!(curves.len(), 1); + let curve = &curves[0].0; + // Take the handle from the painted curve, rather than duplicating hit geometry. + let head = *if fade_in { curve.points.last() } else { curve.points.first() }.unwrap(); + let press = head + vec2(3.0, 2.0); + if fade_in && full_length { + let (tid, _) = s.find_clip(clip.id).unwrap(); + assert_ne!( + clip_at(s.track(tid).unwrap(), sample_at(s, tl, press.x)).unwrap().id, + clip.id, + "the pointer should be over the adjacent clip" + ); + } + let before = s.clone(); + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(press); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + let side = if fade_in { "in" } else { "out" }; + if show_name && !full_length { + fade_shot(&mut h, &format!("fade-{side}-endpoint-hover")); + } + h.drag_at(press); + let to = head + vec2(if fade_in { -35.0 } else { 35.0 }, 0.0); + h.hover_at(to); + h.step(); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { clip: id, fade_in: side, .. }) if id == clip.id && side == fade_in)); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeNwSe); + assert_eq!(h.state().engine.session(), &before, "pressing the handle must not move the edit selection"); + if show_name && !full_length { + fade_shot(&mut h, &format!("fade-{side}-endpoint-dragging")); + } + h.drop_at(to); + h.step(); + let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, to.x)).unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.35 }; + assert!(fade.len < len); + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + } + } + } + } + + #[test] + fn existing_fade_handles_preserve_selector_and_other_smart_tool_zones() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + h.step(); + let s = h.state().engine.session(); + let rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); + let curves = fade_curves(&h, rect); + let head = *curves[0].0.points.first().unwrap(); + h.hover_at(head + vec2(0.0, 16.0)); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text); + h.state_mut().engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap(); + h.step(); + h.hover_at(head); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::Text); + start_drag(&mut h, head); + assert!(matches!(h.state().gesture, Some(Gesture::Select { .. }))); + h.drop_at(head + vec2(20.0, 0.0)); + h.step(); + } + + #[test] + fn a_fade_drag_handles_its_clip_shrinking_to_one_sample() { + for fade_in in [true, false] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 20.0 && xe < tl.max.x - 20.0 && xe - xs > 100.0); + let s = h.state().engine.session(); + let corner = pos2(x_of(s, tl, if fade_in { clip.start } else { clip.end() }) + if fade_in { 6.0 } else { -6.0 }, lane.min.y + 4.0); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { .. }))); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.length = 1; + c.clamp_fades(); + let undo_len = h.state().engine.undo_history().len(); + h.drop_at(corner + vec2(40.0, 0.0)); + h.step(); + let c = h.state().engine.session().find_clip(clip.id).unwrap().1; + assert_eq!(if fade_in { c.fade_in.len } else { c.fade_out.len }, 1); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + assert!(h.state().gesture.is_none()); + } + } + + #[test] + fn a_fade_drag_cancels_if_its_clip_disappears() { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 20.0 && xe - xs > 100.0); + let corner = pos2(x_of(h.state().engine.session(), tl, clip.start) + 6.0, lane.min.y + 4.0); + start_drag(&mut h, corner); + assert!(matches!(h.state().gesture, Some(Gesture::Fade { .. }))); + let (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap(); + h.state_mut().engine.session_mut().track_mut(tid).unwrap().playlist_mut().unwrap().clips.retain(|c| c.id != clip.id); + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(corner + vec2(40.0, 0.0)); + h.step(); + h.drop_at(corner + vec2(40.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + } + #[test] fn a_fade_drag_survives_its_clip_shrinking_to_nothing() { let mut h = smart_demo(); @@ -2336,9 +3740,14 @@ mod tests { if let Some(c) = h.state_mut().engine.session_mut().find_clip_mut(clip.id) { c.length = 0; } + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(corner + vec2(20.0, 0.0)); + h.step(); + assert!(h.state().gesture.is_none()); h.drop_at(corner + vec2(20.0, 0.0)); h.step(); assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); } #[test] @@ -2457,4 +3866,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..00bd28e 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -47,6 +47,12 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.circle_stroke(p(r, 0.42, 0.4), r.width() * 0.2, s); painter.line_segment([p(r, 0.56, 0.55), p(r, 0.8, 0.8)], Stroke::new(s.width * 1.6, c)); } + "zoom_toggle" => { + for (x, y, dx, dy) in [(0.18, 0.24, 0.16, 0.14), (0.82, 0.24, -0.16, 0.14), (0.18, 0.76, 0.16, -0.14), (0.82, 0.76, -0.16, -0.14)] { + painter.add(Shape::line(vec![p(r, x + dx, y), p(r, x, y), p(r, x, y + dy)], s)); + } + painter.rect_filled(Rect::from_min_max(p(r, 0.34, 0.41), p(r, 0.66, 0.59)), 1.0, c); + } "trim" => { painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s); painter.line_segment([p(r, 0.15, 0.5), p(r, 0.85, 0.5)], s); @@ -123,9 +129,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); @@ -140,6 +143,13 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) { painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c)); } } + "lock" => { + painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c); + let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19); + let arc: Vec = + (0..=12).map(|i| std::f32::consts::PI * i as f32 / 12.0).map(|a| pos2(x - w * a.cos(), y - w * 1.4 * a.sin())).collect(); + painter.add(Shape::line(arc, s)); + } "wave" => { let n = 24; let pts: Vec = (0..=n) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index c8c6052..fdb0d16 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -4,6 +4,7 @@ //! executing commands. It can be replaced without touching anything below it. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +pub mod bounce; pub mod control; pub mod credits; pub mod dialogs; @@ -19,19 +20,23 @@ pub mod ops_windows; pub mod palette; pub mod panels; mod recent; +pub mod recovery; pub mod score_editor; pub mod shortcuts; +pub mod spot; pub mod theme; pub mod toolbar; pub mod video_track; pub mod video_window; +pub mod waveform; pub mod widgets; pub mod windows; use serde_json::{Value, json}; use soundcraft_engine::{Engine, TransportRequest}; use soundcraft_model::TrackId; -use soundcraft_playback::Player; +use soundcraft_playback::record::Trouble; +use soundcraft_playback::{OutputTrouble, Player}; use soundcraft_time::{Range, Samples}; use std::collections::HashMap; use std::sync::mpsc::Receiver; @@ -71,8 +76,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, @@ -132,7 +142,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, @@ -203,6 +215,8 @@ pub struct MeterDisplay { pub hold_age: f32, pub clip: bool, pub gr: f32, + /// [`soundcraft_mix::PluginFailures::count`] as last seen. + pub plugin_failures: u32, } /// An in-progress mouse gesture in the Edit window. @@ -213,10 +227,12 @@ pub enum Gesture { TrimStart { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, TrimEnd { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples }, + FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, + MidiClip { track: TrackId, from: Samples, to: Samples }, ZoomBox { start: f32 }, } @@ -235,6 +251,10 @@ pub struct SoundApp { /// Punch range to trim the take to (pre/post-roll recordings). punch: Option, recorder_failed: bool, + /// Input this take lost to a slow disk, as last reported (ms). + lost_shown: u64, + /// Where the takes being recorded go, autosaved with the session so a crash can't lose them. + take_plan: Option, pub ui: UiState, pub audio_health_report: Option>, pub services: Services, @@ -242,6 +262,14 @@ pub struct SoundApp { pub native_menu_bar: bool, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, + /// Writes the open session's autosaves in the background, once there is something to write. + autosaver: Option, + /// The document revision last handed to the autosaver (0 once that autosave is stale). + autosaved_rev: u64, + /// Unsaved work that earlier runs left in the autosave folder, offered for recovery. + pub leftovers: Vec, + /// The document revision last autosaved after a caught panic. + panic_rev: u64, /// Folder for user plugin presets (native apps set it). pub preset_dir: Option, last_autosave: Option, @@ -269,17 +297,26 @@ 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, + /// The Bounce Mix in progress, rendered a slice per frame. + pub bounce: Option, + /// 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 @@ -304,11 +341,17 @@ impl SoundApp { record_start: 0, punch: None, recorder_failed: false, + lost_shown: 0, + take_plan: None, ui: UiState::default(), audio_health_report: None, services, native_menu_bar: false, autosave_dir: None, + autosaver: None, + autosaved_rev: 0, + leftovers: Vec::new(), + panic_rev: 0, preset_dir: None, last_autosave: None, dialogs: dialogs::Dialogs::default(), @@ -331,6 +374,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(), @@ -339,6 +383,9 @@ impl SoundApp { last_frame: None, last_meter_poll: f64::NEG_INFINITY, video: video_track::VideoPool::default(), + bounce: None, + #[cfg(test)] + _palette_lock: theme::lock_test_palette(), } } @@ -349,6 +396,9 @@ impl SoundApp { /// Run a command; errors go to the status line. Returns the result. pub fn run(&mut self, id: &str, params: Value) -> Result { + if matches!(id, "session.new" | "session.open" | "session.close" | "session.revert") && bounce::refuses_session_switch(self) { + return Err(self.ui.status.clone()); + } if let Some(r) = menus::run_ui_command(self, id, ¶ms) { return r; } @@ -356,8 +406,12 @@ impl SoundApp { // Third-party plugins keep state the session can't see: store it first. self.capture_plugin_states(); } + let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert"); match self.engine.execute(id, ¶ms) { Ok(v) => { + if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) { + self.release_autosave(false); + } if id == "app.quit" { self.quit_requested = true; } @@ -382,6 +436,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 } @@ -396,6 +457,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. @@ -448,14 +510,24 @@ 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(); - if let Err(err) = self.arm_recorder() { - self.ui.status = format!("Cannot record: {err}"); - return; - } + let takes = match self.arm_recorder() { + Ok(takes) => takes, + Err(err) => { + self.ui.status = format!("Cannot record: {err}"); + return; + } + }; + self.lost_shown = 0; if let Some(p) = &self.player { p.set_recording(true); } @@ -465,6 +537,7 @@ impl SoundApp { // Punch in on the fly at the current playhead. self.record_start = self.position(); self.punch = None; + self.plan_recording(takes, None); return; } let e = self.engine.session().edit.clone(); @@ -473,6 +546,7 @@ impl SoundApp { self.record_start = sel.start; self.punch = None; self.start_play(sel.start, None, Some(sel)); + self.plan_recording(takes, Some(sel.len())); } else { // Record into the selection when there is one (punch), with pre/post-roll if enabled; // the take is trimmed back to the punch range afterwards. @@ -482,12 +556,23 @@ impl SoundApp { self.punch = (!sel.is_empty()).then_some(sel); let end = (!sel.is_empty()).then_some(sel.end + post); self.start_play(from, end, None); + self.plan_recording(takes, None); + } + } + + /// Remember where the armed `takes` go (split into passes of `pass` when loop recording) and + /// autosave that plan with the session. + fn plan_recording(&mut self, takes: Vec, pass: Option) { + let rate = self.recorder.as_ref().map_or(0, |r| r.sample_rate); + self.take_plan = Some(soundcraft_engine::io::Recording { start: self.record_start, pass, punch: self.punch, rate, takes }); + if let Some(root) = self.autosave_dir.clone() { + self.queue_autosave(root); } } /// Arm the input for the record-enabled tracks; a session never saved records into its - /// autosave folder. - fn arm_recorder(&mut self) -> Result<(), String> { + /// autosave folder. Returns the takes it records. + fn arm_recorder(&mut self) -> Result, String> { let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name)); let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) }; @@ -495,11 +580,16 @@ impl SoundApp { if plan.is_empty() { return Err("no audio track is record-enabled".into()); } - r.arm(plan) + let takes = plan + .iter() + .map(|(track, inputs, path)| soundcraft_engine::io::Take { track: *track, path: path.clone(), channels: inputs.len() }) + .collect(); + r.arm(plan)?; + Ok(takes) } - /// Reads every live third-party plugin's state from the audio engine and stores it on its - /// insert (`mix.insert_state`), so saves and bounces include it. + /// Reads every live third-party plugin's state and stores it on its insert + /// (`mix.insert_state`), so saves and bounces include it. pub fn capture_plugin_states(&mut self) { let Some(p) = &self.player else { return }; for (t, slot, data) in p.capture_states() { @@ -533,14 +623,108 @@ impl SoundApp { } self.last_autosave = Some(std::time::Instant::now()); self.capture_plugin_states(); - let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name); - let path = dir.join(&name).join(format!("{name}.scraft")); - if let Some(parent) = path.parent() { - let _ = std::fs::create_dir_all(parent); + self.autosave_to(&dir); + } + + /// Autosave a modified session within a second of every change, also while recording. The + /// plugins' own state reaches the session every two minutes (`autosave`). + fn autosave_changes(&mut self) { + if let Some(e) = self.autosaver.as_ref().and_then(soundcraft_engine::autosave::Autosaver::failure) { + self.ui.status = format!("Autosave failed: {e}"); } - match soundcraft_engine::io::save_copy(&self.engine, &path.to_string_lossy()) { - Ok(_) => log::info!("autosaved to {}", path.display()), - Err(e) => log::warn!("autosave failed: {e}"), + if self.autosave_dir.is_none() || self.engine.revision == self.autosaved_rev { + return; + } + self.autosaved_rev = self.engine.revision; + if !self.engine.is_dirty() { + self.release_autosave(false); + } else if let Some(root) = self.autosave_dir.clone() { + self.queue_autosave(root); + } + } + + /// Hand the session to the autosaver for its folder under `root`. + fn queue_autosave(&mut self, root: std::path::PathBuf) { + self.autosaved_rev = self.engine.revision; + let w = self.autosaver.get_or_insert_with(|| soundcraft_engine::autosave::Autosaver::new(root)); + w.write(self.engine.session_arc(), self.engine.path.clone(), self.take_plan.clone()); + } + + /// Write the session to its own folder under `root` and wait for it; false when that failed. + fn autosave_to(&mut self, root: &std::path::Path) -> bool { + self.queue_autosave(root.to_path_buf()); + let Some(w) = &self.autosaver else { return false }; + let written = w.flush(); + if let Err(e) = &written { + self.ui.status = format!("Autosave failed: {e}"); + } + written.is_ok() + } + + /// Autosave now, if the app has an autosave folder. + fn write_autosave(&mut self) -> bool { + self.autosave_dir.clone().is_some_and(|root| self.autosave_to(&root)) + } + + /// Done with this session's autosave folder: keep it for the next launch, or delete it. + fn release_autosave(&mut self, keep: bool) { + if let Some(w) = &self.autosaver { + w.close(keep); + } + } + + /// Done with the open session. Unsaved changes stay in its autosave folder for the next + /// launch, unless the user quit with SoundCraft's Quit; a take still recording is finished + /// and always stays. + pub fn close_autosave(&mut self) { + let recording = self.engine.transport.recording; + if recording { + self.stop_play(); + } + let keep = self.engine.is_dirty() && (recording || !self.quit_requested); + if keep { + self.capture_plugin_states(); + self.write_autosave(); + } + self.release_autosave(keep); + if let Some(w) = &self.autosaver { + let _ = w.flush(); + } + } + + /// Run part of a frame. If it panics, the session is autosaved (once per change) and the app + /// carries on instead of quitting. + pub fn guarded(&mut self, f: impl FnOnce(&mut SoundApp)) { + if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))).is_ok() || self.panic_rev == self.engine.revision { + return; + } + self.panic_rev = self.engine.revision; + let saved = self.engine.is_dirty() && self.write_autosave(); + self.ui.status = if saved { + "SoundCraft hit an internal error (see the log). Your changes were autosaved: save them, then restart SoundCraft.".into() + } else { + "SoundCraft hit an internal error (see the log). Restart SoundCraft to be safe.".into() + }; + } + + /// Replace the open session with unsaved work an earlier run left behind. + pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) { + if self.engine.transport.recording { + self.ui.status = "Stop recording before recovering other work.".into(); + return; + } + self.close_autosave(); + match soundcraft_engine::autosave::recover(&mut self.engine, left) { + Ok(missing) => { + if self.write_autosave() { + soundcraft_engine::autosave::discard(left); + } + self.ui.status = match missing.len() { + 0 => format!("Recovered “{}”. Save it to keep the changes.", left.name), + n => format!("Recovered “{}”, but {n} audio file(s) are missing. Save it to keep the changes.", left.name), + }; + } + Err(e) => self.ui.status = format!("Couldn't recover “{}”: {e}", left.name), } } @@ -587,28 +771,96 @@ impl SoundApp { } } + /// Stop the transport, keeping a take in progress, when the audio output fails. + fn watch_output(&mut self) { + let Some(p) = &self.player else { return }; + let Some(trouble) = p.trouble() else { return }; + let name = p.device_name.clone(); + let (cause, then) = match trouble { + OutputTrouble::Bug => ( + "the audio engine hit an internal error (see the log)".to_string(), + "Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.", + ), + OutputTrouble::Gone => { + (format!("“{name}” was disconnected"), "Plug it back in, then use Reconnect audio device in Setup ▸ Playback Engine.") + } + OutputTrouble::Stalled => (format!("“{name}” stopped asking for audio"), "Use Reconnect audio device in Setup ▸ Playback Engine."), + }; + let (recording, playing) = (self.engine.transport.recording, self.engine.transport.playing); + if recording || playing { + self.stop_play(); + } + self.ui.status = if recording { + format!("Recording stopped: {cause}. The take is kept up to there. {then}") + } else if playing { + format!("Playback stopped: {cause}. {then}") + } else { + format!("Audio output: {cause}. {then}") + }; + } + + /// Stop recording, keeping the take, when the input device or the disk fails, and say as soon + /// as a slow disk loses input. + fn watch_input(&mut self) { + let recording = self.engine.transport.recording; + let Some(r) = &mut self.recorder else { return }; + let Some(trouble) = r.trouble() else { + let lost = r.lost_ms(); + if recording && lost > self.lost_shown { + self.lost_shown = lost; + self.ui.status = format!("The disk is too slow: this take has lost {lost} ms of input so far."); + } + return; + }; + if recording { + self.stop_play(); + } + let (what, then) = match trouble { + Trouble::Device(what) => { + self.recorder = None; + self.recorder_failed = true; + (what, "Reconnect it, then use Reconnect audio device in Setup ▸ Playback Engine.") + } + Trouble::Disk(what) => (what, "Check the disk, then record again."), + }; + self.ui.status = if recording { + format!("Recording stopped: {what}. The take is kept up to there. {then}") + } else { + format!("Audio input: {what}. {then}") + }; + } + + /// Reopen the audio devices, keeping a take in progress. + pub fn reconnect_audio(&mut self) { + if self.is_playing() || self.engine.transport.recording { + self.stop_play(); + } + self.recorder = None; + self.recorder_failed = false; + self.player = Some(Player::new(self.engine.session_arc())); + } + fn finish_recording(&mut self) { self.engine.transport.recording = false; if let Some(p) = &self.player { p.set_recording(false); } + let plan = self.take_plan.take(); let Some(r) = &mut self.recorder else { return }; let (takes, problem) = r.finish(); let tracks = takes.len().max(1); - let sel = self.engine.session().edit.selection; - let looped = self.engine.session().edit.loop_record && !sel.is_empty(); - let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) }; - let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass); - if let Some(p) = self.punch.take() { - // Keep only the punch range of each new take (merged into the Record undo step). - let clip_ids: Vec = ids.iter().map(|c| c.0).collect(); - let _ = self.engine.execute_merged( - "edit.trim_to_fill_selection", - &serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}), - "record", - ); - } - self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; + let plan = plan.unwrap_or_else(|| soundcraft_engine::io::Recording { + start: self.record_start, + pass: None, + punch: self.punch, + rate: 0, + takes: Vec::new(), + }); + self.punch = None; + self.autosaved_rev = 0; + let ids = soundcraft_engine::io::place_recording(&mut self.engine, &plan, takes); + self.ui.status = + if plan.pass.is_some() { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) }; if let Some(p) = problem { log::warn!("recording: {p}"); self.ui.status = format!("{} ({p})", self.ui.status); @@ -616,23 +868,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), @@ -657,11 +912,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(); } } @@ -731,6 +990,7 @@ impl SoundApp { if let Some(sim) = &mut self.sim { sim.0 = at; } + self.last_write_at = i64::MIN; } self.engine.transport.position = at; } @@ -768,6 +1028,12 @@ impl SoundApp { for (id, m) in &snap.tracks { let d = self.meters.entry(*id).or_default(); feed_meter(d, m.peak, m.gain_reduction_db, dt); + if m.plugin_failures.count != d.plugin_failures { + d.plugin_failures = m.plugin_failures.count; + if let Some(msg) = failed_plugin_status(self.engine.session(), *id, m.plugin_failures.last) { + self.ui.status = msg; + } + } } let main = snap.main.peak; feed_meter(&mut self.main_meter, main, 0.0, dt); @@ -822,6 +1088,7 @@ impl SoundApp { control::drain(self, ctx); self.note_recent_session(); self.apply_editor_edits(); + bounce::step(self, ctx); if !ctx.input(|i| i.pointer.any_down()) { if matches!(self.gesture, Some(Gesture::Fader { .. })) { self.gesture = None; @@ -831,6 +1098,8 @@ impl SoundApp { self.engine.end_merge(); } } + self.watch_output(); + self.watch_input(); // Stop the old playback before sending a replacement session to the audio thread. self.handle_transport(dt); if self.engine.revision != self.last_rev { @@ -839,6 +1108,7 @@ impl SoundApp { p.update_session(self.engine.session_arc()); } } + self.autosave_changes(); self.write_automation(ctx); self.autosave(); if self.recorder.is_none() @@ -913,6 +1183,7 @@ impl SoundApp { video_window::show(self, &ctx); ops_windows::show(self, &ctx); extra_windows::show(self, &ctx); + recovery::show(self, &ctx); score_editor::show(self, &ctx); palette::show(self, &ctx); dialogs::show(self, &ctx); @@ -970,6 +1241,15 @@ fn feed_meter(d: &mut MeterDisplay, peak: [f32; 2], gr: f32, dt: f32) { d.gr = if gr > d.gr { gr } else { d.gr * fall }; } +/// The status line naming the plugin in `slot` (an insert index or +/// [`soundcraft_mix::INSTRUMENT_SLOT`]), which the engine took out of the signal path. +fn failed_plugin_status(s: &soundcraft_model::Session, track: TrackId, slot: usize) -> Option { + let t = s.track(track)?; + let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { t.instrument.as_ref() } else { t.mixer.inserts.get(slot)?.as_ref() }?; + let name = crate::mix_window::plugin_info(&ins.plugin).map_or(ins.plugin.as_str(), |p| p.name); + Some(format!("{name} on {} put out invalid audio, so it is bypassed until playback stops or starts again.", t.name)) +} + #[cfg(test)] mod tests { use super::{Services, SoundApp, automation_step_due}; @@ -1002,6 +1282,18 @@ mod tests { } } + #[test] + fn the_status_names_a_plugin_the_engine_took_out() { + use soundcraft_model::{ChannelFormat, Insert, TrackKind}; + let mut s = soundcraft_model::Session::default(); + let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Vox")); + s.track_mut(t).unwrap().mixer.inserts[3] = Some(Insert::new("gain")); + let msg = super::failed_plugin_status(&s, t, 3).unwrap(); + assert!(msg.starts_with("Gain on Vox put out invalid audio"), "{msg}"); + assert_eq!(super::failed_plugin_status(&s, t, 0), None); + assert_eq!(super::failed_plugin_status(&s, t, soundcraft_mix::INSTRUMENT_SLOT), None); + } + #[test] fn new_session_cancels_play_queued_before_the_next_frame() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); @@ -1012,6 +1304,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())); @@ -1074,6 +1389,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 f425161..c62ffce 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); } @@ -292,7 +298,7 @@ pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) { true } - Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()), + Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)), None => false, }; (enabled, checked_state(app, path, id)) @@ -493,7 +499,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!({}) @@ -560,6 +570,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); } @@ -66,7 +66,24 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) { ui.painter().rect_filled(header, 0.0, t.panel_bg2); ui.painter().text(pos2(header.min.x + 8.0, header.center().y), Align2::LEFT_CENTER, "MIDI EDITOR", bold(11.5), t.header_text); let Some(cid) = target_clip(app) else { - ui.painter().text(full.center(), Align2::CENTER_CENTER, "Select a MIDI clip (or a MIDI track) to edit its notes.", regular(13.0), t.text_dim); + let track = { + let s = app.engine.session(); + s.tracks.iter().find(|t| t.kind.is_midi() && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.name.clone())) + }; + let msg = match &track { + Some((_, name)) => format!("Click to add a MIDI clip to {name} at the insertion point."), + None => "Select a MIDI clip (or a MIDI track) to edit its notes.".into(), + }; + ui.painter().text(full.center(), Align2::CENTER_CENTER, msg, regular(13.0), t.text_dim); + let body = Rect::from_min_max(pos2(full.min.x, header.max.y), full.max); + if let Some((id, _)) = track + && ui.interact(body, ui.id().with("new_clip"), Sense::click()).clicked() + { + let sel = app.engine.session().edit.selection; + if let Ok(v) = app.run("midi.new_clip", json!({"track": id.0, "start": sel.start, "end": sel.end})) { + let _ = app.run("edit.select", json!({"clips": [v["clip"]]})); + } + } return; }; let Some((track, clip)) = app.engine.session().find_clip(cid).map(|(t, c)| (t, c.clone())) else { return }; @@ -84,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))); @@ -99,29 +129,16 @@ 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]); let _ = app.run(id, p); } - let s = app.engine.session().clone(); + let s = app.engine.session_arc(); 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)); @@ -300,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, ) }); @@ -330,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..ced3ca2 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,15 +110,28 @@ 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 }; + let session = app.engine.session_arc(); + let Some(track) = session.track(id) else { return }; let narrow = app.ui.narrow_mix; let w = strip_width(narrow); let h = ui.available_height().max(560.0); let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover()); let selected = app.engine.session().edit.selected_tracks.contains(&id); - ui.painter().rect_filled(r, 0.0, if selected { 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 +144,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,13 +162,13 @@ 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))); match slot { - _ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, &track, sr), - Some(k) => insert_slot(app, ui, &track, k, sr), + _ if track.kind == TrackKind::Instrument => instrument_slot(app, ui, track, sr), + Some(k) => insert_slot(app, ui, track, k, sr), None if track.kind == TrackKind::Midi => { ui.painter().text(sr.center(), Align2::CENTER_CENTER, "MIDI in: all", regular(9.0), t.text_dim); } @@ -163,14 +198,14 @@ 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)); if is_send { - send_slot(app, ui, &track, off + k, sr); + send_slot(app, ui, track, off + k, sr); } else { - insert_slot(app, ui, &track, off + k, sr); + insert_slot(app, ui, track, off + k, sr); } } y = sec.max.y + 4.0; @@ -208,25 +243,26 @@ 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); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Input")); + 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)); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, "Output")); + 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,13 +294,14 @@ 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); y += 22.0; // Pan: a surround panner when the output is multichannel, else the stereo knobs. - let out_fmt = output_format(app, &track); + let out_fmt = output_format(app, track); let surround_out = out_fmt.channels() > 2 && !out_fmt.is_ambisonic() && !matches!(track.kind, TrackKind::Master | TrackKind::Vca) && track.channels() <= 2; if surround_out { @@ -292,7 +329,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 +338,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 +408,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 +416,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 +448,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,68 +457,144 @@ 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); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Instrument")); + 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)); } let menu_resp = if hosted { resp.clone().on_hover_text("Click: open instrument · right-click: change") } else { resp.clone() }; let popup = if hosted { egui::Popup::context_menu(&menu_resp) } else { egui::Popup::menu(&menu_resp) }; + let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); 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); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Insert {}", (b'A' + slot as u8) as char))); + 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) }; + let popup = popup.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some())); } +/// An insert's menu: a search field that takes typing at once, then (until something is typed) +/// Bypass and removal and the plugins by category and format. pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, occupied: bool) { + let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…"); + if search.typing() { + if let Some(plugin) = plugin_search(ui, &search, false) { + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})); + } + return; + } if occupied { if ui.button("Bypass").clicked() { - let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot})); + run_pick(app, ui, "mix.insert_bypass", json!({"track": id.0, "slot": slot})); } if ui.button("no insert").clicked() { - let _ = app.run("mix.insert_remove", json!({"track": id.0, "slot": slot})); + run_pick(app, ui, "mix.insert_remove", json!({"track": id.0, "slot": slot})); } ui.separator(); } @@ -492,22 +608,28 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc ui.menu_button(format!("{c:?}"), |ui| { for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) { if ui.button(p.name).clicked() { - let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id})); } } }); } - if let Some(plugin) = hosted_menus(ui, false) - && let Err(e) = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) - { - app.ui.status = e; + if let Some(plugin) = hosted_menus(ui, false) { + run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})); } } -/// The instrument picker of an instrument track: its instrument's editor, then the built-in, -/// CLAP, VST3 and Audio Units instruments. +/// The instrument picker of an instrument track: a search field that takes typing at once, then +/// (until something is typed) its instrument's editor and the built-in, CLAP, VST3 and Audio Units +/// instruments. pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { let slot = soundcraft_mix::INSTRUMENT_SLOT; + let search = MenuSearch::show(ui, egui::Id::new(("instrument-search", id.0)), "Search instruments…"); + if search.typing() { + if let Some(plugin) = plugin_search(ui, &search, true) { + run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); + } + return; + } let current = app.engine.session().track(id).and_then(|t| t.instrument.clone()); if let Some(info) = current.as_ref().and_then(|i| plugin_info(&i.plugin)) { ui.label(egui::RichText::new(info.name).strong()); @@ -517,11 +639,13 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { if p.editor_open(id, slot) { if ui.button("Close Plugin Editor").clicked() { p.close_editor(id, slot); + ui.close(); } - } else if ui.button("Open Plugin Editor").clicked() - && let Err(e) = p.open_editor(id, slot) - { - app.ui.status = format!("{}: {e}", info.name); + } else if ui.button("Open Plugin Editor").clicked() { + if let Err(e) = p.open_editor(id, slot) { + app.ui.status = format!("{}: {e}", info.name); + } + ui.close(); } } ui.separator(); @@ -537,11 +661,136 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) { if let Some(p) = hosted_menus(ui, true) { picked = Some(p); } - if let Some(plugin) = picked - && let Err(e) = app.run("mix.instrument", json!({"track": id.0, "plugin": plugin})) - { + if let Some(plugin) = picked { + run_pick(app, ui, "mix.instrument", json!({"track": id.0, "plugin": plugin})); + } +} + +/// Runs a menu's pick, shows its error in the status line and closes the menu. +fn run_pick(app: &mut SoundApp, ui: &Ui, cmd: &str, params: serde_json::Value) { + if let Err(e) = app.run(cmd, params) { app.ui.status = e; } + ui.close(); +} + +/// The plugins whose name or vendor holds the search text: the built-in ones, then Audio Units +/// (listing them creates no instances) and whichever of CLAP and VST3 have been scanned, with a +/// button to scan each of the others. Returns the id picked. +fn plugin_search(ui: &mut Ui, search: &MenuSearch, instruments: bool) -> Option { + let mut built_in: Vec<&soundcraft_dsp::PluginInfo> = + soundcraft_dsp::plugins().iter().copied().filter(|p| p.is_instrument == instruments && search.matches(p.name)).collect(); + built_in.sort_by_cached_key(|p| p.name.to_lowercase()); + let (clap, vst3) = (soundcraft_clap_host::cached(), soundcraft_vst3_host::cached()); + let mut hosted: Vec<(MenuPlugin, &str)> = Vec::new(); + for d in clap.iter().flatten().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id.clone(), name: d.name.clone(), vendor: d.vendor.clone() }, "CLAP")); + } + for d in vst3.iter().flatten().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id.clone(), name: d.name.clone(), vendor: d.vendor.clone() }, "VST3")); + } + for d in soundcraft_au_host::scan().into_iter().filter(|d| d.is_instrument == instruments) { + hosted.push((MenuPlugin { id: d.id, name: d.name, vendor: d.vendor }, "AU")); + } + hosted.retain(|(p, _)| search.matches(&p.name) || search.matches(&p.vendor)); + hosted.sort_by_cached_key(|(p, format)| (p.name.to_lowercase(), *format)); + let n = built_in.len() + hosted.len(); + let picked = search.rows(ui, n, 320.0, |ui, i, highlighted| match built_in.get(i) { + Some(p) => menu_row(ui, highlighted, p.name), + None => { + let (p, format) = &hosted[i - built_in.len()]; + let label = if p.vendor.is_empty() { format!("{} ({format})", p.name) } else { format!("{} — {} ({format})", p.name, p.vendor) }; + menu_row(ui, highlighted, &label) + } + }); + if n == 0 { + ui.weak("No matches"); + } + if clap.is_none() || vst3.is_none() { + ui.separator(); + ui.horizontal(|ui| { + if clap.is_none() && ui.small_button("Scan CLAP").clicked() { + soundcraft_clap_host::scan(); + } + if vst3.is_none() && ui.small_button("Scan VST3").clicked() { + soundcraft_vst3_host::scan(); + } + }); + } + picked.map(|i| built_in.get(i).map_or_else(|| hosted[i - built_in.len()].0.id.clone(), |p| p.id.to_string())) +} + +/// A picker menu's search field. It has the keyboard as soon as the menu opens and starts empty +/// each time; Up/Down move the highlight through the matches and Enter picks it. +struct MenuSearch { + id: egui::Id, + query: String, + highlight: usize, + keys: [bool; 3], +} + +impl MenuSearch { + fn show(ui: &mut Ui, id: egui::Id, hint: &str) -> Self { + let pass = ui.ctx().cumulative_pass_nr(); + // Kept only while the menu stays open: one not shown last pass has just opened. + let kept = ui.data(|d| d.get_temp::<(String, usize, u64)>(id)).filter(|s| s.2 + 1 >= pass); + let opened = kept.is_none(); + let (mut text, mut highlight, _) = kept.unwrap_or_default(); + let field = ui.add(egui::TextEdit::singleline(&mut text).id(id.with("field")).hint_text(hint).return_key(None)); + if opened { + field.request_focus(); + } + if field.changed() { + highlight = 0; + } + // egui's focus, as typing uses: `has_focus` also wants the window focused, which scripted input lacks. + let focused = ui.memory(|m| m.has_focus(field.id)); + let keys = [egui::Key::ArrowUp, egui::Key::ArrowDown, egui::Key::Enter].map(|k| focused && ui.input(|i| i.key_pressed(k))); + ui.data_mut(|d| d.insert_temp(id, (text.clone(), highlight, pass))); + Self { id, query: text.trim().to_lowercase(), highlight, keys } + } + + fn typing(&self) -> bool { + !self.query.is_empty() + } + + fn matches(&self, text: &str) -> bool { + text.to_lowercase().contains(&self.query) + } + + /// `n` matches in a scrolling list, each drawn by `row` (told whether it is highlighted); + /// returns the one clicked, or the highlighted one on Enter. + fn rows(&self, ui: &mut Ui, n: usize, max_height: f32, mut row: impl FnMut(&mut Ui, usize, bool) -> bool) -> Option { + let [up, down, enter] = self.keys; + let typing = self.typing(); + let highlight = match (up, down) { + (_, true) => self.highlight + 1, + (true, _) => self.highlight.saturating_sub(1), + _ => self.highlight, + } + .min(n.saturating_sub(1)); + ui.data_mut(|d| d.get_temp_mut_or_default::<(String, usize, u64)>(self.id).1 = highlight); + let mut picked = (typing && enter && n > 0).then_some(highlight); + let row_h = ui.spacing().interact_size.y; + let step = row_h + ui.spacing().item_spacing.y; + egui::ScrollArea::vertical().id_salt(self.id.with("rows")).max_height(max_height).show_rows(ui, row_h, n, |ui, range| { + if typing && (up || down) { + let top = ui.max_rect().top() + (highlight as f32 - range.start as f32) * step; + ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(1.0, row_h)), None); + } + for i in range { + if row(ui, i, typing && i == highlight) { + picked = Some(i); + } + } + }); + picked + } +} + +fn menu_row(ui: &mut Ui, selected: bool, text: &str) -> bool { + let text = if selected { egui::RichText::new(text).color(Color32::WHITE) } else { egui::RichText::new(text) }; + ui.add(egui::Button::selectable(selected, text).min_size(vec2(0.0, ui.spacing().interact_size.y))).clicked() } /// A hosted plugin as the plugin menus list it. @@ -616,67 +865,53 @@ 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()); + route_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {}", (b'A' + slot as u8) as char))); + 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 +939,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 +1062,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 +1079,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 +1087,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 +1103,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 +1120,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 +1163,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)); @@ -857,26 +1183,16 @@ fn send_level_scale(ui: &mut Ui, db: f32) { fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) { let busses = app.engine.session().busses.clone(); - let search_id = egui::Id::new(("send-bus-search", id.0, slot)); - let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); - ui.data_mut(|d| d.insert_temp(search_id, search.clone())); - let needle = search.trim().to_ascii_lowercase(); - let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect(); - egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows( - ui, - 22.0, - visible.len(), - |ui, rows| { - for bus in &visible[rows] { - let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id); - if ui.selectable_label(selected, &bus.name).clicked() { - let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name})); - ui.close(); - } - } - }, - ); + let search = MenuSearch::show(ui, egui::Id::new(("send-bus-search", id.0, slot)), "Search buses…"); + let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect(); + let picked = search.rows(ui, visible.len(), 260.0, |ui, i, highlighted| { + let current = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == visible[i].id); + menu_row(ui, highlighted || (current && !search.typing()), &visible[i].name) + }); + if let Some(i) = picked { + let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": visible[i].name})); + ui.close(); + } ui.separator(); if ui.button("new bus...").clicked() { let mut n = busses.len() + 1; @@ -911,19 +1227,12 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) { ui.separator(); let busses: Vec = app.engine.session().busses.iter().map(|b| b.name.clone()).collect(); ui.strong("Bus"); - let search_id = egui::Id::new(("route-bus-search", id.0, input)); - let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default(); - ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…")); - ui.data_mut(|d| d.insert_temp(search_id, search.clone())); - let needle = search.trim().to_ascii_lowercase(); - egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| { - for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) { - if ui.button(b).clicked() { - let _ = app.run(cmd, json!({"track": id.0, key: b})); - ui.close(); - } - } - }); + let search = MenuSearch::show(ui, egui::Id::new(("route-bus-search", id.0, input)), "Search buses…"); + let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect(); + if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) { + let _ = app.run(cmd, json!({"track": id.0, key: visible[i]})); + ui.close(); + } if ui.button("new bus...").clicked() { let mut n = busses.len() + 1; while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() { @@ -936,7 +1245,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 +1266,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/recovery.rs b/crates/ui-egui/src/recovery.rs new file mode 100644 index 0000000..bd66e29 --- /dev/null +++ b/crates/ui-egui/src/recovery.rs @@ -0,0 +1,50 @@ +//! The offer to recover unsaved work an earlier run left in the autosave folder. + +use crate::SoundApp; +use crate::theme::{Tokens, bold}; +use egui::{Align2, vec2}; + +/// Offer the newest leftover autosave, one at a time. +pub fn show(app: &mut SoundApp, ctx: &egui::Context) { + let Some(left) = app.leftovers.first().cloned() else { return }; + let mut open = true; + let mut recover = None; + egui::Window::new(egui::RichText::new("Recover Unsaved Work").font(bold(13.0))) + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0)) + .show(ctx, |ui| { + let age = left.saved.elapsed().map_or_else(|_| "recently".into(), |d| ago(d.as_secs())); + let what = if left.recording { "unsaved changes and an unfinished recording" } else { "unsaved changes" }; + ui.label(format!("“{}” has {what} from an earlier run, autosaved {age}.", left.name)); + ui.separator(); + ui.horizontal(|ui| { + if ui.button("Discard").clicked() { + recover = Some(false); + } + let recover_button = egui::Button::new(egui::RichText::new("Recover").strong()).fill(Tokens::current().accent); + if ui.add_enabled(!app.engine.transport.recording, recover_button).on_disabled_hover_text("Stop recording first").clicked() { + recover = Some(true); + } + }); + }); + if !open || recover.is_some() { + app.leftovers.retain(|l| *l != left); + } + match recover { + Some(true) => app.recover(&left), + Some(false) => soundcraft_engine::autosave::discard(&left), + None => {} + } +} + +fn ago(secs: u64) -> String { + let (n, unit) = match secs { + 0..60 => return "less than a minute ago".into(), + 60..3_600 => (secs / 60, "minute"), + 3_600..86_400 => (secs / 3_600, "hour"), + _ => (secs / 86_400, "day"), + }; + format!("{n} {unit}{} ago", if n == 1 { "" } else { "s" }) +} diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 4d4417b..f5b6156 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,14 +122,32 @@ 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", - Key::F2 => "slip", - Key::F3 => "spot", - _ => "grid", + edit_mode_key(app, key); + true + } + Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 if m.alt && !m.command && !m.ctrl && !m.shift => { + let f = match key { + Key::Num1 => Key::F1, + Key::Num2 => Key::F2, + Key::Num3 => Key::F3, + _ => Key::F4, }; - let _ = app.run("edit.mode", json!({"mode": mode})); + edit_mode_key(app, f); + true + } + Key::Backtick if plain => { + cycle_edit_mode(app); true } Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => { @@ -147,11 +167,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,9 +196,31 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } 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!({})); + // 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 + } + // Pro Tools' Control+Up/Down track height, on Ctrl off macOS too (its Start key isn't available there). + Key::ArrowUp | Key::ArrowDown if m.ctrl && (m.alt || !m.shift) => { + let s = app.engine.session(); + let tracks = crate::edit_window::height_targets(s, m, &s.edit.selected_tracks); + let id = if key == Key::ArrowUp { "track.height_up" } else { "track.height_down" }; + let _ = app.run(id, if tracks.is_empty() { json!({}) } else { json!({"tracks": tracks}) }); + true + } + // Extend Edit Down is Control+Shift+;, which macOS sends as Control+:. + Key::Semicolon | Key::Colon if mac && m.ctrl && m.shift && !m.alt && !m.command => { + let _ = app.run("edit.extend_selection_down", json!({})); true } // Ctrl+Shift+Up/Down nudge clip gain off macOS too (the registry's Ctrl is the Mac Control key). @@ -187,7 +230,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" }; @@ -198,19 +249,99 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } } +/// F1–F4 (and Alt+1–4) choose Shuffle, Slip, Spot and Grid; F4 in Grid switches between Absolute and +/// Relative unless Edit/Tool Mode Keyboard Lock is on. Holding F4 with F1–F3, in either order, adds Snap to Grid. +fn edit_mode_key(app: &mut SoundApp, pressed: Key) { + let ctx = app.docking_context.clone(); + let held = |k: Key| ctx.as_ref().is_some_and(|c| c.input(|i| i.key_down(k))); + let (key, snap) = match [Key::F1, Key::F2, Key::F3].into_iter().find(|k| *k != pressed && held(*k)) { + Some(k) if pressed == Key::F4 => (k, true), + _ => (pressed, pressed != Key::F4 && held(Key::F4)), + }; + let ed = &app.engine.session().edit; + let keep_grid = ed.edit_mode.is_grid() && ed.flag("options.edit_tool_mode_keyboard_lock"); + // F4 pressed first in an F4+F1–F3 chord switched the Grid mode on its own; put that back. + let flipped = egui::Id::new("edit_mode_key_grid_before_f4"); + let before = + (ed.edit_mode.is_grid() && !keep_grid).then(|| if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) { "relative_grid" } else { "grid" }); + let restore = match &ctx { + Some(c) if pressed == Key::F4 => { + c.data_mut(|d| d.insert_temp(flipped, before)); + None + } + Some(c) if snap => c.data_mut(|d| d.remove_temp::>(flipped)).flatten(), + _ => None, + }; + let mode = match key { + Key::F1 => "shuffle", + Key::F2 => "slip", + Key::F3 => "spot", + _ if keep_grid => return, + _ => grid_mode(app), + }; + if let Some(grid) = restore { + let _ = app.run("edit.mode", json!({"mode": grid})); + } + let _ = app.run("edit.mode", json!({"mode": mode, "snap_to_grid": snap})); +} + +/// What choosing Grid picks: in Grid the other Grid mode, from another mode the one used last. +pub(crate) fn grid_mode(app: &SoundApp) -> &'static str { + use soundcraft_model::EditMode; + let ed = &app.engine.session().edit; + match ed.edit_mode { + EditMode::Grid => "relative_grid", + EditMode::GridRelative => "grid", + _ if ed.flag(soundcraft_engine::edit::RELATIVE_GRID) => "relative_grid", + _ => "grid", + } +} + +/// ` steps through the Edit modes in F-key order, past a locked Shuffle. +fn cycle_edit_mode(app: &mut SoundApp) { + use soundcraft_model::EditMode; + let ed = &app.engine.session().edit; + let mode = match ed.edit_mode { + EditMode::Shuffle => "slip", + EditMode::Slip => "spot", + EditMode::Spot => grid_mode(app), + _ if ed.flag(soundcraft_engine::edit::SHUFFLE_LOCK) => "slip", + _ => "shuffle", + }; + let _ = app.run("edit.mode", json!({"mode": mode})); +} + /// 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; @@ -223,6 +354,10 @@ fn commands_focus(app: &mut SoundApp, key: Key) -> bool { tab_clip(app, key == Key::L); return true; } + Key::E => { + crate::edit_window::zoom_toggle(app); + return true; + } _ => return false, }; let _ = app.run(id, json!({})); @@ -255,18 +390,26 @@ 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 { - if mac || !s.split('+').any(|p| p == "Cmd") { + let has = |m: &str| s.split('+').any(|p| p == m); + if mac || (!has("Cmd") && !has("Ctrl")) || PC_CTRL_CHORDS.contains(&s) { return s.to_string(); } - if s.split('+').any(|p| p == "Ctrl") { + if has("Ctrl") { return String::new(); } s.split('+').map(|p| if p == "Cmd" { "Ctrl" } else { p }).collect::>().join("+") } +/// Mac Control chords that `fixed` also maps to Ctrl off macOS. +const PC_CTRL_CHORDS: [&str; 4] = ["Ctrl+Up", "Ctrl+Down", "Ctrl+Shift+Up", "Ctrl+Shift+Down"]; + #[cfg(test)] mod label_tests { use super::shortcut_label; @@ -280,6 +423,10 @@ mod label_tests { assert_eq!(shortcut_label("Shift+R", false), "Shift+R"); assert_eq!(shortcut_label("Cmd+Ctrl+S", false), ""); assert_eq!(shortcut_label("", false), ""); + assert_eq!(shortcut_label("Ctrl+Up", false), "Ctrl+Up"); + assert_eq!(shortcut_label("Ctrl+Shift+Down", false), "Ctrl+Shift+Down"); + assert_eq!(shortcut_label("Ctrl+Shift+P", false), ""); + assert_eq!(shortcut_label("Ctrl+Alt+L", false), ""); } #[test] diff --git a/crates/ui-egui/src/spot.rs b/crates/ui-egui/src/spot.rs new file mode 100644 index 0000000..c9b4102 --- /dev/null +++ b/crates/ui-egui/src/spot.rs @@ -0,0 +1,173 @@ +//! The Spot dialog: exact positions for clips in Spot mode. + +use crate::theme::mono; +use egui::{Id, RichText, TextEdit, Ui, vec2}; +use serde_json::{Value, json}; +use soundcraft_engine::cmd::MAX_POSITION; +use soundcraft_engine::edit::first_movable; +use soundcraft_model::{ClipId, Session, SourceId, TrackId}; +use soundcraft_time::{Samples, TimeFormat, format_length}; + +#[derive(Debug, Clone)] +pub enum Target { + /// Clips moving together; the fields follow the earliest. + Clips { clips: Vec, track: Option }, + /// The edge of a clip the Trim tool took. + Trim { clip: ClipId, start: bool }, + /// An audio file dropped from the Clip List. + Source { source: SourceId, track: TrackId }, +} + +#[derive(Debug, Clone)] +pub struct Spot { + pub target: Target, + pub start: Samples, + pub len: Samples, + sync: Samples, + stamp: Option, + format: TimeFormat, + text: [String; 3], + fresh: bool, +} + +const FIELDS: [&str; 3] = ["Start", "Sync Point", "End"]; + +impl Spot { + fn new(s: &Session, target: Target, start: Samples, len: Samples, sync: Samples, stamp: Option) -> Spot { + Spot { target, start, len, sync, stamp, format: s.edit.main_counter, text: Default::default(), fresh: true } + } + + /// Clicked or dragged clips, moved `delta` so far. + pub fn clips(s: &Session, clips: Vec, track: Option, delta: Samples) -> Option { + let c = first_movable(s, &clips)?; + let stamp = c.source().and_then(|id| s.source(id)).map(|src| recorded_at(s, src.time_reference).saturating_add(c.source_offset())); + Some(Spot::new(s, Target::Clips { clips, track }, c.start.saturating_add(delta), c.length, c.sync_point, stamp)) + } + + /// A clip's start or end edge, dragged to `to`. + pub fn trim(s: &Session, clip: ClipId, start: bool, to: Samples) -> Option { + let (_, c) = s.find_clip(clip)?; + let (a, b) = if start { (to.min(c.end().saturating_sub(1)), c.end()) } else { (c.start, to.max(c.start.saturating_add(1))) }; + Some(Spot::new(s, Target::Trim { clip, start }, a, b.saturating_sub(a), 0, None)) + } + + /// An audio file from the Clip List, dropped at `at`. + pub fn source(s: &Session, source: SourceId, track: TrackId, at: Samples) -> Option { + let src = s.source(source)?; + let len = i64::try_from(src.frames).unwrap_or(0); + Some(Spot::new(s, Target::Source { source, track }, at, len, 0, Some(recorded_at(s, src.time_reference)))) + } + + fn value(&self, field: usize) -> Samples { + match field { + 0 => self.start, + 1 => self.start.saturating_add(self.sync), + _ => self.start.saturating_add(self.len), + } + } + + fn set(&mut self, field: usize, v: Samples) { + match (&self.target, field) { + (Target::Trim { start: true, .. }, _) => { + self.len = self.start.saturating_add(self.len).saturating_sub(v); + self.start = v; + } + (Target::Trim { .. }, _) => self.len = v.saturating_sub(self.start), + (_, 0) => self.start = v, + (_, 1) => self.start = v.saturating_sub(self.sync), + _ => self.start = v.saturating_sub(self.len), + } + } + + fn editable(&self, field: usize) -> bool { + match self.target { + Target::Trim { start, .. } => field == if start { 0 } else { 2 }, + _ => true, + } + } + + /// The command OK runs; None when nothing would change. + pub fn command(&self, s: &Session) -> Option<(String, Value)> { + Some(match &self.target { + Target::Clips { clips, track } => { + let first = first_movable(s, clips)?.start; + let by = self.start.saturating_sub(first); + if by == 0 && track.is_none() { + return None; + } + let mut p = json!({"clips": clips.iter().map(|c| c.0).collect::>(), "by": by}); + if let Some(t) = track { + p["track"] = json!(t.0); + } + ("edit.move_clips".into(), p) + } + Target::Trim { clip, start: true } => ("edit.trim_clip".into(), json!({"clip": clip.0, "start": self.start})), + Target::Trim { clip, .. } => ("edit.trim_clip".into(), json!({"clip": clip.0, "end": self.value(2)})), + Target::Source { source, track } => ("clip.place_source".into(), json!({"source": source.0, "track": track.0, "at": self.start})), + }) + } +} + +/// Timeline position of a BWF time stamp (samples since midnight). +fn recorded_at(s: &Session, time_reference: u64) -> Samples { + i64::try_from(time_reference).unwrap_or(0).saturating_sub(s.timecode_start) +} + +/// The dialog's fields; the caller adds OK and Cancel. +pub fn fields(ui: &mut Ui, s: &Session, d: &mut Spot) { + let show = |at: Samples, f: TimeFormat| s.format_position(at, f); + egui::Grid::new("spot").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label("Time Scale"); + egui::ComboBox::from_id_salt("spot_scale").selected_text(d.format.label()).show_ui(ui, |ui| { + for f in TimeFormat::ALL { + ui.selectable_value(&mut d.format, f, f.label()); + } + }); + ui.end_row(); + for (i, label) in FIELDS.into_iter().enumerate() { + if i == 1 && matches!(d.target, Target::Trim { .. }) { + continue; + } + ui.label(label); + if d.editable(i) { + let id = Id::new(("spot_field", i)); + if !ui.memory(|m| m.has_focus(id)) { + d.text[i] = show(d.value(i), d.format); + } + let r = ui.add(TextEdit::singleline(&mut d.text[i]).id(id).font(mono(13.0)).desired_width(150.0)); + if r.changed() + && let Ok(v) = s.parse_position(&d.text[i], d.format) + && (-MAX_POSITION..=MAX_POSITION).contains(&v) + { + d.set(i, v); + } + if std::mem::take(&mut d.fresh) { + r.request_focus(); + select_all(ui, id, &d.text[i]); + } + } else { + ui.label(RichText::new(show(d.value(i), d.format)).font(mono(13.0))); + } + ui.end_row(); + } + ui.label("Duration"); + ui.label(RichText::new(format_length(d.start, d.len, d.format, s.sample_rate, &s.tempo, s.frame_rate)).font(mono(13.0))); + ui.end_row(); + if let Some(at) = d.stamp { + ui.label("Original Time Stamp"); + ui.horizontal(|ui| { + ui.label(RichText::new(show(at, TimeFormat::Timecode)).font(mono(13.0))); + if ui.button("Use").on_hover_text("Spot to where it was recorded").clicked() { + d.start = at; + } + }); + ui.end_row(); + } + }); +} + +fn select_all(ui: &Ui, id: Id, text: &str) { + let mut state = TextEdit::load_state(ui.ctx(), id).unwrap_or_default(); + state.cursor.set_char_range(Some(egui::text::CCursorRange::two(egui::text::CCursor::default(), egui::text::CCursor::new(text.chars().count())))); + state.store(ui.ctx(), id); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index c667e6f..0cf5b9b 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,23 +402,35 @@ 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; - let k = if selected { 0.95 } else { 0.62 }; - let body = Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); - let bar = Color32::from_rgb((f32::from(r) * 0.9) as u8, (f32::from(g) * 0.9) as u8, (f32::from(b) * 0.9) as u8); + let shade = |k: f32| Color32::from_rgb((f32::from(r) * k) as u8, (f32::from(g) * k) as u8, (f32::from(b) * k) as u8); + let tint = |k: f32| { + let t = |c: u8| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; + Color32::from_rgb(t(r), t(g), t(b)) + }; + let bar = shade(0.9); if is_light() { // A paler body and a waveform in a deep shade of the clip's own colour. - let tint = |c: u8, k: f32| (f32::from(c) + (255.0 - f32::from(c)) * k) as u8; - let k = if selected { 0.2 } else { 0.45 }; - let body = Color32::from_rgb(tint(r, k), tint(g, k), tint(b, k)); - let d = if selected { 0.22 } else { 0.32 }; - let wave = Color32::from_rgb((f32::from(r) * d) as u8, (f32::from(g) * d) as u8, (f32::from(b) * d) as u8); - return (body, bar, wave); + return if selected { (tint(0.2), bar, shade(0.22)) } else { (tint(0.45), bar, shade(0.32)) }; } - let wave = if selected { Color32::from_rgb(20, 20, 24) } else { Color32::from_rgb(12, 12, 14) }; - (body, bar, wave) + // A dark body and a light waveform in the clip's own colour; selection inverts them. + if selected { (shade(0.7), bar, Color32::from_rgb(16, 16, 18)) } else { (shade(0.34), bar, tint(0.35)) } } pub fn rgb(c: [u8; 3]) -> Color32 { @@ -415,6 +485,27 @@ 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); + // Other tests compete for the lock too, and some hold it for seconds: allow for a queue. + assert!(rx.recv_timeout(std::time::Duration::from_secs(120)).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 +515,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..b6ed900 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -1,12 +1,13 @@ //! 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_engine::edit::{SHUFFLE_LOCK, SNAP_TO_GRID}; 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,55 +24,90 @@ 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 ed = &app.engine.session().edit; + let (cur, snap, locked) = (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)); + let cmd = crate::shortcuts::shortcut_label("Cmd", ui.ctx().os() == egui::os::OperatingSystem::Mac); + 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 }); - 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() { - let id = match m { - EditMode::Grid if cur == EditMode::Grid => "relative_grid", - EditMode::Shuffle => "shuffle", - EditMode::Spot => "spot", - EditMode::Slip => "slip", - _ => "grid", - }; - let _ = app.run("edit.mode", json!({"mode": id})); + key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off }); + let fg = if on { t.mode_on_text } else { t.mode_off_text }; + if m == EditMode::Grid && snap { + ui.painter().rect_stroke(cell, 1.0, Stroke::new(1.5, t.mode_on), StrokeKind::Inside); } - resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase())); + if m == EditMode::Shuffle && locked { + icons::draw(ui.painter(), Rect::from_center_size(cell.center(), vec2(15.0, 15.0)), "lock", fg); + } else { + 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), fg); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::RadioButton, true, on, label)); + if resp.clicked() { + mode_click(app, m, ui.input(|i| i.modifiers)); + } + resp.on_hover_text(match m { + EditMode::Shuffle if locked => format!("Shuffle is locked ({cmd}-click to unlock)"), + EditMode::Shuffle => format!("Shuffle mode ({fkey}); {cmd}-click locks it out"), + EditMode::Grid => format!("Grid mode ({fkey}; again for Relative); Shift-click: Snap to Grid in the other modes"), + _ => format!("{} mode ({fkey}); Shift-click adds Snap to Grid", if m == EditMode::Slip { "Slip" } else { "Spot" }), + }); } }); } +/// Shift adds Snap to Grid to Shuffle, Slip and Spot (on Grid it toggles it for the current mode); +/// Cmd/Ctrl on Shuffle toggles Shuffle Lock. +fn mode_click(app: &mut SoundApp, m: EditMode, mods: egui::Modifiers) { + let cur = app.engine.session().edit.edit_mode; + let _ = if m == EditMode::Shuffle && mods.command { + app.run("edit.shuffle_lock", json!({})) + } else if m == EditMode::Grid && mods.shift && !cur.is_grid() { + app.run("edit.snap_to_grid", json!({})) + } else { + let id = match m { + EditMode::Shuffle => "shuffle", + EditMode::Spot => "spot", + EditMode::Slip => "slip", + _ => crate::shortcuts::grid_mode(app), + }; + app.run("edit.mode", json!({"mode": id, "snap_to_grid": mods.shift})) + }; +} + fn zoom_group(app: &mut SoundApp, ui: &mut Ui) { group(ui, 128.0, 52.0, |ui, _| { ui.vertical(|ui| { @@ -121,6 +157,9 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { ui.spacing_mut().item_spacing = vec2(2.0, 3.0); let row = ui.horizontal(|ui| { ui.spacing_mut().item_spacing.x = 2.0; + if icon_button(ui, vec2(36.0, 26.0), "zoom_toggle", app.engine.session().edit.zoom_toggle.is_some(), "Zoom Toggle (E)").clicked() { + crate::edit_window::zoom_toggle(app); + } for (tool, icon) in [ (Tool::Zoom, "zoom"), (Tool::Trim, "trim"), @@ -138,7 +177,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) { }); // Smart tool bracket above Trim/Selector/Grabber. let rr = row.response.rect; - let bx0 = rr.min.x + 38.0; + let bx0 = rr.min.x + 76.0; let bx1 = bx0 + 38.0 * 3.0 - 2.0; let bracket = Rect::from_min_max(pos2(bx0 - 2.0, r.min.y + 1.0), pos2(bx1 + 2.0, r.min.y + 5.0)); let resp = ui.interact(bracket.expand2(vec2(0.0, 2.0)), ui.id().with("smart"), Sense::click()); @@ -173,7 +212,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 +267,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 +315,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 +324,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 +376,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 +387,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 +395,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/waveform.rs b/crates/ui-egui/src/waveform.rs new file mode 100644 index 0000000..0a2ccd4 --- /dev/null +++ b/crates/ui-egui/src/waveform.rs @@ -0,0 +1,209 @@ +//! Clip waveforms, drawn per physical pixel, or as a line through the samples at the sample level. + +use egui::{Color32, Mesh, Painter, Rect, Shape, Stroke, pos2, vec2}; +use soundcraft_model::{Clip, Session, SourceAudio}; +use soundcraft_time::Samples; + +/// Draw a clip's audio across `body`, one band per channel, `channels` of them at most. +pub fn draw(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, channels: usize, body: Rect, wave: Color32) { + for (ch, band) in bands(body, channels.min(audio.peaks.len()).max(1)).enumerate() { + channel(painter, s, tl, clip, audio, ch, band, wave); + } +} + +fn bands(body: Rect, n: usize) -> impl Iterator { + let h = body.height() / n as f32; + (0..n).map(move |k| Rect::from_min_size(pos2(body.min.x, body.min.y + h * k as f32), vec2(body.width(), h))) +} + +fn channel(painter: &Painter, s: &Session, tl: Rect, clip: &Clip, audio: &SourceAudio, ch: usize, band: Rect, wave: Color32) { + let (Some(samples), Some(peaks)) = (audio.channel(ch), audio.peaks.get(ch)) else { return }; + let Some(g) = Grid::new(painter, s, tl, band) else { return }; + let (rectified, power, outlines) = (s.edit.flag("waveform.rectified"), s.edit.flag("waveform.power"), s.edit.flag("waveform.outlines")); + let origin = clip.start.saturating_sub(clip.source_offset()) as f64; + let gain = |pos: f64| clip.gain_at((pos as Samples).saturating_sub(clip.start).min(clip.length).max(0)) * s.edit.zoom.waveform_zoom; + if g.step <= 1.0 { + let first = (g.pos_at(g.vis.min.x) - origin).floor().max(0.0) as usize; + let last = (g.pos_at(g.vis.max.x) - origin).ceil() as usize; + let from = first.saturating_sub(1); + let points = samples.get(from..samples.len().min(last.saturating_add(2))).unwrap_or_default().iter().zip(from..).map(|(&v, i)| { + let pos = origin + i as f64; + pos2(g.x_at(pos), g.cy - level(v * gain(pos)) * g.half) + }); + painter.add(Shape::line(points.collect(), Stroke::new(g.weight, wave))); + return; + } + let (start, overlap) = (g.start() - origin, g.overlap()); + let spans = if power { + peaks.rms_spans(samples, start, g.step, overlap, g.cols).into_iter().map(|r| r.map(|r| (-r, r))).collect() + } else { + peaks.spans(samples, start, g.step, overlap, g.cols) + }; + let base = g.cy + g.half; + let ys: Vec> = spans + .into_iter() + .enumerate() + .map(|(c, span)| { + let (lo, hi) = span?; + let k = gain((g.k0 + c as f64 + 0.5) * g.step); + Some(if rectified { + (base - level(lo.abs().max(hi.abs()) * k) * g.half * 2.0, base) + } else { + (g.cy - level(hi * k) * g.half, g.cy - level(lo * k) * g.half) + }) + }) + .collect(); + g.paint(painter, &ys, wave, outlines, rectified); +} + +/// Draw a take while it records across `body`, one band per channel: `blocks` holds each channel's +/// (min, max) per block of input frames, the blocks `block_len` timeline samples long from +/// timeline position `origin`. +pub fn draw_live(painter: &Painter, s: &Session, tl: Rect, origin: f64, block_len: f64, blocks: &[Vec<(f32, f32)>], body: Rect, wave: Color32) { + let zoom = s.edit.zoom.waveform_zoom; + for (blocks, band) in blocks.iter().zip(bands(body, blocks.len())) { + let Some(g) = Grid::new(painter, s, tl, band) else { continue }; + let ys: Vec> = (0..g.cols) + .map(|c| { + let a = g.start() + c as f64 * g.step - origin; + let (i, j) = ((a / block_len).floor().max(0.0) as usize, ((a + g.step + g.overlap()) / block_len).ceil().max(0.0) as usize); + let (lo, hi) = + blocks.get(i..j.min(blocks.len()))?.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |(lo, hi), b| (lo.min(b.0), hi.max(b.1))); + (lo <= hi).then(|| (g.cy - level(hi * zoom) * g.half, g.cy - level(lo * zoom) * g.half)) + }) + .collect(); + g.paint(painter, &ys, wave, false, false); + } +} + +fn level(v: f32) -> f32 { + if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 } +} + +/// One column per physical pixel across the visible part of a channel's band, each summing up a +/// one-point window of timeline samples anchored to absolute positions, so scrolling doesn't +/// change what a column shows. +struct Grid { + vis: Rect, + tl_x: f32, + scroll: f64, + /// Timeline samples per point, and per column. + spp: f64, + step: f64, + px: f32, + /// A one-point line, in whole physical pixels. + weight: f32, + cy: f32, + half: f32, + /// The first column's physical pixel and absolute window index, and the column count. + p0: f32, + k0: f64, + cols: usize, +} + +impl Grid { + fn new(painter: &Painter, s: &Session, tl: Rect, band: Rect) -> Option { + let vis = painter.clip_rect().intersect(band); + if !vis.is_positive() { + return None; + } + let ppp = painter.pixels_per_point(); + let px = 1.0 / ppp; + let weight = ppp.round().max(1.0) * px; + let spp = s.edit.zoom.samples_per_px.max(0.01); + let step = spp * f64::from(px); + let scroll = s.edit.zoom.scroll as f64; + let p0 = (vis.min.x * ppp).floor(); + let k0 = ((scroll + f64::from((p0 + 0.5) * px - tl.min.x) * spp) / step).floor(); + let cy = ((band.center().y - weight * 0.5) * ppp).round() * px + weight * 0.5; + let cols = ((vis.max.x * ppp).ceil() - p0).max(0.0) as usize; + Some(Grid { vis, tl_x: tl.min.x, scroll, spp, step, px, weight, cy, half: band.height() * 0.46, p0, k0, cols }) + } + + fn pos_at(&self, x: f32) -> f64 { + self.scroll + f64::from(x - self.tl_x) * self.spp + } + + fn x_at(&self, pos: f64) -> f32 { + self.tl_x + ((pos - self.scroll) / self.spp) as f32 + } + + /// How far each column's window reaches into its neighbours': a column is `step` apart but sums + /// up a whole point. + fn overlap(&self) -> f64 { + (self.spp - self.step).max(0.0) + } + + /// The timeline position where the first column's window starts. + fn start(&self) -> f64 { + self.k0 * self.step - self.overlap() * 0.5 + } + + /// Fill the columns' (top, bottom) spans; with `outlines`, edge them in `wave` around a lighter + /// fill, along the top only when `rectified`. + fn paint(&self, painter: &Painter, ys: &[Option<(f32, f32)>], wave: Color32, outlines: bool, rectified: bool) { + let mut mesh = Mesh::default(); + self.strip(&mut mesh, ys, if outlines { wave.gamma_multiply(0.55) } else { wave }); + let edges: &[fn((f32, f32)) -> f32] = match (outlines, rectified) { + (false, _) => &[], + (true, true) => &[|y| y.0], + (true, false) => &[|y| y.0, |y| y.1], + }; + for edge in edges { + let line: Vec> = ys + .iter() + .enumerate() + .map(|(c, y)| { + let y = edge((*y)?); + let near = |i: Option| i.and_then(|i| ys.get(i).copied().flatten()).map_or(y, |n| (edge(n) + y) * 0.5); + let (a, b) = (near(c.checked_sub(1)), near(Some(c + 1))); + Some((y.min(a).min(b) - self.weight * 0.5, y.max(a).max(b) + self.weight * 0.5)) + }) + .collect(); + self.strip(&mut mesh, &line, wave); + } + painter.add(Shape::mesh(mesh)); + } + + /// Fill each column's (top, bottom) span, at least `weight` tall, joined to its neighbours in + /// one triangle strip and anti-aliased at both ends. Runs of equal spans share vertices. + fn strip(&self, mesh: &mut Mesh, spans: &[Option<(f32, f32)>], color: Color32) { + let half = self.px * 0.5; + let thick = |(top, bot): (f32, f32)| { + if bot - top < self.weight { ((top + bot - self.weight) * 0.5, (top + bot + self.weight) * 0.5) } else { (top, bot) } + }; + let push = |mesh: &mut Mesh, prev: &mut Option, x: f32, (top, bot): (f32, f32)| { + let i = mesh.vertices.len() as u32; + mesh.colored_vertex(pos2(x, top - half), Color32::TRANSPARENT); + mesh.colored_vertex(pos2(x, top + half), color); + mesh.colored_vertex(pos2(x, bot - half), color); + mesh.colored_vertex(pos2(x, bot + half), Color32::TRANSPARENT); + if let Some(j) = *prev { + for k in 0..3 { + mesh.add_triangle(j + k, j + k + 1, i + k); + mesh.add_triangle(j + k + 1, i + k + 1, i + k); + } + } + *prev = Some(i); + }; + let at = |c: Option| c.and_then(|c| spans.get(c).copied().flatten()).map(thick); + let mut prev = None; + for c in 0..spans.len() { + let Some(span) = at(Some(c)) else { + prev = None; + continue; + }; + let x = (self.p0 + c as f32 + 0.5) * self.px; + let (before, after) = (at(c.checked_sub(1)), at(Some(c + 1))); + if before.is_none() { + push(mesh, &mut prev, x - half, span); + } + if before != Some(span) || after != Some(span) { + push(mesh, &mut prev, x, span); + } + if after.is_none() { + push(mesh, &mut prev, x + half, span); + } + } + } +} 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..751f64a 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; } @@ -178,11 +180,17 @@ fn system_usage(app: &mut SoundApp, ctx: &egui::Context) { fn task_manager(app: &mut SoundApp, ctx: &egui::Context) { let mut open = app.ui.show_task_manager; win(ctx, &mut open, "Task Manager", vec2(320.0, 120.0), |ui| { - ui.label("No background tasks are running."); + match &app.bounce { + Some(b) => { + ui.label(format!("Bouncing {} ({:.0}%)", b.path().display(), b.progress() * 100.0)); + ui.add(egui::ProgressBar::new(b.progress())); + } + None => { + ui.label("No background tasks are running."); + } + } ui.label( - egui::RichText::new("Renders, bounces and AudioSuite processes run to completion before returning.") - .small() - .color(Tokens::current().text_dim), + egui::RichText::new("Renders and AudioSuite processes run to completion before returning.").small().color(Tokens::current().text_dim), ); }); app.ui.show_task_manager = open; @@ -202,10 +210,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 +339,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() @@ -432,7 +437,7 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) { let _ = app.run("options.delay_compensation", json!({"value": dc})); } if ui.button("Reconnect audio device").clicked() { - app.player = Some(soundcraft_playback::Player::new(app.engine.session_arc())); + app.reconnect_audio(); } }); app.ui.show_playback_engine = open; diff --git a/crates/ui-egui/tests/bounce.rs b/crates/ui-egui/tests/bounce.rs new file mode 100644 index 0000000..9745ae4 --- /dev/null +++ b/crates/ui-egui/tests/bounce.rs @@ -0,0 +1,116 @@ +//! Bounce Mix renders across frames behind a progress window instead of freezing the app. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::dialogs::Dialog; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; + +fn harness() -> Harness<'static, SoundApp> { + let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let mut h = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(1_000).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + h +} + +fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) { + let app = h.state_mut(); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + if let Some(Dialog::Bounce { path, .. }) = &mut app.dialogs.open { + *path = file.to_string_lossy().into_owned(); + } + h.run_steps(2); + h.get_by_label("Bounce").click(); + h.run_steps(3); + assert!(h.state().bounce.is_some(), "{}", h.state().ui.status); + assert!(h.query_by_label("Bounce Mix").is_none()); +} + +#[test] +fn a_bounce_renders_across_frames_with_progress_until_its_file_is_written() { + let dir = scratch("bounce-frames"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Mix.wav")); + let frames = h.state().bounce.as_ref().map_or(0, |b| b.range().len()); + let mut progress = Vec::new(); + for _ in 0..4 { + progress.push(h.state().bounce.as_ref().map_or(1.0, |b| b.progress())); + h.get_by_label("Cancel"); + h.step(); + } + assert!(progress.is_sorted() && progress[0] < progress[3] && progress[3] < 1.0, "{progress:?}"); + h.run(); + assert!(h.state().bounce.is_none()); + assert!(h.state().ui.status.starts_with("Bounced Mix.wav (peak"), "{}", h.state().ui.status); + let (_, file) = soundcraft_audio_io::decode(&std::fs::read(dir.join("Mix.wav")).unwrap(), None).unwrap(); + assert_eq!(file.frames() as i64, frames); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_bounce_keeps_going_while_the_window_is_hidden() { + let dir = scratch("bounce-hidden"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Hidden.wav")); + let ctx = h.ctx.clone(); + let mut calls = 0; + while h.state().bounce.is_some() { + let _ = ctx.run_logic(&egui::RawInput::default(), |ctx| h.state_mut().logic(ctx)); + assert!(h.state().bounce.is_none() || ctx.has_requested_repaint()); + calls += 1; + assert!(calls < 10_000); + } + assert!(calls > 3, "{calls}"); + assert!(h.state().ui.status.starts_with("Bounced Hidden.wav"), "{}", h.state().ui.status); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_session_cannot_be_replaced_while_it_bounces() { + let dir = scratch("bounce-replace"); + let mut h = harness(); + let tracks = h.state().engine.session().tracks.len(); + bounce_from_dialog(&mut h, &dir.join("Mix.wav")); + for id in ["session.new", "session.open", "session.close", "session.revert"] { + let err = h.state_mut().run(id, json!({"path": dir.join("Other.scraft").to_string_lossy()})).unwrap_err(); + assert!(err.contains("bounce"), "{id}: {err}"); + } + assert_eq!(h.state().engine.session().tracks.len(), tracks); + assert!(h.state().bounce.is_some()); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state_mut().run("session.close", json!({})).is_ok()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() { + let dir = scratch("bounce-cancel"); + let mut h = harness(); + bounce_from_dialog(&mut h, &dir.join("Clicked.wav")); + h.get_by_label("Cancel").click(); + h.run_steps(2); + assert!(h.state().bounce.is_none()); + assert_eq!(h.state().ui.status, "Bounce cancelled"); + bounce_from_dialog(&mut h, &dir.join("Escaped.wav")); + h.key_press(egui::Key::Escape); + h.run_steps(2); + assert!(h.state().bounce.is_none()); + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/tests/bounce_dialog.rs b/crates/ui-egui/tests/bounce_dialog.rs new file mode 100644 index 0000000..e3a5499 --- /dev/null +++ b/crates/ui-egui/tests/bounce_dialog.rs @@ -0,0 +1,54 @@ +//! Bounce Mix starts in the session's folder and has a Browse button for the file (#186). +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog}; +use std::cell::RefCell; +use std::rc::Rc; + +#[test] +fn bounce_starts_in_the_session_folder_and_browse_picks_the_file_and_format() { + let asked = Rc::new(RefCell::new(Vec::new())); + let log = Rc::clone(&asked); + let pick_save: Box Option> = Box::new(move |purpose, default| { + log.borrow_mut().push((purpose.to_string(), default.to_string())); + Some("/Mixes/Final Mix.FLAC".into()) + }); + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.path = Some("/Songs/Midnight Groove/Midnight Groove.scraft".into()); + let mut app = SoundApp::new(engine, None, Services { pick_save: Some(pick_save), ..Default::default() }); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + 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(3); + h.get_by_label("Browse…").click(); + h.run_steps(3); + let default = "/Songs/Midnight Groove/Midnight Groove Bounce.wav".to_string(); + assert_eq!(*asked.borrow(), vec![("file.bounce_mix".to_string(), default)]); + match &h.state().dialogs.open { + Some(Dialog::Bounce { path, format, .. }) => assert_eq!((path.as_str(), format.as_str()), ("/Mixes/Final Mix.FLAC", "flac")), + other => panic!("{other:?}"), + } +} + +#[test] +fn bounce_of_a_session_with_a_relative_path_starts_in_the_default_folder() { + let mut engine = soundcraft_engine::demo::demo_engine(); + engine.path = Some("Midnight Groove.scraft".into()); + let name = engine.session().name.clone(); + let mut app = SoundApp::new(engine, None, Services::default()); + assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix")); + let mut fresh = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + assert!(fresh.dialogs.open_for_command(&fresh.engine, "file.bounce_mix")); + match (&app.dialogs.open, &fresh.dialogs.open) { + (Some(Dialog::Bounce { path, .. }), Some(Dialog::Bounce { path: unsaved, .. })) => { + assert_ne!(path.as_str(), format!("/{name} Bounce.wav")); + assert_eq!(path, unsaved, "the same folder as a session never saved"); + } + other => panic!("{other:?}"), + } +} 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_modes.rs b/crates/ui-egui/tests/edit_modes.rs new file mode 100644 index 0000000..ab7e05e --- /dev/null +++ b/crates/ui-egui/tests/edit_modes.rs @@ -0,0 +1,293 @@ +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_engine::Engine; +use soundcraft_engine::edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID}; +use soundcraft_model::{Clip, ClipId, EditMode, Source, SourceId, TrackId}; +use soundcraft_ui_egui::{Services, SoundApp, edit_window}; + +const SEC: i64 = 48_000; + +fn frame(ctx: &egui::Context, 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(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE } +} + +fn key(key: Key) -> Vec { + chord(key, Modifiers::NONE) +} + +fn chord(key: Key, modifiers: Modifiers) -> Vec { + [true, false].map(|pressed| key_event(key, pressed, modifiers)).to_vec() +} + +fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed, repeat: false, modifiers } +} + +fn app(mode: &str, tool: &str, spans: &[(i64, i64)]) -> (SoundApp, egui::Context, TrackId, Vec) { + let (app, ctx, tracks, ids) = tracks(mode, tool, &[spans]); + (app, ctx, tracks[0], ids) +} + +fn tracks(mode: &str, tool: &str, lanes: &[&[(i64, i64)]]) -> (SoundApp, egui::Context, Vec, Vec) { + let mut e = Engine::default(); + let made = e.execute("track.new", &json!({"count": lanes.len()})).unwrap()["tracks"].clone(); + let tracks: Vec = (0..lanes.len()).map(|i| TrackId(made[i].as_u64().unwrap())).collect(); + let s = e.session_mut(); + s.sources.push(Source { + id: SourceId(7), + name: "src".into(), + path: "src.wav".into(), + channels: 1, + frames: 20 * SEC as u64, + sample_rate: 48_000, + format: soundcraft_audio_io::FileFormat::Wav, + time_reference: 0, + unsaved: false, + }); + let mut ids = Vec::new(); + for (t, spans) in tracks.iter().zip(lanes) { + for &(start, len) in *spans { + let id = s.new_clip_id(); + s.track_mut(*t).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(id, "c", SourceId(7), start, start, len)); + ids.push(id); + } + } + e.execute("edit.mode", &json!({"mode": mode})).unwrap(); + e.execute("edit.tool", &json!({"tool": tool})).unwrap(); + e.execute("view.zoom_set", &json!({"samples_per_px": 400.0})).unwrap(); + let mut app = SoundApp::new(e, None, Services::default()); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + (app, ctx, tracks, ids) +} + +fn point(app: &SoundApp, t: TrackId, at: i64) -> Pos2 { + let s = app.engine.session(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == t.0).unwrap().1; + let x = edit_window::x_of(s, Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at); + pos2(x, row[1] + s.track(t).unwrap().height.points() * 0.75) +} + +fn drag(ctx: &egui::Context, app: &mut SoundApp, t: TrackId, from: i64, to: i64) { + let (from, to) = (point(app, t, from), point(app, t, to)); + drag_between(ctx, app, from, to); +} + +fn drag_between(ctx: &egui::Context, app: &mut SoundApp, from: Pos2, to: Pos2) { + frame(ctx, app, vec![Event::PointerMoved(from)]); + frame(ctx, app, vec![button(from, true)]); + let started = from + vec2(7.0_f32.copysign(to.x - from.x), 0.0); + for k in 0..=5 { + frame(ctx, app, vec![Event::PointerMoved(started + (to - started) * (k as f32 / 5.0))]); + } + frame(ctx, app, vec![button(to, false)]); + frame(ctx, app, vec![]); +} + +fn click(ctx: &egui::Context, app: &mut SoundApp, at: Pos2) { + frame(ctx, app, vec![Event::PointerMoved(at)]); + frame(ctx, app, vec![button(at, true)]); + frame(ctx, app, vec![button(at, false)]); +} + +fn starts(app: &SoundApp, ids: &[ClipId]) -> Vec { + ids.iter().map(|id| app.engine.session().find_clip(*id).unwrap().1.start).collect() +} + +#[test] +fn shuffle_drags_swap_clips() { + let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 2, SEC / 2 - 2000); + assert_eq!(starts(&app, &ids), vec![SEC, 0]); +} + +#[test] +fn grid_drags_snap_the_dragged_clip_not_the_one_under_the_pointer() { + let (mut app, ctx, t, ids) = app("grid", "grabber", &[(1000, SEC), (3 * SEC + 777, SEC)]); + let s = app.engine.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + drag(&ctx, &mut app, t, SEC / 2, 3 * SEC + SEC / 2); + assert_eq!(starts(&app, &ids[..1])[0] % step, 0); +} + +#[test] +fn spot_clicks_open_the_spot_dialog() { + let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]); + let at = point(&app, t, SEC * 3 / 2); + click(&ctx, &mut app, at); + frame(&ctx, &mut app, vec![]); + assert_eq!(app.dialogs.open_name(), Some("spot")); + frame(&ctx, &mut app, vec![Event::Text("4.5".into())]); + frame(&ctx, &mut app, key(Key::Enter)); + assert!(app.dialogs.open.is_none()); + assert_eq!(starts(&app, &ids), vec![SEC * 9 / 2]); +} + +#[test] +fn spot_drags_ask_where_before_moving() { + let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 2, SEC * 5 / 2); + assert_eq!(starts(&app, &ids), vec![SEC]); + assert_eq!(app.dialogs.open_name(), Some("spot")); + frame(&ctx, &mut app, key(Key::Enter)); + assert!((starts(&app, &ids)[0] - 2 * SEC).abs() < 4000); +} + +#[test] +fn slip_trims_stop_at_the_next_clip() { + let (mut app, ctx, t, ids) = app("slip", "trim", &[(0, SEC), (2 * SEC, SEC)]); + drag(&ctx, &mut app, t, SEC * 3 / 4, 4 * SEC); + let s = app.engine.session(); + assert_eq!(s.find_clip(ids[0]).unwrap().1.end(), 2 * SEC); + assert_eq!(starts(&app, &ids), vec![0, 2 * SEC]); +} + +#[test] +fn f4_toggles_absolute_and_relative_grid_and_comes_back_to_the_last_one() { + let (mut app, ctx, _, _) = app("slip", "grabber", &[]); + let mode = |app: &SoundApp| app.engine.session().edit.edit_mode; + let mut seen = Vec::new(); + for k in [Key::F4, Key::F4, Key::F4, Key::F4, Key::F2, Key::F4, Key::F1, Key::Backtick, Key::Backtick, Key::Backtick] { + frame(&ctx, &mut app, key(k)); + seen.push(mode(&app)); + } + use EditMode::*; + assert_eq!(seen, [Grid, GridRelative, Grid, GridRelative, Slip, GridRelative, Shuffle, Slip, Spot, GridRelative]); + app.engine.execute("options.keyboard_lock", &json!({})).unwrap(); + frame(&ctx, &mut app, key(Key::F4)); + frame(&ctx, &mut app, chord(Key::Num4, Modifiers::ALT)); + assert_eq!(mode(&app), GridRelative); +} + +#[test] +fn snap_to_grid_snaps_the_edit_cursor_while_slip_moves_stay_free() { + let (mut app, ctx, t, ids) = app("slip", "grabber", &[(1000, SEC)]); + app.engine.execute("edit.snap_to_grid", &json!({})).unwrap(); + let s = app.engine.session(); + let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate); + drag(&ctx, &mut app, t, SEC / 2, SEC / 2 + 10_000); + let moved = starts(&app, &ids)[0]; + assert!(moved > 1000 && moved % step != 0, "{moved}"); + app.engine.execute("edit.tool", &json!({"tool": "selector"})).unwrap(); + let at = point(&app, t, 3 * SEC + 1234); + click(&ctx, &mut app, at); + assert_eq!(app.engine.session().edit.selection.start % step, 0); +} + +#[test] +fn mode_keys_add_snap_to_grid_and_cycle_past_a_locked_shuffle() { + let (mut app, ctx, _, _) = app("slip", "grabber", &[]); + let state = |app: &SoundApp| (app.engine.session().edit.edit_mode, app.engine.session().edit.flag(SNAP_TO_GRID)); + frame(&ctx, &mut app, chord(Key::F4, Modifiers::SHIFT)); + assert_eq!(state(&app), (EditMode::Slip, true)); + let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE); + frame(&ctx, &mut app, vec![f4(true)]); + assert_eq!(state(&app), (EditMode::Grid, false)); + frame(&ctx, &mut app, key(Key::F1)); + frame(&ctx, &mut app, vec![f4(false)]); + assert_eq!(state(&app), (EditMode::Shuffle, true)); + frame(&ctx, &mut app, chord(Key::Num3, Modifiers::ALT)); + assert_eq!(state(&app), (EditMode::Spot, false)); + app.engine.execute("edit.shuffle_lock", &json!({})).unwrap(); + let mut seen = Vec::new(); + for _ in 0..4 { + frame(&ctx, &mut app, key(Key::Backtick)); + seen.push(state(&app).0); + } + assert_eq!(seen, [EditMode::Grid, EditMode::Slip, EditMode::Spot, EditMode::Grid]); + frame(&ctx, &mut app, key(Key::F1)); + assert_eq!(state(&app).0, EditMode::Grid); +} + +#[test] +fn toolbar_clicks_add_snap_to_grid_lock_shuffle_and_switch_grid_modes_despite_the_keyboard_lock() { + let mut e = Engine::default(); + e.execute("edit.mode", &json!({"mode": "slip"})).unwrap(); + let app = SoundApp::new(e, None, Services::default()); + let mut h = egui_kittest::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, + ); + h.run_steps(3); + let click = |h: &mut egui_kittest::Harness<'_, SoundApp>, label: &str, modifiers: Modifiers| { + h.get_by_label(label).click_modifiers(modifiers); + h.step(); + }; + let state = |h: &egui_kittest::Harness<'_, SoundApp>| { + let ed = &h.state().engine.session().edit; + (ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(SHUFFLE_LOCK)) + }; + click(&mut h, "GRID", Modifiers::SHIFT); + assert_eq!(state(&h), (EditMode::Slip, true, false)); + click(&mut h, "SPOT", Modifiers::NONE); + assert_eq!(state(&h), (EditMode::Spot, false, false)); + click(&mut h, "SHUFFLE", Modifiers::COMMAND); + click(&mut h, "SHUFFLE", Modifiers::NONE); + assert_eq!(state(&h), (EditMode::Spot, false, true)); + assert!(h.state().ui.status.contains("locked"), "{}", h.state().ui.status); + click(&mut h, "SLIP", Modifiers::SHIFT); + assert_eq!(state(&h), (EditMode::Slip, true, true)); + h.state_mut().engine.execute("options.keyboard_lock", &json!({})).unwrap(); + for (label, want) in [("GRID", EditMode::Grid), ("GRID", EditMode::GridRelative), ("SLIP", EditMode::Slip), ("GRID", EditMode::GridRelative)] { + click(&mut h, label, Modifiers::NONE); + assert_eq!(state(&h).0, want, "{label}"); + } +} + +#[test] +fn dragging_clips_on_two_tracks_moves_both_down_a_track() { + let (mut app, ctx, t, ids) = tracks("slip", "grabber", &[&[(0, SEC)], &[(2 * SEC, SEC)], &[]]); + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + let (from, to) = (point(&app, t[0], SEC / 2), point(&app, t[1], SEC / 2)); + drag_between(&ctx, &mut app, from, to); + let s = app.engine.session(); + assert_eq!(s.find_clip(ids[0]).map(|(t, _)| t), Some(t[1])); + assert_eq!(s.find_clip(ids[1]).map(|(t, _)| t), Some(t[2])); +} + +#[test] +fn shuffle_drags_of_clips_on_two_tracks_land_on_the_grabbed_tracks_boundaries() { + let lanes: [&[(i64, i64)]; 2] = [&[(4 * SEC, SEC), (5 * SEC, 3 * SEC), (17 * SEC / 2, SEC / 2)], &[(0, SEC), (SEC, 6 * SEC)]]; + let (mut app, ctx, t, ids) = tracks("shuffle", "grabber", &lanes); + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[3].0]})).unwrap(); + let before = starts(&app, &ids); + drag(&ctx, &mut app, t[0], 9 * SEC / 2, 9 * SEC / 2 + 3 * SEC / 10); + assert_eq!(starts(&app, &ids), before); +} + +#[test] +fn shuffle_drags_leave_locked_clips_in_the_selection_out_of_it() { + let (mut app, ctx, t, ids) = app("shuffle", "grabber", &[(0, SEC), (3 * SEC, SEC)]); + app.engine.session_mut().find_clip_mut(ids[0]).unwrap().time_locked = true; + app.engine.execute("edit.select", &json!({"clips": [ids[0].0, ids[1].0]})).unwrap(); + drag(&ctx, &mut app, t, 7 * SEC / 2, 2 * SEC); + assert_eq!(starts(&app, &ids), vec![0, SEC]); +} + +#[test] +fn f4_pressed_before_f1_leaves_the_kind_of_grid_alone() { + let (mut app, ctx, _, _) = app("grid", "grabber", &[]); + let f4 = |pressed| key_event(Key::F4, pressed, Modifiers::NONE); + frame(&ctx, &mut app, vec![f4(true)]); + frame(&ctx, &mut app, key(Key::F1)); + frame(&ctx, &mut app, vec![f4(false)]); + let ed = &app.engine.session().edit; + assert_eq!((ed.edit_mode, ed.flag(SNAP_TO_GRID), ed.flag(RELATIVE_GRID)), (EditMode::Shuffle, true, false)); +} 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/menu_search.rs b/crates/ui-egui/tests/menu_search.rs new file mode 100644 index 0000000..0f2c7f5 --- /dev/null +++ b/crates/ui-egui/tests/menu_search.rs @@ -0,0 +1,171 @@ +//! Opening an insert, instrument, send or I/O menu gives its search field the keyboard: typing +//! filters at once, Up/Down move through the matches and Enter picks one. + +use egui::accesskit::Role; +use egui::{Key, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; +use soundcraft_model::Route; +use soundcraft_ui_egui::{MainWindow, Services, SoundApp}; + +fn harness(engine: soundcraft_engine::Engine) -> Harness<'static, SoundApp> { + let mut app = SoundApp::new(engine, None, Services::default()); + app.ui.window = MainWindow::Mix; + let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(4); + h +} + +fn open(h: &mut Harness, label: &str) { + h.get_by_label(label).click(); + h.run_steps(3); +} + +fn type_text(h: &mut Harness, text: &str) { + h.event(egui::Event::Text(text.into())); + h.run_steps(2); +} + +fn press(h: &mut Harness, key: Key) { + h.key_press(key); + h.run_steps(2); +} + +/// Built-in plugins whose name holds `text`, as the search lists them. +fn built_in(instruments: bool, text: &str) -> Vec<&'static soundcraft_dsp::PluginInfo> { + let mut found: Vec<_> = + soundcraft_dsp::plugins().iter().copied().filter(|p| p.is_instrument == instruments && p.name.to_lowercase().contains(text)).collect(); + found.sort_by_key(|p| p.name.to_lowercase()); + found +} + +fn vox() -> soundcraft_engine::Engine { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"name": "Vox"})).unwrap(); + engine +} + +#[test] +fn an_insert_menu_searches_as_soon_as_it_opens() { + let reverbs = built_in(false, "reverb"); + assert!(reverbs.len() >= 2, "the test needs two built-in reverbs"); + let mut h = harness(vox()); + open(&mut h, "Insert A"); + type_text(&mut h, "reverb"); + assert!(h.query_by_label(reverbs[0].name).is_some(), "typing went to the search"); + assert!(h.query_by_label_contains("Dynamics").is_none(), "the matches replace the categories"); + + h.get_by_role(Role::TextInput).click(); + h.run_steps(3); + assert!(h.query_by_label(reverbs[0].name).is_some(), "a click in the field keeps the menu open"); + + press(&mut h, Key::ArrowUp); + press(&mut h, Key::ArrowDown); + press(&mut h, Key::Enter); + let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter inserts the highlighted plugin"); + assert_eq!(insert.plugin, reverbs[1].id); + assert!(h.query_by_label(reverbs[0].name).is_none(), "picking closes the menu"); +} + +#[test] +fn a_menu_opens_with_an_empty_search() { + let mut h = harness(vox()); + open(&mut h, "Insert B"); + type_text(&mut h, "reverb"); + press(&mut h, Key::Escape); + assert!(h.query_by_label(built_in(false, "reverb")[0].name).is_none(), "Escape closes the menu"); + + open(&mut h, "Insert B"); + let category = format!("{:?}", soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap().category); + assert!(h.query_by_label_contains(&category).is_some(), "the old search is gone, so the categories show"); + type_text(&mut h, "zzzz"); + assert!(h.query_by_label("No matches").is_some()); + press(&mut h, Key::Enter); + assert!(h.state().engine.session().tracks[0].mixer.inserts.iter().all(Option::is_none), "Enter without a match picks nothing"); +} + +#[test] +fn picks_without_the_search_still_close_the_menu() { + let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap(); + let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap(); + let category = format!("{:?} ⏵", first.category); + let mut h = harness(vox()); + for (plugin, click) in [(first, egui::PointerButton::Primary), (other, egui::PointerButton::Secondary)] { + h.get_by_label("Insert C").click_button(click); + h.run_steps(3); + h.get_by_label(&category).hover(); + h.run_steps(3); + h.get_by_label(plugin.name).click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().unwrap().plugin, plugin.id); + assert!(h.query_by_label(&category).is_none(), "a plugin from a category closes the menu"); + } + + for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] { + h.get_by_label("Insert C").click_secondary(); + h.run_steps(3); + h.get_by_label(item).click(); + h.run_steps(3); + assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().map(|i| i.bypass), bypassed); + assert!(h.query_by_label(item).is_none(), "{item} closes the menu"); + } +} + +#[test] +fn an_instrument_menu_searches_as_soon_as_it_opens() { + let drums = built_in(true, "drum"); + assert!(!drums.is_empty(), "the test needs a built-in drum instrument"); + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"kind": "instrument", "name": "Keys"})).unwrap(); + let mut h = harness(engine); + h.state_mut().ui.mix_views.push("instruments".into()); + h.run_steps(2); + open(&mut h, "Instrument"); + type_text(&mut h, "drum"); + press(&mut h, Key::Enter); + let instrument = h.state().engine.session().tracks[0].instrument.clone().unwrap(); + assert_eq!(instrument.plugin, drums[0].id); + assert!(h.query_by_label(drums[0].name).is_none(), "picking closes the menu"); +} + +#[test] +fn send_and_output_menus_search_buses_as_soon_as_they_open() { + let mut engine = vox(); + 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})).unwrap(); + } + let mut h = harness(engine); + let bus = |h: &Harness, name: &str| h.state().engine.session().busses.iter().find(|b| b.name == name).unwrap().id; + + open(&mut h, "Send C"); + type_text(&mut h, "del"); + assert!(h.query_by_label("Verb").is_none()); + press(&mut h, Key::Enter); + let send = h.state().engine.session().tracks[0].mixer.sends[2].clone().expect("Enter picks the bus"); + assert_eq!(send.target, Route::Bus(bus(&h, "Delay"))); + + open(&mut h, "Output"); + type_text(&mut h, "ver"); + press(&mut h, Key::Enter); + assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb"))); +} + +#[test] +fn scripted_keys_reach_the_search_with_the_window_in_the_background() { + let plates = built_in(false, "plate"); + let mut h = harness(vox()); + h.input_mut().focused = false; + open(&mut h, "Insert A"); + type_text(&mut h, "plate"); + press(&mut h, Key::Enter); + let insert = h.state().engine.session().tracks[0].mixer.inserts[0].clone().expect("Enter works without window focus, as typing does"); + assert_eq!(insert.plugin, plates[0].id); +} 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_midi_clip.rs b/crates/ui-egui/tests/new_midi_clip.rs new file mode 100644 index 0000000..ec3fb07 --- /dev/null +++ b/crates/ui-egui/tests/new_midi_clip.rs @@ -0,0 +1,137 @@ +//! Empty MIDI clips from the Pencil, in whole bars, and from the empty MIDI Editor (#282). + +use egui::{Event, Id, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_model::ClipContent; +use soundcraft_time::BarBeat; +use soundcraft_ui_egui::{Services, SoundApp, edit_window::x_of, midi_editor}; + +struct Rig { + ctx: egui::Context, + app: SoundApp, +} + +impl Rig { + fn new(kind: &str) -> Rig { + let mut engine = soundcraft_engine::Engine::default(); + engine.execute("track.new", &json!({"kind": kind})).unwrap(); + engine.execute("edit.tool", &json!({"tool": "pencil"})).unwrap(); + let mut r = Rig { ctx: egui::Context::default(), app: SoundApp::new(engine, None, Services::default()) }; + 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() }; + self.ctx + .run_ui(raw, |ui| { + self.app.logic(ui.ctx()); + self.app.ui(ui); + }) + .textures_delta + .clear(); + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + self.frame(vec![]); + } + } + + fn bar(&self, bar: i64) -> i64 { + let s = self.app.engine.session(); + s.tempo.samples_at_bar_beat(BarBeat { bar, beat: 1, tick: 0 }, s.sample_rate) + } + + fn at(&self, at: i64) -> Pos2 { + let [x0, y0, x1, y1] = self.app.edit_layout.timeline; + let x = x_of(self.app.engine.session(), Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)), at); + pos2(x, self.app.edit_layout.rows[0].1[1] + 10.0) + } + + 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: i64, to: i64) { + let (a, b) = (self.at(from), self.at(to)); + self.press(a, true); + for k in 1..=6 { + self.frame(vec![Event::PointerMoved(a.lerp(b, k as f32 / 6.0))]); + } + self.press(b, false); + self.frames(2); + } + + fn click(&mut self, at: i64) { + self.click_at(self.at(at)); + } + + fn click_at(&mut self, p: Pos2) { + self.press(p, true); + self.press(p, false); + self.frames(2); + } + + fn clips(&self) -> Vec<(i64, i64)> { + self.app.engine.session().tracks[0].clips().iter().map(|c| (c.start, c.end())).collect() + } +} + +#[test] +fn a_pencil_click_on_an_empty_instrument_track_makes_a_one_bar_clip() { + let mut r = Rig::new("instrument"); + let mid = (r.bar(3) + r.bar(4)) / 2; + r.click(mid); + assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); + let s = r.app.engine.session(); + let clip = &s.tracks[0].clips()[0]; + assert!(matches!(&clip.content, ClipContent::Midi { sequence } if sequence.notes.is_empty())); + assert_eq!(s.edit.selected_clips, vec![clip.id]); +} + +#[test] +fn a_pencil_drag_makes_a_clip_over_the_bars_it_crosses() { + let mut r = Rig::new("midi"); + let (from, to) = ((r.bar(2) + r.bar(3)) / 2, (r.bar(4) + r.bar(5)) / 2); + r.drag(from, to); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5))]); + r.app.run("edit.undo", json!({})).unwrap(); + r.drag(to, from); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5))]); +} + +#[test] +fn a_pencil_drag_stops_at_the_next_clip_and_clicks_on_clips_add_nothing() { + let mut r = Rig::new("instrument"); + r.click(r.bar(5)); + r.drag(r.bar(2), (r.bar(6) + r.bar(7)) / 2); + assert_eq!(r.clips(), vec![(r.bar(2), r.bar(5)), (r.bar(5), r.bar(6))]); + r.click((r.bar(2) + r.bar(3)) / 2); + assert_eq!(r.clips().len(), 2); +} + +#[test] +fn the_pencil_makes_no_clips_on_audio_tracks() { + let mut r = Rig::new("audio"); + r.click(r.bar(3)); + r.drag(r.bar(2), r.bar(4)); + assert!(r.clips().is_empty()); +} + +#[test] +fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() { + let mut r = Rig::new("instrument"); + r.app.ui.show_midi_editor = true; + let track = r.app.engine.session().tracks[0].id.0; + r.app.run("edit.select", json!({"tracks": [track], "start": (r.bar(3) + r.bar(4)) / 2})).unwrap(); + r.frames(4); + assert_eq!(midi_editor::target_clip(&r.app), None); + let body = r.ctx.data(|d| d.get_temp::(Id::new("soundcraft-midi-body"))).unwrap(); + r.click_at(body.center()); + assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]); + assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id)); +} 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/recovery.rs b/crates/ui-egui/tests/recovery.rs new file mode 100644 index 0000000..00ea265 --- /dev/null +++ b/crates/ui-egui/tests/recovery.rs @@ -0,0 +1,176 @@ +//! Unsaved work outlives a crash or an exit without Quit and is offered at the next launch. +use egui::vec2; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_engine::autosave::leftovers; +use soundcraft_ui_egui::{Services, SoundApp}; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, MutexGuard, PoisonError}; + +/// One test at a time: a child process another test starts (fontconfig's `fc-match`) holds copies +/// of this process's open files until it execs, so a released autosave lock can still look taken. +fn one_at_a_time() -> MutexGuard<'static, ()> { + static ONE: Mutex<()> = Mutex::new(()); + ONE.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn folder(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// Autosaves are written in the background. +fn eventually(done: impl Fn() -> bool) -> bool { + for _ in 0..300 { + if done() { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + done() +} + +fn autosaved(root: &Path) -> String { + let folders = std::fs::read_dir(root).into_iter().flatten().flatten(); + folders.map(|d| std::fs::read_to_string(d.path().join("session.scraft")).unwrap_or_default()).collect() +} + +fn launch(root: &Path) -> SoundApp { + let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()); + app.autosave_dir = Some(root.to_path_buf()); + app.leftovers = leftovers(root); + app +} + +#[test] +fn an_exit_without_quit_offers_unsaved_changes_at_the_next_launch() { + let _one = one_at_a_time(); + let dir = folder("recovery-quit"); + let root = dir.join("Autosave"); + let path = dir.join("Song.scraft"); + let mut first = launch(&root); + first.run("session.save_as", json!({"path": path})).unwrap(); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + drop(first); + + 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); + }, + launch(&root), + ); + h.run_steps(3); + h.get_by_label("Recover").click(); + h.run_steps(3); + let e = &h.state().engine; + assert!(e.session().track_by_name("Vocal").is_some() && e.is_dirty()); + assert_eq!(e.path, Some(path.to_string_lossy().into_owned())); + assert!(leftovers(&root).is_empty()); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1); + h.state_mut().run("session.save", json!({})).unwrap(); + assert!(eventually(|| std::fs::read_dir(&root).unwrap().count() == 0)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn every_change_is_autosaved_within_a_second() { + let _one = one_at_a_time(); + let dir = folder("recovery-changes"); + let root = dir.join("Autosave"); + 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); + }, + launch(&root), + ); + for name in ["Vocal", "Bass"] { + h.state_mut().run("track.new", json!({"name": name})).unwrap(); + h.run_steps(1); + assert!(eventually(|| autosaved(&root).contains(name)), "{name}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn recovering_keeps_the_unsaved_work_it_replaces() { + let _one = one_at_a_time(); + let dir = folder("recovery-replace"); + let root = dir.join("Autosave"); + let mut first = launch(&root); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + let left = leftovers(&root); + assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Untitled", None)); + let mut next = launch(&root); + next.run("track.new", json!({"name": "Drums"})).unwrap(); + next.recover(&left[0]); + assert!(next.engine.session().track_by_name("Vocal").is_some() && next.engine.path.is_none()); + let kept = leftovers(&root); + assert_eq!(kept.len(), 1); + let mut third = launch(&root); + third.recover(&kept[0]); + assert!(third.engine.session().track_by_name("Drums").is_some()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn closing_the_session_or_quitting_drops_its_autosave() { + let _one = one_at_a_time(); + let dir = folder("recovery-close"); + let root = dir.join("Autosave"); + for end in ["session.close", "app.quit"] { + let mut first = launch(&root); + first.run("track.new", json!({"name": "Vocal"})).unwrap(); + first.close_autosave(); + let mut next = launch(&root); + let left = next.leftovers[0].clone(); + next.recover(&left); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 1, "{end}"); + next.run(end, json!({})).unwrap(); + next.close_autosave(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0, "{end}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn quitting_while_recording_keeps_the_take_for_the_next_launch() { + let _one = one_at_a_time(); + let dir = folder("recovery-recording"); + let root = dir.join("Autosave"); + let mut app = launch(&root); + app.run("track.new", json!({"name": "Vocal"})).unwrap(); + app.engine.transport.recording = true; + assert!(app.run("app.quit", json!({})).is_err(), "Quit waits for the take"); + app.quit_requested = true; + app.close_autosave(); + assert!(!app.engine.transport.recording); + assert_eq!(leftovers(&root).len(), 1); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_panic_in_a_frame_autosaves_and_the_app_carries_on() { + let _one = one_at_a_time(); + let dir = folder("recovery-panic"); + let root = dir.join("Autosave"); + let mut app = launch(&root); + app.run("track.new", json!({"name": "Vocal"})).unwrap(); + app.guarded(|_| std::panic::panic_any("a bug in a frame")); + assert!(app.ui.status.contains("autosaved"), "{}", app.ui.status); + app.guarded(|app| { + app.run("track.new", json!({"name": "Bass"})).unwrap(); + }); + assert!(app.engine.session().track_by_name("Bass").is_some()); + drop(app); + let left = leftovers(&root); + let mut next = launch(&root); + next.recover(&left[0]); + assert!(next.engine.session().track_by_name("Vocal").is_some()); + let _ = std::fs::remove_dir_all(&dir); +} 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_height.rs b/crates/ui-egui/tests/track_height.rs new file mode 100644 index 0000000..027dad9 --- /dev/null +++ b/crates/ui-egui/tests/track_height.rs @@ -0,0 +1,138 @@ +use egui::os::OperatingSystem; +use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_model::TrackHeight; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +fn frame(ctx: &egui::Context, 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(); +} + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +fn rig() -> (egui::Context, SoundApp) { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + (ctx, app) +} + +fn id(app: &SoundApp, name: &str) -> u64 { + app.engine.session().track_by_name(name).unwrap().id.0 +} + +fn height(app: &SoundApp, name: &str) -> TrackHeight { + app.engine.session().track_by_name(name).unwrap().height +} + +fn row(app: &SoundApp, name: &str) -> [f32; 4] { + let id = id(app, name); + app.edit_layout.rows.iter().find(|(t, _)| *t == id).unwrap().1 +} + +fn select(app: &mut SoundApp, names: &[&str]) { + let ids: Vec = names.iter().map(|n| id(app, n)).collect(); + app.engine.execute("edit.select", &json!({"tracks": ids})).unwrap(); +} + +/// Drags `name`'s bottom edge by `dy` with `m` held, holding still for a few frames before letting go. +fn drag_edge(ctx: &egui::Context, app: &mut SoundApp, name: &str, dy: f32, m: Modifiers) { + let [x, _, _, bottom] = row(app, name); + let at = pos2(x + 40.0, bottom - 1.0); + frame(ctx, app, vec![Event::ModifiersChanged(m), Event::PointerMoved(at)]); + frame(ctx, app, vec![button(at, true)]); + frame(ctx, app, vec![Event::PointerMoved(at + vec2(0.0, dy))]); + let held: Vec = app.engine.session().tracks.iter().map(|t| t.height).collect(); + for _ in 0..3 { + frame(ctx, app, vec![]); + assert!(app.engine.session().tracks.iter().map(|t| t.height).eq(held.iter().copied()), "the heights hold while the pointer does"); + } + frame(ctx, app, vec![button(at + vec2(0.0, dy), false), Event::ModifiersChanged(Modifiers::NONE)]); + frame(ctx, app, vec![]); +} + +#[test] +fn dragging_a_track_edge_resizes_it_live_in_one_undo_step() { + let (ctx, mut app) = rig(); + assert_eq!(height(&app, "Kick"), TrackHeight::Medium); + let [x, _, _, bottom] = row(&app, "Kick"); + let at = |dy: f32| pos2(x + 40.0, bottom + dy); + + frame(&ctx, &mut app, vec![Event::PointerMoved(at(-1.0))]); + frame(&ctx, &mut app, vec![button(at(-1.0), true)]); + for (dy, want) in [(20.0, TrackHeight::Medium), (44.0, TrackHeight::Large), (132.0, TrackHeight::Jumbo), (40.0, TrackHeight::Large)] { + frame(&ctx, &mut app, vec![Event::PointerMoved(at(dy))]); + frame(&ctx, &mut app, vec![]); + let [_, y0, _, y1] = row(&app, "Kick"); + assert_eq!((height(&app, "Kick"), y1 - y0), (want, want.points()), "{dy}"); + } + frame(&ctx, &mut app, vec![button(at(40.0), false)]); + frame(&ctx, &mut app, vec![]); + + app.engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(height(&app, "Kick"), TrackHeight::Medium); +} + +#[test] +fn cmd_drags_to_any_height_alt_resizes_every_track_and_alt_shift_the_selected_ones() { + let (ctx, mut app) = rig(); + drag_edge(&ctx, &mut app, "Kick", 20.0, Modifiers::COMMAND); + assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Custom(108), TrackHeight::Medium)); + + drag_edge(&ctx, &mut app, "Snare", 44.0, Modifiers::ALT); + assert!( + app.edit_layout.rows.iter().all(|(t, _)| app.engine.session().track(soundcraft_model::TrackId(*t)).unwrap().height == TrackHeight::Large) + ); + + select(&mut app, &["Hats", "Bass"]); + drag_edge(&ctx, &mut app, "Kick", -86.0, Modifiers { alt: true, shift: true, ..Modifiers::NONE }); + let small = [TrackHeight::Small, TrackHeight::Large, TrackHeight::Small, TrackHeight::Small]; + assert_eq!(["Kick", "Snare", "Hats", "Bass"].map(|n| height(&app, n)), small); + + app.engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(["Kick", "Snare", "Hats", "Bass"].map(|n| height(&app, n)), [TrackHeight::Large; 4]); +} + +#[test] +fn ctrl_arrows_step_the_selected_tracks_and_ctrl_alt_arrows_every_track() { + let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE }; + for (os, ctrl) in [(OperatingSystem::Mac, Modifiers::CTRL), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] { + let (ctx, mut app) = rig(); + ctx.set_os(os); + select(&mut app, &["Kick"]); + press(&ctx, &mut app, Key::ArrowUp, ctrl); + assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Large, TrackHeight::Medium), "{os:?}"); + press(&ctx, &mut app, Key::ArrowDown, ctrl); + press(&ctx, &mut app, Key::ArrowDown, ctrl); + assert_eq!(height(&app, "Kick"), TrackHeight::Small, "{os:?}"); + press(&ctx, &mut app, Key::ArrowUp, Modifiers { alt: true, ..ctrl }); + assert_eq!((height(&app, "Kick"), height(&app, "Snare")), (TrackHeight::Medium, TrackHeight::Large), "{os:?}"); + assert_eq!(app.engine.session().edit.selected_tracks.len(), 1, "{os:?}"); + } +} + +#[test] +fn control_shift_p_and_semicolon_extend_the_edit_on_macos() { + let (ctx, mut app) = rig(); + ctx.set_os(OperatingSystem::Mac); + select(&mut app, &["Snare"]); + let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; + press(&ctx, &mut app, Key::P, ctrl_shift); + press(&ctx, &mut app, Key::Colon, ctrl_shift); + let selected: Vec = app.engine.session().edit.selected_tracks.iter().map(|t| t.0).collect(); + assert_eq!(selected, ["Kick", "Snare", "Hats"].map(|n| id(&app, n))); +} 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/ui-egui/tests/waveform.rs b/crates/ui-egui/tests/waveform.rs new file mode 100644 index 0000000..2724ec0 --- /dev/null +++ b/crates/ui-egui/tests/waveform.rs @@ -0,0 +1,105 @@ +use egui::{Color32, Pos2, RawInput, Rect, Shape, vec2}; +use serde_json::json; +use soundcraft_audio_io::AudioBuffer; +use soundcraft_model::{Clip, ClipId, Session, SourceAudio, SourceId}; +use soundcraft_time::SampleRate; +use soundcraft_ui_egui::{Services, SoundApp, waveform}; + +const WAVE: Color32 = Color32::from_rgb(12, 12, 14); + +/// The lines and meshes `f` paints across a 400×100 band on a 2x screen. +fn paint(samples_per_px: f64, f: impl Fn(&egui::Painter, &Session, Rect)) -> Vec { + let mut s = Session::new("t", SampleRate::HZ_48000); + s.edit.zoom.samples_per_px = samples_per_px; + let band = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 100.0)); + let ctx = egui::Context::default(); + ctx.set_pixels_per_point(2.0); + let mut out = ctx.run_ui(RawInput { screen_rect: Some(band), ..Default::default() }, |ui| f(ui.painter(), &s, band)); + out.textures_delta.clear(); + out.shapes.into_iter().map(|c| c.shape).filter(|s| matches!(s, Shape::Path(_) | Shape::Mesh(_))).collect() +} + +fn draw(samples: Vec, samples_per_px: f64) -> Vec { + let clip = Clip::audio(ClipId(1), "c", SourceId(1), 0, 0, samples.len() as i64); + let audio = SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![samples] }); + paint(samples_per_px, |p, s, band| waveform::draw(p, s, band, &clip, &audio, 1, band, WAVE)) +} + +#[test] +fn a_take_draws_its_overview_while_it_records() { + let blocks = vec![vec![(-0.5, 0.5); 100]]; + let shapes = paint(128.0, |p, s, band| waveform::draw_live(p, s, band, 1280.0, 128.0, &blocks, band, WAVE)); + let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; + let span = |f: fn(&Pos2) -> f32| mesh.vertices.iter().map(|v| f(&v.pos)).fold((f32::MAX, f32::MIN), |(a, b), v| (a.min(v), b.max(v))); + let ((left, right), (top, bottom)) = (span(|p| p.x), span(|p| p.y)); + assert!((left - 10.0).abs() <= 1.0 && (right - 110.0).abs() <= 1.0, "drawn from {left} to {right}"); + assert!((top - 27.0).abs() <= 1.0 && (bottom - 73.0).abs() <= 1.0, "drawn from {top} to {bottom}"); +} + +#[test] +fn at_the_sample_level_the_waveform_is_a_line_through_the_samples() { + let x: Vec = (0..200).map(|i| (i as f32 * 0.3).sin() * 0.8).collect(); + let shapes = draw(x.clone(), 0.25); + let [Shape::Path(line)] = shapes.as_slice() else { panic!("{shapes:?}") }; + assert!(line.points.len() > 100); + for p in &line.points { + let i = (p.x / 4.0).round() as usize; + assert!((p.x - i as f32 * 4.0).abs() < 1e-3 && (p.y - (50.0 - x[i] * 46.0)).abs() < 1e-3, "{p:?}"); + } +} + +#[test] +fn zoomed_out_a_one_sample_spike_stays_a_point_wide() { + let mut x = vec![0.0f32; 4800]; + x[1600] = 1.0; + let shapes = draw(x, 8.0); + let [Shape::Mesh(mesh)] = shapes.as_slice() else { panic!("{shapes:?}") }; + let tall: Vec = mesh.vertices.iter().filter(|v| v.pos.y < 40.0).map(|v| v.pos.x).collect(); + let (left, right) = tall.iter().fold((f32::MAX, f32::MIN), |(a, b), &x| (a.min(x), b.max(x))); + assert!(right - left <= 1.0 && (199.0..=201.0).contains(&left), "spike drawn from {left} to {right}"); + assert!(mesh.vertices.iter().any(|v| v.pos.y < 5.0 && v.color == WAVE)); +} + +/// Waveform meshes drawn in a track's row, from `below` its top, once the app has settled. +fn waveforms(ctx: &egui::Context, app: &mut SoundApp, name: &str, below: f32) -> usize { + let mut shapes = Vec::new(); + for _ in 0..2 { + 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(); + shapes = out.shapes; + } + let id = app.engine.session().track_by_name(name).unwrap().id.0; + let row = app.edit_layout.rows.iter().find(|(i, _)| *i == id).unwrap().1; + let [x0, _, x1, _] = app.edit_layout.timeline; + let lane = Rect::from_min_max(egui::pos2(x0, row[1] + below), egui::pos2(x1, row[3])); + shapes.iter().filter(|c| matches!(&c.shape, Shape::Mesh(m) if m.vertices.iter().any(|v| lane.contains(v.pos)))).count() +} + +#[test] +fn blocks_hide_waveforms_and_playlist_lanes_show_them() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("track.playlist_duplicate", &json!({"track": "Bass"})).unwrap(); + app.engine.execute("track.view", &json!({"tracks": ["Bass"], "view": "playlists"})).unwrap(); + app.engine.execute("track.view", &json!({"tracks": ["Snare"], "view": "blocks"})).unwrap(); + let main = app.engine.session().track_by_name("Bass").unwrap().height.points(); + let ctx = egui::Context::default(); + assert!(waveforms(&ctx, &mut app, "Kick", 0.0) > 0); + assert_eq!(waveforms(&ctx, &mut app, "Snare", 0.0), 0); + assert!(waveforms(&ctx, &mut app, "Bass", main) > 0, "the alternate playlist's lane"); +} + +#[test] +fn overlapped_crossfades_show_the_outgoing_clip_too() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("edit.fades_create", &json!({"tracks": ["Bass"], "start": {"seconds": 18.2}, "end": {"seconds": 18.7}})).unwrap(); + app.engine.execute("view.zoom_set", &json!({"samples_per_px": 64})).unwrap(); + app.engine.execute("view.scroll", &json!({"to": {"seconds": 18.0}})).unwrap(); + let ctx = egui::Context::default(); + let plain = waveforms(&ctx, &mut app, "Bass", 0.0); + app.engine.execute("view.waveform_overlapped_crossfades", &json!({"value": true})).unwrap(); + assert_eq!(waveforms(&ctx, &mut app, "Bass", 0.0), plain + 1); +} diff --git a/crates/ui-egui/tests/zoom_toggle.rs b/crates/ui-egui/tests/zoom_toggle.rs new file mode 100644 index 0000000..d3badcf --- /dev/null +++ b/crates/ui-egui/tests/zoom_toggle.rs @@ -0,0 +1,43 @@ +use egui::os::OperatingSystem; +use egui::{Key, vec2}; +use egui_kittest::{Harness, kittest::Queryable}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp}; + +#[test] +fn e_fits_the_selection_to_the_edit_window_and_the_button_goes_back() { + for os in [OperatingSystem::Mac, OperatingSystem::Windows, OperatingSystem::Nix] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let bass = app.engine.session().track_by_name("Bass").unwrap().id.0; + app.engine.execute("edit.select", &json!({"tracks": [bass], "start": 96_000, "end": 192_000})).unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_os(os).build_ui_state( + |ui, app: &mut SoundApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(5); + let view = |a: &SoundApp| (a.engine.session().edit.zoom, a.edit_layout.rows.clone(), a.edit_layout.scroll_y); + let before = view(h.state()); + h.state_mut().engine.execute("options.keyboard_focus", &json!({"focus": "none"})).unwrap(); + h.key_press(Key::E); + h.run_steps(1); + assert_eq!(view(h.state()), before, "{os:?}"); + + h.state_mut().engine.execute("options.keyboard_focus", &json!({"focus": "commands"})).unwrap(); + h.key_press(Key::E); + h.run_steps(1); + let [x0, y0, x1, y1] = h.state().edit_layout.timeline; + let zoom = h.state().engine.session().edit.zoom; + assert_eq!(zoom.scroll, 96_000, "{os:?}"); + assert!((f64::from(x1 - x0) * zoom.samples_per_px - 96_000.0).abs() < 1.0, "{os:?}"); + let row = h.state().edit_layout.rows.iter().find(|(id, _)| *id == bass).unwrap().1; + assert_eq!(row[1], y0, "{os:?}"); + assert!((row[3] + 1.0 - y1).abs() < 1.0, "{os:?}: {row:?} in {y0}..{y1}"); + + h.get_by_label("Zoom Toggle (E)").click(); + h.run_steps(1); + assert_eq!(view(h.state()), before, "{os:?}"); + } +} 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..ca10647 100644 --- a/crates/vst3-host/src/ffi.rs +++ b/crates/vst3-host/src/ffi.rs @@ -15,6 +15,7 @@ #![allow(clippy::unnecessary_cast)] use crate::{Vst3Error, scan}; +use soundcraft_dsp::Transport; use std::ffi::{c_char, c_int, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -24,7 +25,7 @@ use std::time::Duration; use vst3::Steinberg::Vst::{self as sv}; use vst3::Steinberg::Vst::{ IAudioProcessorTrait, IComponentHandlerTrait, IComponentTrait, IConnectionPointTrait, IEditControllerTrait, IEventListTrait, - IHostApplicationTrait, IParamValueQueueTrait, IParameterChangesTrait, + IHostApplicationTrait, IMidiMappingTrait, IParamValueQueueTrait, IParameterChangesTrait, }; use vst3::Steinberg::{self as sb}; use vst3::Steinberg::{FUnknown, IBStreamTrait, IPlugFrameTrait, IPlugViewTrait, IPluginBaseTrait, IPluginFactory2Trait, IPluginFactoryTrait}; @@ -81,6 +82,10 @@ fn ok(r: sb::tresult) -> bool { r == sb::kResultOk } +/// VST3's numbers for the MIDI controllers that aren't CCs. +pub(crate) const AFTERTOUCH: usize = sv::ControllerNumbers_::kAfterTouch as usize; +pub(crate) const PITCH_BEND: usize = sv::ControllerNumbers_::kPitchBend as usize; + // ---- loaded binaries ----------------------------------------------------------------------- /// One class as reported by the factory. @@ -571,8 +576,10 @@ impl Bundle { // SAFETY: valid controller; the handler (host context) outlives it. unsafe { c.setComponentHandler(h.as_ptr()) }; } - inst.editor = Arc::new(EditorLink { inner: Mutex::new(EditorInner { controller: Some(c.clone()), ..EditorInner::default() }) }); } + inst.editor = Arc::new(EditorLink { + inner: Mutex::new(EditorInner { component: Some(inst.component.clone()), controller: inst.controller.clone(), ..EditorInner::default() }), + }); Ok(inst) } } @@ -958,17 +965,18 @@ impl IBStreamTrait for MemoryStream { pub(crate) enum NoteEvent { On { time: u32, key: u8, velocity: f32 }, Off { time: u32, key: u8 }, + Pressure { time: u32, key: u8, pressure: f32 }, } impl NoteEvent { pub fn time(&self) -> u32 { match *self { - NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => time, + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } | NoteEvent::Pressure { time, .. } => time, } } pub fn set_time(&mut self, t: u32) { match self { - NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } => *time = t, + NoteEvent::On { time, .. } | NoteEvent::Off { time, .. } | NoteEvent::Pressure { time, .. } => *time = t, } } @@ -987,6 +995,10 @@ impl NoteEvent { e.r#type = sv::Event_::EventTypes_::kNoteOffEvent as u16; e.__field0.noteOff = sv::NoteOffEvent { channel: 0, pitch: i16::from(key), velocity: 0.0, noteId: -1, tuning: 0.0 }; } + NoteEvent::Pressure { key, pressure, .. } => { + e.r#type = sv::Event_::EventTypes_::kPolyPressureEvent as u16; + e.__field0.polyPressure = sv::PolyPressureEvent { channel: 0, pitch: i16::from(key), pressure, noteId: -1 }; + } } e } @@ -1072,6 +1084,27 @@ pub(crate) struct Layout { pub event_input: bool, } +fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) { + use sv::ProcessContext_::StatesAndFlags_::*; + let mut state = kTempoValid | kTimeSigValid | kProjectTimeMusicValid | kBarPositionValid | kContTimeValid; + if t.playing { + state |= kPlaying; + } + if t.recording { + state |= kRecording; + } + // The flag constants are the C default enum type: i32 on Windows, u32 elsewhere. + #[allow(clippy::unnecessary_cast)] + let state = state as u32; + c.state = state; + c.tempo = t.tempo; + c.timeSigNumerator = i32::try_from(t.numerator).unwrap_or(4); + c.timeSigDenominator = i32::try_from(t.denominator).unwrap_or(4); + c.projectTimeSamples = t.samples; + c.projectTimeMusic = t.quarters; + c.barPositionMusic = t.bar_start; +} + /// A live plugin: component + audio processor (+ edit controller) and the host objects. pub(crate) struct Instance { processor: Option>, @@ -1123,6 +1156,11 @@ impl Instance { self.editor.has_controller().then(|| Arc::clone(&self.editor)) } + /// The same link, for state handles. + pub fn state_link(&self) -> Arc { + Arc::clone(&self.editor) + } + /// Writes one state into a fresh memory stream. fn read_state(f: impl FnOnce(*mut sb::IBStream) -> sb::tresult) -> Option> { let stream = MemoryStream::new(Vec::new()); @@ -1130,26 +1168,7 @@ impl Instance { ok(f(s.as_ptr())).then(|| stream.take()) } - /// The component state plus the controller state, in SoundCraft's container (see - /// `STATE_MAGIC`). `None` when the component refuses `getState`. - pub fn save_state(&self) -> Option> { - // SAFETY: valid component; the stream lives in `read_state` for the whole call. - let comp = Instance::read_state(|s| unsafe { self.component.getState(s) })?; - let ctrl = match &self.controller { - // SAFETY: valid controller; as above. - Some(c) => Instance::read_state(|s| unsafe { c.getState(s) }).unwrap_or_default(), - None => Vec::new(), - }; - let mut out = Vec::with_capacity(12 + comp.len() + ctrl.len()); - out.extend_from_slice(STATE_MAGIC); - out.extend_from_slice(&u32::try_from(comp.len()).ok()?.to_le_bytes()); - out.extend_from_slice(&comp); - out.extend_from_slice(&u32::try_from(ctrl.len()).ok()?.to_le_bytes()); - out.extend_from_slice(&ctrl); - Some(out) - } - - /// Restores a [`Instance::save_state`] blob (or a bare component state): component + /// Restores a [`EditorLink::save_state`] blob (or a bare component state): component /// `setState`, then the controller's `setComponentState` and `setState`. `false` when the /// blob is malformed or the component refuses it. pub fn load_state(&self, data: &[u8]) -> bool { @@ -1239,6 +1258,20 @@ impl Instance { v.is_finite().then(|| v.clamp(0.0, 1.0)) } + /// The parameter each MIDI controller drives on channel 1 (`IMidiMapping`), indexed by VST3's + /// controller numbers: CCs 0–127, then [`AFTERTOUCH`] and [`PITCH_BEND`]. Main thread only. + pub fn midi_mapping(&self) -> Vec> { + let Some(m) = self.controller.as_ref().and_then(|c| c.cast::()) else { return Vec::new() }; + (0..=PITCH_BEND) + .map(|n| { + let mut id: sv::ParamID = 0; + let n = i16::try_from(n).unwrap_or(0); + // SAFETY: valid mapping interface on the main thread; `id` is a local. + ok(unsafe { m.getMidiControllerAssignment(0, 0, n, &mut id) }).then_some(id) + }) + .collect() + } + pub fn value_text(&self, id: u32, normalized: f64) -> Option { let c = self.controller.as_ref()?; let mut s: sv::String128 = [0; 128]; @@ -1392,16 +1425,20 @@ impl Instance { } /// Runs one `process` call over `frames` frames: `params` become one point each at offset 0, - /// `notes` (sorted by time) have their times made relative to `offset`. + /// `controls` (time, id, value) one point at their time when it falls in this call (the last + /// one wins per parameter), and `notes` (sorted by time) have their times made relative to + /// `offset`. pub fn process( &mut self, frames: usize, inputs: &mut BusBuffers, outputs: &mut BusBuffers, params: &[(u32, f64)], + controls: &[(u32, u32, f64)], notes: &[NoteEvent], offset: u32, project_time: i64, + transport: Option<&Transport>, ) -> Result<(), Vst3Error> { if !self.active { return Err(Vst3Error::Process("not active".into())); @@ -1420,6 +1457,11 @@ impl Instance { for &(id, v) in params.iter().take(PARAM_QUEUE_CAP) { self.in_params.set(id, 0, v.clamp(0.0, 1.0)); } + for &(time, id, v) in controls { + if let Some(at) = time.checked_sub(offset).filter(|&t| (t as usize) < frames) { + self.in_params.set(id, i32::try_from(at).unwrap_or(0), v.clamp(0.0, 1.0)); + } + } self.out_params.clear(); let event_in = self.event_in; self.in_events.with(|v| { @@ -1433,8 +1475,11 @@ impl Instance { self.out_events.with(Vec::clear); inputs.refresh(); outputs.refresh(); - self.context.projectTimeSamples = project_time; self.context.continousTimeSamples = project_time; + match transport { + Some(t) => apply_transport(&mut self.context, t), + None => self.context.projectTimeSamples = project_time, + } let mut data = sv::ProcessData { processMode: sv::ProcessModes_::kRealtime as i32, symbolicSampleSize: sv::SymbolicSampleSizes_::kSample32 as i32, @@ -1545,17 +1590,20 @@ fn rect_size(r: &sb::ViewRect) -> (f64, f64) { #[derive(Default)] struct EditorInner { /// `None` once the instance is gone. + component: Option>, + /// `None` once the instance is gone, or without an edit controller. controller: Option>, view: Option>, frame: Option>, window: Option, } -/// The link between an instance and its editor handles. The instance may process on the audio -/// thread while the editor (the edit controller's `IPlugView`) is driven from the main thread, -/// which is VST3's own threading model (controller and views on the UI thread). All view calls -/// happen under this lock, and the instance closes the view and drops the controller reference -/// under the same lock before it terminates, so a handle never calls into a dead plugin. +/// The link between an instance and its editor and state handles. The instance may process on +/// the audio thread while the editor (the edit controller's `IPlugView`) is driven and the state +/// read from the main thread, which is VST3's own threading model (controller, views and +/// `getState` on the UI thread). All such calls happen under this lock, and the instance closes +/// the view and drops the component and controller references under the same lock before it +/// terminates, so a handle never calls into a dead plugin. #[derive(Default)] pub(crate) struct EditorLink { inner: Mutex, @@ -1657,6 +1705,41 @@ impl EditorLink { } } + /// Hands a host-set value to the controller before a state save (`setParamNormalized`, + /// under the lock like [`EditorLink::save_state`]). No-op once the instance is gone. + pub fn sync_param(&self, id: u32, n: f64) { + if !n.is_finite() { + return; + } + let g = self.lock(); + if let Some(c) = &g.controller { + // SAFETY: valid controller (the instance clears it under this lock before it terminates). + unsafe { c.setParamNormalized(id, n.clamp(0.0, 1.0)) }; + } + } + + /// The component state plus the controller state, in SoundCraft's container (see + /// `STATE_MAGIC`). `None` when the component refuses `getState` or the instance is gone. + pub fn save_state(&self) -> Option> { + let g = self.lock(); + let component = g.component.as_ref()?; + // SAFETY: valid component (the instance clears it under this lock before it terminates); + // the stream lives in `read_state` for the whole call. + let comp = Instance::read_state(|s| unsafe { component.getState(s) })?; + let ctrl = match &g.controller { + // SAFETY: valid controller; as above. + Some(c) => Instance::read_state(|s| unsafe { c.getState(s) }).unwrap_or_default(), + None => Vec::new(), + }; + let mut out = Vec::with_capacity(12 + comp.len() + ctrl.len()); + out.extend_from_slice(STATE_MAGIC); + out.extend_from_slice(&u32::try_from(comp.len()).ok()?.to_le_bytes()); + out.extend_from_slice(&comp); + out.extend_from_slice(&u32::try_from(ctrl.len()).ok()?.to_le_bytes()); + out.extend_from_slice(&ctrl); + Some(out) + } + /// Closes the editor. Main thread only (no-op elsewhere). pub fn close(&self) { if window::on_main_thread() { @@ -1739,6 +1822,7 @@ impl EditorLink { } } } + g.component = None; g.controller = None; leaked } @@ -1881,6 +1965,31 @@ mod window { mod tests { use super::*; + #[test] + fn the_transport_fills_the_process_context() { + use sv::ProcessContext_::StatesAndFlags_::*; + // SAFETY: `ProcessContext` is plain data (numbers and small structs); all-zero is valid. + let mut c: sv::ProcessContext = unsafe { std::mem::zeroed() }; + let t = Transport { + playing: true, + tempo: 90.0, + numerator: 3, + denominator: 4, + samples: 96_000, + quarters: 3.5, + bar_start: 3.0, + ..Transport::default() + }; + apply_transport(&mut c, &t); + let playing = kPlaying as u32; + assert_eq!(c.state & playing, playing); + assert_eq!(c.state & kRecording as u32, 0); + assert_eq!((c.tempo, c.timeSigNumerator, c.timeSigDenominator), (90.0, 3, 4)); + assert_eq!((c.projectTimeSamples, c.projectTimeMusic, c.barPositionMusic), (96_000, 3.5, 3.0)); + apply_transport(&mut c, &Transport { playing: false, ..t }); + assert_eq!(c.state & playing, 0); + } + #[test] fn state_container_parses_strictly() { let mut blob = b"SCV3".to_vec(); diff --git a/crates/vst3-host/src/lib.rs b/crates/vst3-host/src/lib.rs index 0cfd3ab..17580bb 100644 --- a/crates/vst3-host/src/lib.rs +++ b/crates/vst3-host/src/lib.rs @@ -114,6 +114,9 @@ pub enum Vst3Error { mod api { use super::*; + pub fn cached() -> Option> { + registry::cached() + } pub fn scan() -> Vec { registry::scan() } @@ -144,6 +147,9 @@ mod api { mod api { use super::*; + pub fn cached() -> Option> { + Some(Vec::new()) + } pub fn scan() -> Vec { Vec::new() } @@ -191,6 +197,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec { api::scan_paths(dirs) } +/// The plugins of the last scan, without scanning: `None` until something has scanned. +pub fn cached() -> Option> { + api::cached() +} + /// Shuts plugin hosting down: runs the module exit of every loaded binary that has no live /// instance left, each on the thread that loaded it; later loads fail. Call it on the main /// thread once the plugin instances are gone, before the process exits. Without it the exits @@ -294,11 +305,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 +321,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..2975465 100644 --- a/crates/vst3-host/src/plugin.rs +++ b/crates/vst3-host/src/plugin.rs @@ -1,15 +1,17 @@ //! `Vst3Plugin`: a loaded VST3 instance behind the safe `soundcraft_dsp::Plugin` trait. -use crate::ffi::{BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP}; -use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Unit}; +use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND}; +use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit}; use std::sync::Arc; -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicU64, Ordering}; use vst3::ComWrapper; /// Largest block we set a plugin up for. const MAX_BLOCK: usize = 1 << 16; /// Most channels a host strip may ask for. const MAX_CHANNELS: usize = 64; +/// An `unsynced` slot with no value waiting (a NaN pattern, which `set_param` never stores). +const SYNCED: u64 = u64::MAX; /// How a SoundCraft parameter value maps to the VST3 normalized value. #[derive(Debug, Clone, Copy, PartialEq)] @@ -33,8 +35,16 @@ pub struct Vst3Plugin { values: Vec, /// Normalized values waiting for the next block (coalesced per id; bounded). pending: Vec<(u32, f64)>, + /// Host-set normalized values the controller has not seen yet (same order as `ids`; the bits + /// of an `f64`, or [`SYNCED`]). `set_param` may run on the audio thread, so it never calls the + /// controller; the state reader, which already does, hands them over first. + unsynced: Arc<[AtomicU64]>, /// Pending notes, kept sorted by time. Never grows past `EVENT_CAP`. notes: Vec, + /// The parameter each MIDI controller drives (see `Instance::midi_mapping`). + midi_map: Vec>, + /// Controller points for the next block: (time, param id, normalized value). Bounded. + controls: Vec<(u32, u32, f64)>, inputs: BusBuffers, outputs: BusBuffers, has_input: bool, @@ -46,6 +56,7 @@ pub struct Vst3Plugin { latency: usize, tail: usize, position: i64, + transport: Option, } impl Vst3Plugin { @@ -76,7 +87,10 @@ impl Vst3Plugin { info, values: Vec::with_capacity(ids.len()), pending: Vec::with_capacity(ids.len().min(PARAM_QUEUE_CAP)), + unsynced: ids.iter().map(|_| AtomicU64::new(SYNCED)).collect(), notes: Vec::with_capacity(EVENT_CAP), + midi_map: Vec::new(), + controls: Vec::with_capacity(EVENT_CAP), inputs: BusBuffers::new(&[], 0), outputs: BusBuffers::new(&[], 0), has_input: false, @@ -88,6 +102,7 @@ impl Vst3Plugin { latency: 0, tail: 0, position: 0, + transport: None, ids, maps, inst, @@ -145,6 +160,9 @@ impl Vst3Plugin { } } self.pending.clear(); + for u in self.unsynced.iter() { + u.store(SYNCED, Ordering::Relaxed); + } } /// Inserts keeping the queue time-sorted; drops the event when the queue is full (never @@ -178,6 +196,7 @@ impl Plugin for Vst3Plugin { self.max_block = mb; self.latency = self.inst.latency() as usize; self.tail = self.inst.tail() as usize; + self.midi_map = self.inst.midi_mapping(); } Err(e) => { log::warn!("{}: {e}", self.info.id); @@ -195,6 +214,7 @@ impl Plugin for Vst3Plugin { fn reset(&mut self) { self.notes.clear(); + self.controls.clear(); self.held = [false; 128]; } @@ -206,6 +226,9 @@ impl Plugin for Vst3Plugin { *slot = v; } let Some(n) = self.normalized_from_plain(i, v) else { return true }; + if let Some(u) = self.unsynced.get(i) { + u.store(n.to_bits(), Ordering::Relaxed); + } if let Some(e) = self.pending.iter_mut().find(|(pid, _)| *pid == vid) { e.1 = n; } else if self.pending.len() < self.pending.capacity() { @@ -237,6 +260,9 @@ impl Plugin for Vst3Plugin { e.set_time(last); } } + for c in &mut self.controls { + c.0 = c.0.min(last); + } let mut start = 0usize; while start < frames { let n = (frames - start).min(self.max_block); @@ -253,7 +279,17 @@ impl Plugin for Vst3Plugin { let k = self.notes.iter().position(|e| e.time() >= end).unwrap_or(self.notes.len()); let offset = u32::try_from(start).unwrap_or(u32::MAX); let notes = self.notes.get(..k).unwrap_or(&[]); - let r = self.inst.process(n, &mut self.inputs, &mut self.outputs, &self.pending, notes, offset, self.position); + let r = self.inst.process( + n, + &mut self.inputs, + &mut self.outputs, + &self.pending, + &self.controls, + notes, + offset, + self.position, + self.transport.as_ref(), + ); self.pending.clear(); self.notes.drain(..k); self.position = self.position.saturating_add(i64::try_from(n).unwrap_or(0)); @@ -278,12 +314,17 @@ impl Plugin for Vst3Plugin { } start += n; } + self.controls.clear(); } fn latency(&self) -> usize { self.latency } + fn set_transport(&mut self, transport: &Transport) { + self.transport = Some(*transport); + } + fn tail_samples(&self) -> usize { self.tail } @@ -310,6 +351,26 @@ impl Plugin for Vst3Plugin { } } + fn midi(&mut self, offset: usize, msg: [u8; 3]) { + let time = u32::try_from(offset).unwrap_or(u32::MAX); + let [_, d1, d2] = msg.map(|b| b & 0x7F); + let (ctrl, value) = match msg[0] & 0xF0 { + 0xB0 => (usize::from(d1), f64::from(d2) / 127.0), + 0xD0 => (AFTERTOUCH, f64::from(d1) / 127.0), + 0xE0 => (PITCH_BEND, f64::from(u16::from(d2) << 7 | u16::from(d1)) / 16383.0), + 0xA0 if self.event_input => { + self.push_note(NoteEvent::Pressure { time, key: d1, pressure: f32::from(d2) / 127.0 }); + return; + } + _ => return, + }; + if let Some(&Some(id)) = self.midi_map.get(ctrl) + && self.controls.len() < self.controls.capacity() + { + self.controls.push((time, id, value)); + } + } + fn all_notes_off(&mut self) { for k in 0..128u8 { if self.held.get(k as usize).copied().unwrap_or(false) { @@ -318,10 +379,6 @@ impl Plugin for Vst3Plugin { } } - fn save_state(&mut self) -> Option> { - self.inst.save_state() - } - fn load_state(&mut self, data: &[u8]) -> bool { let ok = self.inst.load_state(data); if ok { @@ -344,6 +401,21 @@ impl Plugin for Vst3Plugin { let link = self.inst.editor()?; Some(Box::new(Vst3Editor { link, host: self.inst.host_ref(), info: self.info, ids: self.ids.clone(), maps: self.maps.clone() })) } + + fn state_reader(&mut self) -> Option> { + let (link, ids, unsynced) = (self.inst.state_link(), self.ids.clone(), Arc::clone(&self.unsynced)); + Some(Box::new(move || { + // A parameter only the controller stores (no processor state) would otherwise be + // saved with the controller's stale value. + for (&id, u) in ids.iter().zip(unsynced.iter()) { + let bits = u.swap(SYNCED, Ordering::Relaxed); + if bits != SYNCED { + link.sync_param(id, f64::from_bits(bits)); + } + } + link.save_state() + })) + } } /// A VST3 plugin's editor (`IPlugView` in a host window), driven from the main thread. diff --git a/crates/vst3-host/src/registry.rs b/crates/vst3-host/src/registry.rs index 453fbe3..629b8f3 100644 --- a/crates/vst3-host/src/registry.rs +++ b/crates/vst3-host/src/registry.rs @@ -87,6 +87,10 @@ pub fn scan() -> Vec { ensure_scanned(&mut r).clone() } +pub fn cached() -> Option> { + registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone() +} + pub fn rescan() -> Vec { let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); r.scanned = None; diff --git a/crates/vst3-host/tests/fixture/src/lib.rs b/crates/vst3-host/tests/fixture/src/lib.rs index 3ba07c6..935d4c6 100644 --- a/crates/vst3-host/tests/fixture/src/lib.rs +++ b/crates/vst3-host/tests/fixture/src/lib.rs @@ -9,7 +9,8 @@ //! 320×240, no real drawing) supporting NSView/HWND/X11. //! - "Fixture Synth" (`Instrument|Synth`): a single-component instrument (the component is also //! its edit controller). No audio input, one stereo output, one event input; every output -//! sample is the velocity of the held note (sample-accurate), 0 when none. +//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and +//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too. //! //! The module checks the host's module lifecycle the way real plugins depend on it (see "module //! lifecycle" below) and aborts the process when it is broken. @@ -52,7 +53,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]> { @@ -417,7 +418,21 @@ struct Synth { } impl Class for Synth { - type Interfaces = (IComponent, IAudioProcessor, IEditController); + type Interfaces = (IComponent, IAudioProcessor, IEditController, IMidiMapping); +} + +/// The synth's hidden parameter that MIDI CC 7 drives. +const VOLUME_PARAM: ParamID = 1; + +impl IMidiMappingTrait for Synth { + unsafe fn getMidiControllerAssignment(&self, bus: i32, channel: i16, ctrl: CtrlNumber, id: *mut ParamID) -> tresult { + if bus == 0 && channel == 0 && ctrl == 7 { + unsafe { *id = VOLUME_PARAM }; + kResultOk + } else { + kResultFalse + } + } } impl IPluginBaseTrait for Synth { @@ -461,7 +476,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 @@ -521,6 +536,16 @@ impl IAudioProcessorTrait for Synth { } } } + let mut volume = None; + if let Some(changes) = unsafe { ComRef::from_raw(d.inputParameterChanges) } { + for i in 0..unsafe { changes.getParameterCount() } { + let Some(q) = (unsafe { ComRef::from_raw(changes.getParameterData(i)) }) else { continue }; + let (mut at, mut v) = (0i32, 0f64); + if unsafe { q.getParameterId() } == VOLUME_PARAM && unsafe { q.getPoint(0, &mut at, &mut v) } == kResultOk { + volume = Some((at.max(0) as usize, v as f32)); + } + } + } let outs = unsafe { channels(&*d.outputs, frames) }; let mut level = f32::from_bits(self.level.load(Ordering::Relaxed)); let mut out = vec![0.0f32; frames]; @@ -530,8 +555,15 @@ impl IAudioProcessorTrait for Synth { level = unsafe { e.__field0.noteOn.velocity }; } else if e.r#type == Event_::EventTypes_::kNoteOffEvent as u16 { level = 0.0; + } else if e.r#type == Event_::EventTypes_::kPolyPressureEvent as u16 { + level = unsafe { e.__field0.polyPressure.pressure }; } } + if let Some((at, v)) = volume + && at == i + { + level = v; + } *o = level; } self.level.store(level.to_bits(), Ordering::Relaxed); diff --git a/crates/vst3-host/tests/fixture_e2e.rs b/crates/vst3-host/tests/fixture_e2e.rs index e3603b7..5f708a8 100644 --- a/crates/vst3-host/tests/fixture_e2e.rs +++ b/crates/vst3-host/tests/fixture_e2e.rs @@ -121,6 +121,16 @@ fn synth_plays_notes_at_their_offsets() { p.all_notes_off(); p.process(&mut io, 64); assert!(io[0].iter().all(|x| x.abs() < 1e-6)); + // MIDI CC 7 reaches the parameter the plugin maps it to, and poly pressure arrives as an event, + // each at its offset; an unmapped controller is dropped. + p.midi(10, [0xA0, 60, 32]); + p.midi(20, [0xB0, 7, 64]); + p.midi(30, [0xB0, 1, 127]); + p.process(&mut io, 64); + assert!(io[0][9].abs() < 1e-6 && (io[0][10] - 32.0 / 127.0).abs() < 1e-6); + assert!((io[0][20] - 64.0 / 127.0).abs() < 1e-6 && (io[0][63] - 64.0 / 127.0).abs() < 1e-6); + p.midi(0, [0xB0, 7, 0]); + p.process(&mut io, 64); // Notes in a later sub-block of a chunked call land at the right frame. p.prepare(48_000.0, 32, 2); let mut io = vec![vec![0.0f32; 96], vec![0.0f32; 96]]; @@ -208,6 +218,55 @@ fn state_saves_component_and_controller_and_restores_them() { assert_eq!(q.param("7"), Some(1.0)); } +#[test] +fn state_reader_reads_while_another_thread_processes() { + 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)); + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + let mut reader = p.state_reader().expect("every VST3 component has a state"); + let blob = p.save_state().unwrap(); + let audio = std::thread::spawn(move || { + for _ in 0..2_000 { + p.process(&mut vec![vec![0.5f32; 64]; 2], 64); + } + p + }); + while !audio.is_finished() { + assert_eq!(reader.save_state().as_ref(), Some(&blob)); + } + let p = audio.join().unwrap(); + assert_eq!(reader.save_state(), Some(blob)); + drop(p); + assert_eq!(reader.save_state(), None); +} + +#[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/architecture.md b/docs/architecture.md index a041434..5bb0b21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (crate diagram brought up to date: video, vst3-host, au-host; device I/O limits) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (bounces stream to disk) · **Target:** Avid Pro Tools Ultimate 2026.4.1 SoundCraft is a Cargo workspace of small crates with enforced layering (`cargo xtask layers`). Nothing below the UI knows about egui, so the interface can be replaced. @@ -48,8 +48,9 @@ report. fades, clip effects) → trim → inserts → pre-fader sends → fader/mute (automation, VCA, trim automation) → post-fader sends → pan → busses → aux inputs → master faders. Independent strips process in parallel; plugin delay compensation aligns every path. The same engine renders -bounces offline (`render_range`) and realtime playback (`soundcraft_playback::Player`, which owns -it on the audio thread and receives new session snapshots through a channel). +bounces offline (`RangeRender`, streamed to the file block by block) and realtime playback +(`soundcraft_playback::Player`, which owns it on the audio thread and receives new session +snapshots through a channel). Device I/O today is the system's *default* output and *default* input device, opened as two separate cpal streams; the mix renders 512-sample blocks and resamples to the device rate when diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 5149502..0b13427 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format inventory against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its @@ -23,7 +23,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | OMF | import/export | ❌ | ❌ | Legacy, still delivered by some editors | | Media Composer–compatible session | export | ❌ | ❌ | AAF variant | | ADM BWF (Dolby Atmos master) | import/export | ❌ | ❌ | G-12 | -| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded | +| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded. Fades save optional `curvature` (-1..1); missing values default to 0 and preserve legacy envelopes | | Import Session Data (from another session) | Pro Tools sessions | 🟡 | — | From `.scraft` only | | Session info as text | export | — | ✅ | `file.export_session_text` | | Clip groups `.rgrp` / ours `.scgrp` | read/write | 🟡 | 🟡 | Our own `.scgrp` format; Pro Tools' clip-group files not read | @@ -80,4 +80,5 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged | | 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options | 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) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 80ff25b..4d6bb09 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (waveforms preview fades during drag) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -16,6 +16,9 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Feature | Status | Notes | |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | +| Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | +| Fade midpoint curvature | ✅ | Visible fades retain a small midpoint handle in Smart mode, including after release and extreme bends. Hover shows the vertical resize cursor; repeated drags change curvature without changing length. Curves sample at physical-pixel resolution; preview, playback and bounce share the envelope. Length drags retain curvature | +| Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -64,4 +67,10 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged | +| 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged | +| 2026-10-13 | minor | Continuous fade curvature from a midpoint drag, shared UI/audio envelope, one-step undo and saved session data. Readiness estimate unchanged | +| 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | +| 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | +| 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged | | 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 |