Merge remote-tracking branch 'origin/main' into new-midi-clip

# Conflicts:
#	crates/engine/src/cmd/edit.rs
This commit is contained in:
grovebit 2026-10-13 10:51:30 +01:00
commit 41f6670889
116 changed files with 7428 additions and 981 deletions

36
Cargo.lock generated
View File

@ -3424,7 +3424,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@ -3442,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"log",
"serde",
@ -3452,7 +3452,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3464,7 +3464,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"log",
"serde_json",
@ -3474,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"clap-sys",
"libloading",
@ -3486,7 +3486,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3508,7 +3508,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3530,7 +3530,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3540,7 +3540,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"log",
"rayon",
@ -3556,7 +3556,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"cpal",
"log",
@ -3582,7 +3582,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3592,7 +3592,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
@ -3618,7 +3618,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"image",
"proptest",
@ -3629,7 +3629,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"libloading",
"log",
@ -3644,7 +3644,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.0"
version = "0.6.1"
dependencies = [
"serde_json",
]

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.6.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"

View File

@ -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. `<ver>` 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. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows

View File

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

View File

@ -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<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> 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<Item = String>) -> Result<bool, String> {
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}=<dir> 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()));
}
}

View File

@ -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::<u32>().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<Option<u32>, String> {
match v {
None => Ok(None),
Some(r) => match r.parse::<u32>() {
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<soundcraft_audio_io::BitDepth, String> {
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<String> {
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<String>,
demo: bool,
roots: Option<std::sync::Arc<AutomationRoots>>,
}
/// 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<McpArgs, String> {
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"));
}

View File

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

View File

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

View File

@ -162,7 +162,7 @@ fn services() -> Services {
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
_ => d.add_filter(
"Audio",
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
),
};
d.pick_file().map(|p| p.to_string_lossy().into_owned())

View File

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

View File

@ -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<Option<u64>> {
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
}

View File

@ -1,8 +1,8 @@
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
//! partitioned Rice-coded residuals.
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
use crate::pcm::{Quantizer, validate_buffer};
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
use crate::{AudioBuffer, AudioError, BitDepth, Result};
const BLOCK_SIZE: usize = 4096;
@ -354,6 +354,54 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
Ok(out)
}
/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its
/// metadata block holds: symphonia allocates each field's length before reading it.
pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
let mut pos = at + 4;
while let Some(head) = be_u32(b, pos) {
let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize);
let end = (body + len).min(b.len());
let overruns = match (head >> 24) & 0x7f {
4 => comments_overrun(b, body, end),
// Picture: type, MIME type, description, four size fields, data.
6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]),
_ => false,
};
if overruns {
return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into()));
}
if head >> 31 == 1 {
break;
}
pos = body + len;
}
Ok(())
}
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
/// The vendor string is skipped unread.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body) else { return false };
let at = (body + 4).saturating_add(vendor as usize);
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
}
/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are
/// the bytes skipped before each length, which `read` reads.
fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option<u32>, gaps: impl IntoIterator<Item = usize>) -> bool {
for gap in gaps {
at = at.saturating_add(gap);
let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
at += 4;
if n as usize > end - at {
return true;
}
at += n as usize;
}
false
}
#[cfg(test)]
mod tests {
use super::*;

View File

@ -14,7 +14,9 @@
mod aiff;
mod buffer;
mod caf;
mod flac;
mod mp4;
mod pcm;
pub mod peaks;
mod symph;
@ -187,7 +189,7 @@ fn detect_by_ext(ext: &str) -> FileFormat {
"flac" => FileFormat::Flac,
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
"ogg" | "oga" => FileFormat::Ogg,
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
"caf" => FileFormat::Caf,
_ => FileFormat::Other,
}
@ -205,16 +207,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
wav::WavParse::Native(l) => Ok(l.info),
wav::WavParse::Foreign { bwf } => {
let mut info = symph::probe(bytes, Some("wav"), format)?;
let mut info = symph::probe(bytes, format)?;
info.bwf = bwf;
Ok(info)
}
},
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
aiff::AiffParse::Native(l) => Ok(l.info),
aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::probe(bytes, format),
},
_ => symph::probe(bytes, ext_hint, format),
_ => symph::probe(bytes, format),
}
}
@ -230,7 +232,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
Ok((info, buf))
}
wav::WavParse::Foreign { bwf } => {
let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?;
let (mut info, buf) = symph::decode(bytes, format)?;
info.bwf = bwf;
Ok((info, buf))
}
@ -242,9 +244,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
info.frames = buf.frames() as u64;
Ok((info, buf))
}
aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::decode(bytes, format),
},
_ => symph::decode(bytes, ext_hint, format),
_ => symph::decode(bytes, format),
}
}

106
crates/audio-io/src/mp4.rs Normal file
View File

@ -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<bool> {
// 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<bool> {
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<bool> {
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)
}

View File

@ -36,6 +36,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
Some(u32::from_be_bytes(s))
}
/// Big-endian `u64` at `pos`.
pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option<u64> {
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()

View File

@ -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<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
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<Box<dyn FormatReader>> {
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<Box<dyn FormatReader>> {
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(&params, &DecoderOptions::default()).map_err(map_err)?;
let mut planar: Vec<Vec<f32>> = 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<AudioInfo> {
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
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))
}

View File

@ -83,6 +83,57 @@ fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
})
}
/// 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<usize> {
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")

View File

@ -153,6 +153,194 @@ fn absurd_chunk_sizes() {
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
}
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
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<u8>) {
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<u8> {
[&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<u8> = (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<u8> {
[&(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<u8> {
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<u8>) -> Vec<u8>) -> Vec<u8> {
let full = |kind: &[u8; 4], fields: &[u32]| {
let body: Vec<u8> = [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<u8> = (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<u8>| [&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<u8>| {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
a
};
let sizes: Vec<u8> = (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::<Vec<u8>>();
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<u8>)> = 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);

View File

@ -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<Arc<AutomationRoots>>,
}
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<Value, String> {
/// 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<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
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<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, params))
}
fn has_ui(&self) -> bool {
false
}

View File

@ -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<Path>) -> 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::<Vec<_>>();
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<Headless> {
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<Headless>, 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<Headless>, command: &str, params: Value) -> String {
let (failed, text) = run(s, command, params);
assert!(!failed, "{command} failed: {text}");
text
}
fn refused(s: &mut Server<Headless>, 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<String> {
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<Headless>) -> 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<Headless> {
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());
}

View File

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

View File

@ -341,7 +341,14 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
return ch.to_vec();
}
let factor = 2f64.powf(f64::from(st) / 12.0);
let stretched = time_stretch(ch, factor, sample_rate);
// time_stretch clamps to 0.1..=10, short of the 16x (or 1/16) that ±48 needs: stretch twice by
// the square root instead.
let stretched = if (0.1..=10.0).contains(&factor) {
time_stretch(ch, factor, sample_rate)
} else {
let half = factor.sqrt();
time_stretch(&time_stretch(ch, half, sample_rate), half, sample_rate)
};
let len = max_len(ch);
let slen = max_len(&stretched);
if slen == 0 || len == 0 {
@ -443,39 +450,71 @@ pub fn detect_transients(ch: &[Vec<f32>], 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<f32>], 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<usize> = 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<u64>,
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<f32>]) {
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

View File

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

View File

@ -184,12 +184,18 @@ impl<const S: usize> Eq<S> {
}
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);
}

View File

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

View File

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

View File

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

View File

@ -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<usize> {
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<Clip>)> = Vec::new();
for id in ids {
@ -47,7 +50,6 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
};
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<usize> {
&& 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<usize> {
}
let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio };
let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
crate::io::write_path(&p, text)?;
Ok(n)
}
@ -82,7 +84,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
@ -98,7 +100,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
if !full.starts_with(&dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
}
let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?;
let bytes = crate::io::read_file(&full)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
decoded.insert(*old, (rel.clone(), buf));
}

View File

@ -40,12 +40,25 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
return Err(bad("audiosuite.process", "select audio clips first"));
}
let sr = e.session().sample_rate;
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
let most = match name.as_str() {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr.as_f64()),
_ => usize::MAX,
};
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels());
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
let mut tmp = e.session().clone();
@ -78,13 +91,9 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
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));

View File

@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
let ids = clip_ids_param(e, p);
let mut n = 0;
for id in ids {
if crate::io::render_clip_gain(e, id)? {
if crate::io::render_clip_gain(e, "clip.gain_render", id)? {
n += 1
}
}
@ -199,9 +199,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
continue;
}
let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);
let mut warp = base.clone();
warp.stretch = ratio;
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &warp, out_len)? else { continue };
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue };
if orig.is_none()
&& let soundcraft_model::ClipContent::Audio { source, offset } = base.content
{
@ -288,8 +286,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut made = 0;
for id in ids {
let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
if reps > 0 && c.loop_id.is_none() {
let lid = s.alloc();
if let Some(src) = s.find_clip_mut(id) {
src.loop_id = Some(lid);
}
c.loop_id = Some(lid);
}
let mut copies = Vec::new();
for k in 1..=reps {
let mut nc = c.clone();
@ -378,6 +383,45 @@ mod tests {
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
}
/// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`.
fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) {
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let cid = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
let mut e = crate::Engine::new(s);
e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap();
(e, t, cid)
}
fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 {
let range = e.session().find_clip(cid).unwrap().1.range();
soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs()))
}
/// Elastic Properties stretches the material the clip plays, by the requested ratio (#227).
#[test]
fn elastic_properties_stretches_the_clip_material() {
let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000);
assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost");
let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in");
for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] {
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap();
assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}");
}
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();

View File

@ -101,12 +101,13 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
} 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<Value> {
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<ClipId> = 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)]);

View File

@ -10,16 +10,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))),
cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection),
cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain(
e,
p,
"edit.cut_clip_gain"
)),
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| {
super::edit_more::copy_clip_gain(e, p)?;
reset_clip_gain(e, p, "edit.cut_clip_gain")
}),
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
e,
p,
@ -103,7 +102,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file),
cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill),
cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips),
cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist),
cmd!(
"edit.copy_to_new_playlist",
"New Playlist",
["Edit", "Copy Selection to..."],
Some("Alt+Shift+Down"),
"{}",
has_range,
copy_to_new_playlist
),
cmd!(
"edit.strip_silence",
"Strip Silence",
@ -125,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge),
cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| {
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
e.session_mut().edit.edit_mode = m;
@ -153,8 +160,28 @@ fn shuffle(s: &Session) -> bool {
s.edit.edit_mode == EditMode::Shuffle
}
fn has_last_selection(e: &Engine) -> std::result::Result<(), String> {
if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) }
}
/// Remember the selection `before` had when a command changed it, for Restore Last Selection.
pub(crate) fn remember_selection(e: &mut Engine, before: &Session) {
let (old, new) = (&before.edit, &e.session().edit);
if old.selection == new.selection && old.selected_tracks == new.selected_tracks {
return;
}
e.last_selection = Some((old.selection, old.selected_tracks.clone()));
}
/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so
/// invoking it again goes back.
fn restore_selection(e: &mut Engine, _: &Value) -> Result<Value> {
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<Value> {
@ -429,20 +456,25 @@ fn scale_selection(e: &mut Engine, f: f64) -> Result<Value> {
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<Value> {
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<usize> = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect();
let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) };
if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) {
let next = match (idxs.iter().min(), idxs.iter().max()) {
(Some(&top), _) if dir < 0 => s.tracks.iter().take(top).rev().find(shown),
(_, Some(&bottom)) if dir > 0 => s.tracks.iter().skip(bottom + 1).find(shown),
(None, _) => s.tracks.iter().find(shown),
_ => None,
}
.map(|t| t.id);
let s = e.session_mut();
if let Some(t) = next {
if dir < 0 {
s.edit.selected_tracks.insert(0, t);
} else {
s.edit.selected_tracks.push(t);
}
} else if idxs.is_empty()
&& let Some(t) = s.tracks.first()
{
s.edit.selected_tracks.push(t.id);
}
Ok(json!({"tracks": s.edit.selected_tracks}))
}
@ -591,13 +623,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut n = 0;
for id in ids {
let frames =
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
if let Some((frames, lo, hi)) = trim_limits(s, id)
&& let Some(c) = s.find_clip_mut(id)
{
let off = c.source_offset();
let new_start = c.start - off;
c.trim_start_to(new_start);
c.trim_end_to(new_start + frames);
c.trim_start_to(new_start.max(lo));
c.trim_end_to((new_start + frames).min(hi));
n += 1;
}
}
@ -610,18 +642,31 @@ fn trim_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
@ -665,7 +710,7 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut kept = 0;
for t in &tracks {
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post);
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
}
Ok(json!({"clips": kept}))
}
@ -715,7 +760,8 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
let dir = i64_or(p, "direction", 1).signum();
let count = i64_or(p, "count", 1).clamp(1, 1000);
let s = e.session();
let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
let value = if bool_or(p, "next", false) { s.edit.nudge.stepped(true) } else { s.edit.nudge };
let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) };
@ -760,7 +806,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count();
let mut n = 0;
for t in tracks {
if crate::io::consolidate(e, t, r)? {
if crate::io::consolidate(e, "edit.consolidate", t, r)? {
n += 1;
}
}
@ -829,4 +875,74 @@ mod tests {
assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]);
assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]);
}
/// A 60,000-frame source with `clips` placed on one track as (start, length, source offset).
fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec<ClipId>) {
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::<Vec<_>>();
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]]));
}
}

View File

@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"edit.move_to_target_playlist",
"Target Playlist",
["Edit", "Move Selection to..."],
None,
Some("Alt+Shift+T"),
"{tracks?, start?, end?} — copies to the target playlist and clears it from the main",
has_range,
|e, p| to_playlist(e, p, Dest::Target, true, "edit.move_to_target_playlist")
),
cmd!("edit.move_to_new_playlist", "New Playlist", ["Edit", "Move Selection to..."], None, "{tracks?, start?, end?}", has_range, |e, p| {
to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist")
}),
cmd!(
"edit.move_to_new_playlist",
"New Playlist",
["Edit", "Move Selection to..."],
Some("Alt+Shift+N"),
"{tracks?, start?, end?}",
has_range,
|e, p| to_playlist(e, p, Dest::New, true, "edit.move_to_new_playlist")
),
cmd!(
"edit.move_to_duplicate_playlist",
"Duplicate of Main Playlist",
@ -326,7 +332,7 @@ fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Re
Ok(json!({"lanes": lanes_n, "length": r.len()}))
}
fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
pub(super) fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.copy_clip_gain";
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
@ -1044,6 +1050,22 @@ mod tests {
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn cut_clip_gain_can_be_pasted() {
let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]);
e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap();
e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap();
// An older copy sits in the clipboard: the paste must get the cut gain, not this one.
e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap();
e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap();
e.execute("edit.cut_clip_gain", &json!({})).unwrap();
let src = e.session().track(t).unwrap().clips()[0].clone();
assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}");
e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap();
let c = e.session().track(t).unwrap().clips()[1].clone();
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn move_to_new_playlist_clears_main() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);

View File

@ -22,7 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(json!({"tick": tick, "bpm": bpm}))
}
fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
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<i64> = 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<Value> {
let num = i64_or(p, "numerator", 4).clamp(1, 64) as u32;
let den = i64_or(p, "denominator", 4).clamp(1, 64) as u32;
@ -314,7 +338,8 @@ fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
let at = position_param(e, "markers.edit", p, "at")?;
let s = e.session_mut();
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
if let Some(n) = p.get("rename").and_then(Value::as_str) {
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
m.name = n.to_string();
}
if let Some(c) = str_param(p, "comments") {
@ -376,4 +401,63 @@ mod tests {
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
}
/// Changing the initial meter keeps a later meter change at its bar (issue #250).
#[test]
fn earlier_meter_change_keeps_later_meter_on_its_bar() {
use soundcraft_time::BarBeat;
let mut e = crate::Engine::default();
assert_eq!(e.session().sample_rate.hz(), 48_000);
e.execute("event.meter", &json!({"numerator": 3, "denominator": 4, "at": 192_000})).unwrap();
e.execute("event.meter", &json!({"numerator": 6, "denominator": 4, "at": 0})).unwrap();
let map = &e.session().tempo;
let later = map.meters().get(1).copied().unwrap();
assert_eq!((later.numerator, later.denominator), (3, 4));
assert_eq!(map.bar_beat_at_tick(later.tick), BarBeat { bar: 3, beat: 1, tick: 0 });
let bar2_beat3 = BarBeat { bar: 2, beat: 3, tick: 0 };
assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(bar2_beat3)), bar2_beat3);
}
fn marker_name(e: &crate::Engine, id: u64) -> String {
e.session().markers.iter().find(|m| m.id.0 == id).map(|m| m.name.clone()).unwrap_or_default()
}
/// `markers.edit` documents `name`: with `id` or `number` selecting the location, it is the new name.
#[test]
fn edit_marker_renames_with_documented_name() {
let mut e = crate::Engine::default();
let verse = e.execute("markers.add", &json!({"name": "Verse", "at": 1000})).unwrap();
let outro = e.execute("markers.add", &json!({"name": "Outro", "at": 3000})).unwrap();
let (verse_id, outro_id) = (verse["id"].as_u64().unwrap(), outro["id"].as_u64().unwrap());
e.execute("markers.edit", &json!({"id": verse_id, "name": "Chorus"})).unwrap();
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Outro".into()));
e.execute("markers.edit", &json!({"number": outro["number"], "name": "Bridge"})).unwrap();
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Bridge".into()));
e.execute("markers.edit", &json!({"name": "Bridge", "comments": "last"})).unwrap();
let s = e.session();
let bridge = s.markers.iter().find(|m| m.id.0 == outro_id).unwrap();
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
}
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
#[test]
fn tempo_constant_is_bounded_by_its_range() {
let mut e = crate::Engine::default();
e.execute("event.tempo", &json!({"bpm": 100.0, "at": 0})).unwrap();
e.execute("event.tempo", &json!({"bpm": 140.0, "at": 48_000})).unwrap();
let s = e.session();
let (t0, t1) = (s.tempo.samples_to_ticks(24_000, s.sample_rate), s.tempo.samples_to_ticks(96_000, s.sample_rate));
let mid = s.tempo.samples_to_ticks(72_000, s.sample_rate);
let after = s.tempo.tempo_at_tick(t1);
e.execute("event.tempo_constant", &json!({"bpm": 90.0, "start": 24_000, "end": 96_000})).unwrap();
let tempo = &e.session().tempo;
assert_eq!(tempo.tempo_at_tick(t0 - 1), 100.0, "before the range");
assert_eq!(tempo.tempo_at_tick(t0), 90.0);
assert_eq!(tempo.tempo_at_tick(mid), 90.0, "the interior change is replaced");
assert_eq!(tempo.tempo_at_tick(t1), after, "the old tempo resumes at the end");
assert_eq!(after, 140.0);
}
}

View File

@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
|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<CommandSpec> {
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<CommandSpec> {
/// 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<Value> {
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);

View File

@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(
"midi.new_clip",
"New MIDI Clip",
[],
["Clip"],
None,
"{track, start?, end?|length?} (default: the edit selection, or 1 bar)",
has_tracks,

View File

@ -100,7 +100,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"Add Automation Breakpoint",
[],
None,
"{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value}",
"{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value, from?}",
has_selection,
set_point
),
@ -360,7 +360,8 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
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<Value> {
.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<AutoParam> {
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<Value> {
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(&param).set_point(at, v);
let lane = tr.lane_mut(&param);
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();

View File

@ -36,6 +36,8 @@ mod view;
mod view_more;
mod vst3;
pub(crate) use edit::remember_selection;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
pub type Enabled = fn(&Engine) -> std::result::Result<(), String>;
@ -275,7 +277,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
}
};
for f in fmts {
if let Ok(x) = soundcraft_time::parse_position(body, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) {
if let Ok(x) = s.parse_position(body, f) {
return Ok(Some(x));
}
}

View File

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

View File

@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
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)}))
}),
]
}

View File

@ -55,7 +55,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
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}")))?;

View File

@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
let r = soundcraft_time::Range::new(0, end);
let mut n = 0;
for t in tracks {
if crate::io::consolidate(e, t, r)? {
// Each track keeps its own length (its material plus a second), as before.
let tr = crate::io::bounded_to_track(e.session(), t, r);
if crate::io::consolidate(e, id, t, tr)? {
n += 1;
}
}
@ -318,7 +320,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result<Value> {
let at = s.edit.selection.start.max(0);
let frames_at = s.frame_rate.frames_at(at, s.sample_rate);
let start = ft * 16 + fr - frames_at;
s.edit.values.insert("feet_frames.start".into(), start as f64);
s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64);
Ok(json!({"feet_frames_start": start}))
}
@ -404,7 +406,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
/// Decode a source file of another session (paths relative to that session's folder).
fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io::AudioBuffer> {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
let bytes = std::fs::read(&p).ok()?;
let bytes = crate::io::read_file(&p).ok()?;
let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?;
if buf.sample_rate != sr && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
@ -416,7 +418,7 @@ fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io:
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
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<Vec<String>> =
@ -556,8 +558,7 @@ mod tests {
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap();
let s = e.session();
let shown =
soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode);
assert_eq!(shown, "01:00:00:00");
}

View File

@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"track": t}))
}),
cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))),
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))),
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, super::track_more::clear_trim),
cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click),
cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)),
cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)),
@ -128,7 +128,10 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!("track.vca", "Assign to VCA", [], None, "{tracks?, vca: VCA track id|name | null}", has_selection, |e, p| {
let tracks = tracks_required(e, "track.vca", p)?;
let vca = track_param(e, "track.vca", p, "vca")?;
let vca = match p.get("vca") {
Some(Value::Null) => None,
_ => track_param(e, "track.vca", p, "vca")?,
};
if let Some(v) = vca
&& e.session().track(v).is_none_or(|t| t.kind != TrackKind::Vca)
{
@ -680,4 +683,24 @@ mod tests {
assert!(matches!(kick.mixer.output, Route::Bus(_)));
assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output));
}
#[test]
fn vca_null_clears_the_assignment() {
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Member"})).unwrap();
e.execute("track.new", &json!({"kind": "vca", "name": "Master"})).unwrap();
let (member, master) = (e.session().tracks[0].id, e.session().tracks[1].id);
let vca = |e: &Engine| e.session().track(member).unwrap().mixer.vca;
e.execute("track.vca", &json!({"tracks": [member.0], "vca": master.0})).unwrap();
assert_eq!(vca(&e), Some(master.0));
// #237: null is documented as "no VCA"; anything else that is not a VCA is still an error.
for invalid in [json!(true), json!(999), json!("Missing"), json!(member.0)] {
assert!(e.execute("track.vca", &json!({"tracks": [member.0], "vca": invalid})).is_err());
assert_eq!(vca(&e), Some(master.0));
}
e.execute("track.vca", &json!({"tracks": [member.0], "vca": null})).unwrap();
assert_eq!(vca(&e), None);
assert!(e.undo());
assert_eq!(vca(&e), Some(master.0));
}
}

View File

@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
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<Value> {
Ok(json!({"tracks": n}))
}
pub(super) fn clear_trim(e: &mut Engine, p: &Value) -> Result<Value> {
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<Value> {
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<TrackId> = 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();

View File

@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
.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)}))

View File

@ -72,8 +72,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<CommandSpec> {
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<Value> {
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<GridValue> {
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<GridValue> {
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");
}
}

View File

@ -250,6 +250,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
n += 1;
}
}
pl.sort();
n
}

View File

@ -0,0 +1,487 @@
//! The folders automation may read and write.
//!
//! `soundcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` 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<Root>,
write: Option<Root>,
}
#[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<Self, String> {
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<Option<Arc<Self>>, 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<Path>, 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<AutomationRoots>),
Unconfined,
}
thread_local! {
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
static PROCESS: OnceLock<Arc<AutomationRoots>> = 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<AutomationRoots>) -> bool {
PROCESS.set(roots).is_ok()
}
/// Puts the scope it replaced back when dropped (also when `f` panics).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = previous);
}
}
fn with_scope<T>(scope: Option<Scope>, 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<T>(roots: Option<&Arc<AutomationRoots>>, 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<T>(f: impl FnOnce() -> T) -> T {
with_scope(Some(Scope::Unconfined), f)
}
/// The roots in force on this thread, if any.
pub fn current() -> Option<Arc<AutomationRoots>> {
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<Path>) -> 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<Path>) -> 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<PathBuf, String> {
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<OsString> = 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<AutomationRoots>) {
let base = std::env::temp_dir().join(format!("sc-file-access-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.scraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.scraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
(base, roots)
}
fn s(p: PathBuf) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn paths_inside_the_roots_pass_and_others_dont() {
let (base, roots) = folders("inside");
assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself");
assert_eq!(roots.check(s(base.join("in/sub/new.scraft")), Access::Read), Ok(()), "missing files are checked by their folder");
assert_eq!(roots.check(s(base.join("out/new/deeper/x.wav")), Access::Write), Ok(()), "and by their nearest folder");
let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
// Read and write access are separate.
let e = roots.check(s(base.join("in/a.scraft")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "{e}");
assert!(roots.check(s(base.join("out/x.wav")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_sibling_with_the_same_prefix_is_outside() {
let (base, roots) = folders("prefix");
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
assert!(roots.check(s(base.join("in2/b.scraft")), Access::Read).is_err(), "in2 is not inside in");
assert!(roots.check(format!("{}2/c.scraft", s(base.join("in"))), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_is_resolved_or_refused() {
let (base, roots) = folders("dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in")));
assert!(roots.check(up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
let back = format!("{}{sep}..{sep}a.scraft", s(base.join("in/sub")));
assert_eq!(roots.check(back, Access::Read), Ok(()), "`..` that stays inside");
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.scraft", s(base.join("out")));
let e = roots.check(missing, Access::Write).unwrap_err();
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
// The tests' working directory is the crate's folder: not inside the roots.
assert!(roots.check("a.scraft", Access::Read).unwrap_err().contains("outside the read root"));
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn links_are_followed_before_comparing() {
let (base, roots) = folders("links");
#[cfg(unix)]
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let made = |target: &Path, link: &Path| {
let made =
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
made.is_ok()
};
#[cfg(not(any(unix, windows)))]
let made = |_: &Path, _: &Path| false;
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
if !made(&base.join("outside/secret.scraft"), &base.join("in/leak.scraft")) {
eprintln!("skipped: symbolic links can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(roots.check(s(base.join("in/leak.scraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
assert!(made(&base.join("outside"), &base.join("out/away")));
let e = roots.check(s(base.join("out/away/new.wav")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "a folder link out: {e}");
assert!(made(&base.join("outside/gone.scraft"), &base.join("out/dangling.wav")));
let e = roots.check(s(base.join("out/dangling.wav")), Access::Write).unwrap_err();
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
// A link to a folder inside the root stays inside.
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
let (base, roots) = folders("case");
let upper = s(base.join("IN/a.scraft"));
assert_eq!(roots.check(upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(s(base.join("In/Sub/New.scraft")), Access::Read), Ok(()));
let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err());
assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn scopes_nest_and_stay_on_their_thread() {
let (base, roots) = folders("scopes");
let outside = s(base.join("outside/secret.scraft"));
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
confine(Some(&roots), || {
assert!(check_read(&outside).is_err());
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
assert!(check_read(&outside).is_err(), "restored after unconfined");
let o = outside.clone();
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
assert!(there, "another thread is not confined by this thread's scope");
});
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
assert!(r.is_err());
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn device_names() {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.scraft", "NUL "] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.scraft", "nul-x"] {
assert!(!is_device_name(f), "{f}");
}
}
/// Every file a command reads or writes by path goes through `io` (`read_file`, `write_file`
/// and the other checked helpers there), which check the roots. Engine code that reaches for
/// the file system itself would bypass them, so none may: every file under `src/` except
/// `io.rs`, this module and the test files. Audio is decoded into the session when it enters
/// it (import, open, sampler load), so playback and bounce read nothing from disk and need no
/// check of their own. No engine module reads a fixed folder (plug-in scans live in the host
/// crates), so nothing else is skipped.
#[test]
fn commands_do_not_touch_the_file_system_themselves() {
fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, files);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = vec![];
walk(&src, &mut files);
assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}");
let mut offenders = vec![];
for path in files {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs") || name.starts_with("tests") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
// Test modules (which build their own fixtures) come last in each file.
let code = text.split("#[cfg(test)]").next().unwrap_or("");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
let touches =
["std::fs", "fs::", "File::open", "File::create", "OpenOptions", ".is_file()", ".exists()", ".is_dir()", "canonicalize"]
.iter()
.any(|w| line.contains(w));
if !line.starts_with("//") && touches {
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
}
}
}
assert!(offenders.is_empty(), "use the checked helpers in crate::io (read_file, write_file, ...): {offenders:#?}");
}
}

View File

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

View File

@ -11,6 +11,50 @@ use std::sync::Arc;
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
// ─── Checked file access ──────────────────────────────────────────────────────────────────────
//
// Every file a command names by path is read and written through the helpers below, so the
// automation roots (`crate::file_access`, `soundcraft-cli mcp --automation-read-root` and
// `--automation-write-root`) are checked in one place. A test in `file_access` keeps the rest of
// the engine from calling `std::fs` itself. Audio is decoded into the session's pool the moment it
// enters it (import, open, sampler load), so playback and bounce read nothing from disk and
// need no check of their own.
/// The bytes of the file at `path`, which must be inside the automation read root when there is one.
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(EngineError::Io)?;
std::fs::read(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// [`read_file`] for a text file.
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
crate::file_access::check_read(path).map_err(EngineError::Io)?;
std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
/// reported as not found, so a command can't probe for files beyond it.
pub fn file_found(path: impl AsRef<Path>) -> 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<Path>) -> 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<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
crate::file_access::check_write(path).map_err(EngineError::Io)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// A relative `Audio Files/` path no other source in `s` already uses.
fn unique_audio_rel(s: &Session, stem: &str) -> String {
let stem = sanitize_name(stem);
@ -152,12 +196,13 @@ pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec<String> {
let mut missing = Vec::new();
for v in &mut s.videos {
let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) };
if p.is_file() {
if file_found(&p) {
continue;
}
let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default();
let near =
[session_dir.join(&name), session_dir.join("Video Files").join(&name)].into_iter().find(|c| !name.as_os_str().is_empty() && c.is_file());
let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)]
.into_iter()
.find(|c| !name.as_os_str().is_empty() && file_found(c));
match near {
Some(c) => v.path = c.to_string_lossy().into_owned(),
None => missing.push(format!("{} (video)", p.display())),
@ -303,7 +348,7 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re
/// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`.
pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result<Vec<String>> {
let s = e.session();
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
make_dir(dir)?;
let ext = match opts.format {
FileFormat::Aiff => "aif",
FileFormat::Flac => "flac",
@ -325,7 +370,7 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?;
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
write_path(&path, bytes)?;
out.push(path.to_string_lossy().into_owned());
}
Ok(out)
@ -349,20 +394,13 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Sample
}
/// Strip Silence: keep only the non-silent ranges of clips inside `r`.
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> usize {
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<usize> {
let r = bounded_to_track(s, t, r);
let audio = soundcraft_mix::render_clips(s, t, r);
let keep = soundcraft_dsp::offline::non_silent_ranges(
&audio,
thr,
usize::try_from(min_len).unwrap_or(0),
usize::try_from(pre).unwrap_or(0),
usize::try_from(post).unwrap_or(0),
);
let keep = non_silent(s, t, r, thr, min_len, pre, post)?;
let mut cut_points = vec![r.start];
for (a, b) in &keep {
cut_points.push(r.start + *a as i64);
cut_points.push(r.start + *b as i64);
cut_points.push(*a);
cut_points.push(*b);
}
cut_points.push(r.end);
for p in &cut_points {
@ -372,11 +410,10 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
let mut gaps = Vec::new();
let mut prev = r.start;
for (a, b) in &keep {
let a = r.start + *a as i64;
if a > prev {
gaps.push(Range::new(prev, a));
if *a > prev {
gaps.push(Range::new(prev, *a));
}
prev = r.start + *b as i64;
prev = *b;
}
if prev < r.end {
gaps.push(Range::new(prev, r.end));
@ -384,21 +421,67 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) {
pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end));
}
keep.len()
Ok(keep.len())
}
/// Consolidate the selection on a track into one new clip backed by a rendered source.
pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
/// 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<Vec<(Samples, Samples)>> {
let Some(tr) = s.track(t) else { return Ok(Vec::new()) };
let mut clips: Vec<Range> = 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<Range> = 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<bool> {
let s = e.session();
if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) {
let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) };
let start = r.start.max(0);
let len = r.end.saturating_sub(start);
if len <= 0 {
return Ok(false);
}
let r = bounded_to_track(s, t, r);
if r.is_empty() {
return Ok(false);
let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1);
if usize::try_from(len).map_or(true, |n| n > max) {
let secs = max as f64 / s.sample_rate.as_f64();
return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name)));
}
let r = Range::new(start, start + len);
let audio = soundcraft_mix::render_clips(s, t, r);
let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str()));
if audio.iter().any(|ch| ch.len() as i64 != len) {
return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into()));
}
let name = format!("{}_consolidated", tr.name);
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let s = e.session_mut();
let src = add_source(s, &name, buf, None, FileFormat::Wav);
@ -408,13 +491,28 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
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<bool> {
pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result<bool> {
let s = e.session();
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) };
if !c.is_audio() {
return Ok(false);
}
refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?;
let mut c2 = c.clone();
c2.fade_in = Default::default();
c2.fade_out = Default::default();
@ -449,6 +547,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
return Ok(None);
}
let range = Range::new(0, end + s.sample_rate.samples(2.0));
// The track renders through a main mix of at least two channels.
refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?;
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
let name = format!("{}.cm", tr.name);
let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2));
@ -479,7 +579,7 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
let s = e.session();
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
make_dir(dir)?;
let mut n = 0;
for id in ids {
let Some((t, c)) = s.find_clip(*id) else { continue };
@ -496,7 +596,7 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
}
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
write_path(&path, bytes)?;
n += 1;
}
Ok(n)
@ -541,6 +641,8 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
/// files written and the final path.
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
let p = session_file(path);
// Before any audio is written: a session saved outside the write root changes nothing.
crate::file_access::check_write(&p).map_err(EngineError::Io)?;
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let audio_dir = dir.join("Audio Files");
let mut s = session.clone();
@ -568,7 +670,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
continue;
}
let Some(audio) = s.pool.get(src.id) else { continue };
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
make_dir(&audio_dir)?;
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
@ -595,7 +697,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
make_dir(&dir)?;
// Atomic-ish write: temp file then rename.
let tmp = p.with_extension("scraft.tmp");
if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) {
@ -608,6 +710,8 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
/// old or the new contents, never a mix.
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
crate::file_access::check_write(file).map_err(EngineError::Io)?;
crate::file_access::check_write(tmp).map_err(EngineError::Io)?;
if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) {
let _ = std::fs::remove_file(tmp);
return Err(EngineError::Io(format!("{}: {err}", file.display())));
@ -636,6 +740,10 @@ fn back_up(file: &Path) {
return;
};
let backups = dir.join("Backups");
if let Err(err) = crate::file_access::check_write(&backups) {
log::warn!("no backup of {}: {err}", file.display());
return;
}
let prefix = format!("{stem} backup ");
let suffix = format!(".{ext}");
let numbers: Vec<u32> = std::fs::read_dir(&backups)
@ -659,7 +767,9 @@ fn back_up(file: &Path) {
/// extension and isn't an existing file.
pub fn session_file(path: &str) -> PathBuf {
let p = PathBuf::from(path);
if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
// Only a path the write root allows is probed, so a refused one can't be used to test for files.
let is_file = crate::file_access::check_write(&p).is_ok() && p.is_file();
if p.extension().is_none() && !is_file { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
}
/// True when `command` would write a session over the existing `file`. Save, Save As and Save
@ -708,12 +818,17 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let text = read_text(path)?;
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut missing = Vec::new();
for src in s.sources.clone() {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
// A session file is hostile input too: a path in it that leaves the read root is not read.
if let Err(why) = crate::file_access::check_read(&p) {
missing.push(why);
continue;
}
match std::fs::read(&p) {
Ok(bytes) => {
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
@ -774,6 +889,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io);
}
crate::file_access::check_write(path).map_err(EngineError::Io)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
@ -1139,7 +1255,10 @@ mod save_tests {
open_session(&mut missing, &path("A/A.scraft")).unwrap();
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
let mut r = Engine::default();
assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]);
assert_eq!(
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()]
);
let _ = std::fs::remove_dir_all(&dir);
}
@ -1229,6 +1348,43 @@ mod stem_tests {
}
}
#[cfg(test)]
mod strip_tests {
use super::*;
/// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone.
fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) {
let mut e = Engine::default();
e.execute("track.new", &json!({"format": "stereo"})).unwrap();
let t = e.session().tracks[0].id;
let s = e.session_mut();
let tone = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 48_000]; 2] };
let src = add_source(s, "tone", tone, None, FileFormat::Wav);
for at in starts {
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "tone", src, 0, *at, length));
}
(e, t)
}
#[test]
fn clips_past_what_one_render_holds_are_kept() {
// 100 minutes in: a stereo render stops at 2^28 frames, about 93 minutes.
let far = 48_000 * 6000;
let (mut e, t) = tones(&[0, far], 48_000);
let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": far + 96_000})).unwrap();
assert_eq!(r["clips"], 2);
assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far));
}
#[test]
fn more_than_a_day_of_clips_is_refused() {
let (mut e, t) = tones(&[0], 48_000 * 86_401);
assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 48_000i64 * 86_402})).is_err());
assert_eq!(e.session().track(t).unwrap().clips().len(), 1);
}
}
#[cfg(test)]
mod bundle_tests {
use super::*;

View File

@ -11,6 +11,7 @@ pub mod clip_group_file;
pub mod cmd;
pub mod demo;
pub mod edit;
pub mod file_access;
pub mod inspect;
pub mod io;
pub mod score;
@ -118,8 +119,13 @@ pub struct Engine {
dirty_at: Cell<(u64, bool)>,
/// Gesture key for undo coalescing (see `execute_merged`).
merge_key: Option<String>,
/// The key every merged gesture takes while held (see `hold_merge`).
merge_hold: Option<String>,
/// Messages for the UI status line / agents.
pub messages: Vec<String>,
/// 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<TrackId>)>,
}
impl Default for Engine {
@ -147,7 +153,9 @@ impl Engine {
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
merge_hold: None,
messages: Vec::new(),
last_selection: None,
}
}
@ -181,6 +189,7 @@ impl Engine {
self.saved = Arc::clone(&self.doc);
self.undo.clear();
self.redo.clear();
self.last_selection = None;
self.revision = self.revision.wrapping_add(1);
self.dirty = false;
}
@ -266,7 +275,6 @@ impl Engine {
if spec.view {
return self.execute_view(spec, params);
}
self.merge_key = None;
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
@ -280,8 +288,13 @@ impl Engine {
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
}
};
if !Arc::ptr_eq(&before, &self.doc) {
// Only a change to the document ends a gesture, so Stop doesn't split the pass it ends.
self.merge_key = None;
}
match result {
Ok(v) => {
cmd::remember_selection(self, &before);
if spec.undoable && !Arc::ptr_eq(&before, &self.doc) {
self.push_undo(spec.label, before);
}
@ -315,17 +328,26 @@ impl Engine {
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
/// with the same `key` collapse into a single undo step.
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<Value> {
let continuing = self.merge_key.as_deref() == Some(key);
let key = self.merge_hold.clone().unwrap_or_else(|| key.to_string());
let continuing = self.merge_key.as_ref() == Some(&key);
let before_len = self.undo.len();
let r = self.execute(id, params)?;
if continuing && self.undo.len() == before_len + 1 && before_len > 0 {
// Drop the step just pushed; the earlier one still holds the pre-gesture document.
self.undo.pop();
}
self.merge_key = Some(key.to_string());
self.merge_key = Some(key);
Ok(r)
}
/// While `key` is held, every `execute_merged` gesture takes it instead of its own, so they
/// all join one undo step (an automation pass and whatever is dragged during it).
pub fn hold_merge(&mut self, key: Option<&str>) {
if self.merge_hold.as_deref() != key {
self.merge_hold = key.map(str::to_string);
}
}
/// End a gesture started with `execute_merged`.
pub fn end_merge(&mut self) {
self.merge_key = None;
@ -400,6 +422,34 @@ mod merge_tests {
e.undo();
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
}
#[test]
fn a_held_merge_joins_other_gestures_into_its_step() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.hold_merge(Some("pass"));
for db in [-1.0, -2.0, -3.0] {
e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap();
e.execute_merged("mix.pan", &json!({"track": t.0, "pan": db / 4.0}), "pan").unwrap();
}
assert_eq!(e.undo_history().len(), 1);
e.hold_merge(None);
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -6.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 2);
}
#[test]
fn only_a_change_to_the_document_ends_a_gesture() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -1.0}), "fader").unwrap();
e.execute("transport.stop", &json!({})).unwrap();
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -2.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 1);
e.execute("mix.mute", &json!({"track": t.0})).unwrap();
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -3.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 3);
}
}
#[cfg(test)]

View File

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

View File

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

View File

@ -0,0 +1,49 @@
//! Renders longer than one offline buffer holds are refused, rather than cut short and kept.
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES;
use soundcraft_engine::Engine;
use soundcraft_mix::MAX_RENDER_SAMPLES;
use soundcraft_model::{ChannelFormat, Clip, ClipId, Session, TrackKind};
/// A stereo track "Talk" with one clip `frames` long. Its source is one second: the commands
/// refuse on the clip's length before they render anything.
fn long_clip(frames: usize) -> (Engine, ClipId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Talk"));
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 48_000]; 2] };
let src = soundcraft_engine::io::add_source(&mut s, "talk", buf, None, FileFormat::Wav);
let id = s.new_clip_id();
soundcraft_engine::edit::place_clip(&mut s, t, Clip::audio(id, "Talk", src, 0, 0, frames as i64));
(Engine::new(s), id)
}
fn refused(e: &mut Engine, cmd: &str, params: &Value) {
let before = serde_json::to_value(e.session()).unwrap();
let err = e.execute(cmd, params).expect_err(cmd).to_string();
assert!(err.contains("is too long to"), "{cmd}: {err}");
assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "{cmd} changed the session");
}
#[test]
fn clips_longer_than_one_render_are_refused() {
let (mut e, id) = long_clip(MAX_RENDER_SAMPLES / 2 + 1);
let dir = std::env::temp_dir().join(format!("soundcraft-long-renders-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
refused(&mut e, "clip.gain_render", &json!({"clips": [id.0]}));
refused(&mut e, "clip.effects_render", &json!({"clips": [id.0]}));
refused(&mut e, "audiosuite.process", &json!({"process": "reverse", "clips": [id.0]}));
refused(&mut e, "track.commit", &json!({"tracks": ["Talk"]}));
refused(&mut e, "file.export_clips", &json!({"dir": dir.to_string_lossy(), "clips": [id.0]}));
assert!(!dir.exists(), "Export Clips started writing before it refused");
}
#[test]
fn audiosuite_refuses_results_longer_than_one_render() {
let (mut e, id) = long_clip(MAX_OFFLINE_SAMPLES / 4 + 1);
let fourfold = [("time_stretch", json!({"ratio": 4.0})), ("pitch_shift", json!({"semitones": 24.0})), ("varispeed", json!({"speed": 0.25}))];
for (process, params) in fourfold {
refused(&mut e, "audiosuite.process", &json!({"process": process, "params": params, "clips": [id.0]}));
}
}

View File

@ -0,0 +1,30 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_time::Range;
fn render(e: &Engine) -> Vec<Vec<f32>> {
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");
}

View File

@ -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<_>>(), 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<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
}

View File

@ -58,6 +58,7 @@ use soundcraft_model::{
};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::ops::Bound;
use std::sync::Arc;
/// Default (stereo) main mix width. The actual width is [`main_channels`].
@ -251,6 +252,9 @@ impl DelayLine {
}
self.idx = (start + frames) % self.len.max(1);
}
fn clear(&mut self) {
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
}
}
/// Clip Effects: an EQ and a compressor per clip, configured from `edit.values`
@ -344,6 +348,13 @@ impl SlotCtx<'_> {
}
}
/// True when the host handed over an instance for this slot. It replaces whatever the slot
/// holds, even an instance of the same plugin: the host made it because it no longer tracks
/// the old one (it was removed and re-added between two renders), and drives its editor.
fn waiting(&self, t: TrackId, idx: usize) -> bool {
self.external && self.adopt.contains_key(&(t, idx))
}
/// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels.
fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option<PluginSlot> {
let third = is_third_party(&ins.plugin);
@ -474,6 +485,9 @@ impl MixEngine {
i.plugin.reset();
}
s.held.clear();
s.align.clear();
s.align_pre.clear();
s.out_extra.clear();
}
self.meters.clear();
self.main_meter = StripMeter::default();
@ -511,7 +525,7 @@ impl MixEngine {
strip.plugins.resize_with(t.mixer.inserts.len(), || None);
for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() {
match ins {
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => {
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => {
let old = slot.take();
cx.retire(old);
*slot = cx.make(t.id, idx, i, ch);
@ -525,7 +539,9 @@ impl MixEngine {
}
let want = t.instrument.as_ref();
let ich = ch.max(2);
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) {
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich))
|| (want.is_some() && cx.waiting(t.id, INSTRUMENT_SLOT))
{
let old = strip.instrument.take();
cx.retire(old);
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
@ -763,8 +779,7 @@ impl MixEngine {
let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law);
apply_matrix(src, frames, &mut dst.buf, &m, gain);
}
let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() };
match &t.mixer.output {
let src: &[Vec<f32>] = match &t.mixer.output {
Route::Main if strip.out_extra.len > 0 => {
if strip.outbuf.len() != strip.buf.len() {
strip.outbuf = vec![Vec::new(); strip.buf.len()];
@ -774,20 +789,25 @@ impl MixEngine {
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
}
strip.out_extra.process(&mut strip.outbuf, frames);
let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law);
apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0);
&strip.outbuf
}
Route::Main => {
let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law);
apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0);
}
Route::Bus(b) => {
if let Some(dst) = self.busses.get_mut(b) {
let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law);
apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0);
}
}
_ => {}
_ => &strip.buf,
};
let (dst, dst_fmt) = match &t.mixer.output {
Route::Main => (&mut self.main, self.main_fmt),
Route::Bus(b) => match self.busses.get_mut(b) {
Some(d) => (&mut d.buf, d.fmt),
None => return,
},
_ => return,
};
let mut start = 0;
while start < frames {
let (at, end) = pan_segment(t, pos, start, frames);
let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() };
let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law);
apply_matrix_span(src, start..end, dst, &m, 1.0);
start = end;
}
}
}
@ -852,7 +872,7 @@ fn process_strip(
}
render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch);
let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch));
fx.configure(&params);
fx.configure(params);
fx.process(&mut strip.clipbuf, frames);
for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) {
for (x, y) in d.iter_mut().zip(c.iter()).take(frames) {
@ -980,19 +1000,49 @@ fn process_strip(
}
/// Clip-effect parameters of a clip, or None when it has none or they are bypassed.
fn clip_fx_params(s: &Session, clip: u64) -> Option<Vec<(String, f32)>> {
if s.edit.values.is_empty() {
fn clip_fx_params(s: &Session, clip: u64) -> Option<impl Iterator<Item = (&str, f32)> + 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::<str, _>((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<Key> {
let mut k = Key { buf: [0; 64], len: 0 };
std::fmt::Write::write_fmt(&mut k, args).ok()?;
Some(k)
}
fn as_str(&self) -> &str {
self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
}
}
impl std::fmt::Write for Key {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?;
self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes());
self.len = end;
Ok(())
}
}
impl ClipFx {
@ -1006,10 +1056,10 @@ impl ClipFx {
})
}
/// Modules exist only when one of their parameters is set.
fn configure(&mut self, params: &[(String, f32)]) {
fn configure<'a>(&mut self, params: impl Iterator<Item = (&'a str, f32)> + Clone) {
self.gain_db = 0.0;
let has_eq = params.iter().any(|(k, _)| k.starts_with("eq."));
let has_comp = params.iter().any(|(k, _)| k.starts_with("comp."));
let has_eq = params.clone().any(|(k, _)| k.starts_with("eq."));
let has_comp = params.clone().any(|(k, _)| k.starts_with("comp."));
if has_eq && self.eq.is_none() {
self.eq = self.make("eq_7band");
} else if !has_eq {
@ -1023,18 +1073,18 @@ impl ClipFx {
for (k, v) in params {
if let Some(p) = k.strip_prefix("eq.") {
if let Some(eq) = &mut self.eq
&& eq.param(p) != Some(*v)
&& eq.param(p) != Some(v)
{
eq.set_param(p, *v);
eq.set_param(p, v);
}
} else if let Some(p) = k.strip_prefix("comp.") {
if let Some(c) = &mut self.comp
&& c.param(p) != Some(*v)
&& c.param(p) != Some(v)
{
c.set_param(p, *v);
c.set_param(p, v);
}
} else if k == "gain" {
self.gain_db = *v;
self.gain_db = v;
}
}
}
@ -1305,9 +1355,27 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
}
}
/// The track's MIDI Track Offset (`midi.offset.<id>` 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::<str, _>((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::<u64>().ok()) == Some(t.id.0))
.map(|(_, v)| *v)
.filter(|v| v.is_finite())
.map_or(0, |v| v as i64)
}
/// Send note on/offs for MIDI clips overlapping the block.
fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {
let sr = s.sample_rate;
// MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so
// the block looks that far back into the clips.
let offset = midi_offset(s, t);
let pos = pos.saturating_sub(offset);
let end = pos.saturating_add(frames as i64);
for clip in t.clips() {
if clip.muted {
@ -1319,7 +1387,7 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
let base = s.tempo.samples_to_ticks(clip.start, sr);
// MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay).
let rtp = |name: &str, d: f64| s.edit.value(&format!("rtp.{}.{name}", t.id.0), d);
let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d));
let (dv, dp, dur, delay) = if s.edit.values.is_empty() {
(0i64, 0i64, 1.0f64, 0i64)
} else {
@ -1458,6 +1526,23 @@ fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
base
}
/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block,
/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically.
const PAN_STEP: Samples = 64;
/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end
/// of the frames that share it. Without pan automation the whole block shares one pan.
fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) {
let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty());
if !automated {
return (pos, frames);
}
let here = pos.saturating_add(start as i64);
let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP);
let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1);
(at, start.saturating_add(left).min(frames))
}
fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
if t.mixer.automation_mode.reads()
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
@ -1707,6 +1792,11 @@ fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: u
/// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation).
fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], 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<f32>], span: std::ops::Range<usize>, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
let ns = src.len().min(MAX_CHANNELS);
for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) {
let mut idx = [(0usize, 0.0f32); MAX_CHANNELS];
@ -1724,7 +1814,7 @@ fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matri
if taps.is_empty() {
continue;
}
for (i, o) in out.iter_mut().enumerate().take(frames) {
for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) {
let mut acc = 0.0f32;
for &(s, g) in taps {
acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g;
@ -1852,17 +1942,14 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>>
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(&params);
fx.configure(params);
let mut done = 0;
while done < len {
let n = (len - done).min(4096);
@ -2063,6 +2150,22 @@ mod tests {
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
}
#[test]
fn pan_automation_does_not_depend_on_the_block_size() {
// Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244).
let (mut s, t) = session_with_dc(0.5, 4800);
{
let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0));
l.set_point(0, -1.0);
l.set_point(480, 1.0);
}
let bounce = render_range(&s, Range::new(0, 4800), 1024);
assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]);
for block in [512, 100, 37] {
assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}");
}
}
#[test]
fn fades_and_clip_gain_apply() {
let (mut s, t) = session_with_dc(1.0, 48_000);
@ -2168,6 +2271,55 @@ mod tests {
}
}
struct Keys {
inner: Box<dyn Plugin>,
heard: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
}
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<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.inner.process(io, frames);
}
fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) {
self.heard.lock().unwrap().push((note, velocity));
}
}
#[test]
fn real_time_properties_reach_the_instrument() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys"));
let mut seq = soundcraft_midi::Sequence::default();
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq));
s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0);
s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0);
let heard = Arc::new(std::sync::Mutex::new(Vec::new()));
let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) };
let mut eng = MixEngine::new(48_000.0, 512);
eng.set_external_instances(true);
eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]);
render_block(&mut eng, &s, 0);
assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]);
}
fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 {
let mut out = vec![vec![0.0f32; 512]; 2];
eng.render(s, pos, 512, &mut out);
@ -2218,6 +2370,31 @@ mod tests {
assert!(eng.drain_plugins().is_empty());
}
#[test]
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
// The host removed and re-added the plugin, and both session updates reached the audio
// thread in one go: the slot still holds the old instance (same id, same channels), but
// the host made a new one and drives its editor. The new one must win, or the editor
// talks to an instance that was retired ("the plugin is gone").
let (mut s, t) = session_with_dc(0.5, 48_000);
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake"));
let mut eng = MixEngine::new(48_000.0, 512);
eng.set_external_instances(true);
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let make = |blob: &[u8]| {
let mut gain = soundcraft_dsp::create("gain").unwrap();
gain.prepare(48_000.0, 512, 1);
Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) })
};
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]);
render_block(&mut eng, &s, 0);
assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]);
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]);
render_block(&mut eng, &s, 512);
assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]);
assert_eq!(eng.take_retired().len(), 1, "the old instance is retired");
}
#[test]
fn stored_state_comes_first_then_session_params() {
let (mut s, t) = session_with_dc(0.5, 48_000);
@ -2287,6 +2464,27 @@ mod tests {
assert_ne!(argmax(&out[0]), argmax(&out[1]));
}
/// A reset (stop, locate) must not leave the previous position's audio in the alignment delay.
#[test]
fn reset_clears_delay_compensation_history() {
let (mut s, t) = session_with_dc(0.5, 4800);
s.edit.click = false;
s.track_mut(t).unwrap().mixer.pan = vec![1.0];
let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut comp = Insert::new("compressor");
comp.params.insert("lookahead".into(), 5.0);
s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp);
let mut eng = MixEngine::new(48_000.0, 512);
let mut out = vec![vec![0.0f32; 512]; 2];
for b in 0..9 {
eng.render(&s, b * 512, 512, &mut out);
}
assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay");
eng.reset();
eng.render(&s, 1_000_000, 512, &mut out);
assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset");
}
fn impulse_at(ch: &[f32]) -> Vec<usize> {
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
}

View File

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

View File

@ -105,6 +105,10 @@ pub struct Clip {
/// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews).
#[serde(default)]
pub group: Option<u64>,
/// 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<u64>,
}
fn one() -> f64 {
@ -131,6 +135,7 @@ impl Clip {
color: None,
stretch: 1.0,
group: None,
loop_id: None,
}
}
@ -215,7 +220,12 @@ impl Clip {
self.length -= delta;
self.start = new_start;
self.clamp_fades();
self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect();
// Keep the gain at the cut when earlier breakpoints are dropped.
if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) {
self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta)));
}
self.gain_env.retain(|&(t, _)| t >= delta);
self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta));
}
/// Move the end edge to `new_end`.
@ -282,6 +292,27 @@ mod tests {
assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4);
}
#[test]
fn trim_start_keeps_gain_envelope_at_cut() {
for (env, cut, expected) in [
(vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]),
(vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]),
(vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]),
(vec![], 500, vec![]),
(vec![(100, -3.0)], 0, vec![(100, -3.0)]),
(vec![(100, -3.0)], -100, vec![(200, -3.0)]),
] {
let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000);
clip.gain_env = env;
let gain_at_cut = clip.gain_at(cut);
clip.trim_start_to(cut);
assert_eq!(clip.gain_env, expected);
if cut > 0 {
assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6);
}
}
}
#[test]
fn trims_keep_material_anchored() {
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000);

View File

@ -22,7 +22,7 @@ pub use markers::{Group, MarkerKind, MemoryLocation};
pub use mixer::{
Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db,
};
pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState};
pub use session::{BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState};
pub use source::{Source, SourceAudio, SourcePool, VideoSource};
pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind};

View File

@ -9,6 +9,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap
/// Native session file extension.
pub const SESSION_EXTENSION: &str = "scraft";
/// `EditState::values` key holding the feet+frames reading of sample 0, in frames.
pub const FEET_FRAMES_START: &str = "feet_frames.start";
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SessionError {
#[error("not a SoundCraft session: {0}")]
@ -421,6 +424,21 @@ impl Session {
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
}
/// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position).
pub fn feet_frames_start(&self) -> i64 {
self.edit.value(FEET_FRAMES_START, 0.0) as i64
}
/// Format a position against this session's timecode and feet+frames references.
pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String {
soundcraft_time::format_position(at, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
}
/// Parse a position typed in `fmt` against this session's references.
pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result<Samples, soundcraft_time::TimeError> {
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])

View File

@ -15,8 +15,9 @@
//! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored
//! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and
//! travel to the audio thread together with the session snapshot that needs them; the mix
//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires come back
//! on a return channel and are dropped on the UI thread ([`Player::idle`]). Plugin states for
//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires, and the
//! session snapshots the callback replaces, come back on return channels and are dropped on the UI
//! thread ([`Player::idle`]). Plugin states for
//! saving are read on request ([`Player::capture_states`]: a command with a reply; the audio
//! thread answers between two blocks, never blocking). Plugin editors are driven on the UI thread
//! through handles taken from each instance before it leaves ([`Player::open_editor`]).
@ -30,11 +31,14 @@ use soundcraft_model::{Session, TrackId};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
const BLOCK: usize = 512;
/// Room for replaced snapshots on their way back; `update_session` empties it before each send.
const OLD_SESSIONS: usize = 16;
/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
@ -55,6 +59,8 @@ pub type EditorEdit = (TrackId, usize, String, f32);
enum Cmd {
/// A new snapshot plus the third-party instances it needs (created on the UI thread).
Session(Arc<Session>, Vec<PreparedInstance>),
/// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees).
OldSessions(SyncSender<Arc<Session>>),
/// Read every plugin state and reply.
CaptureStates(Sender<PluginStates>),
Input(Arc<record::InputRing>),
@ -97,6 +103,7 @@ struct AudioState {
recording: bool,
/// Retired plugin instances go back to the UI thread to be dropped there.
garbage: Sender<Vec<Box<dyn Plugin>>>,
old_sessions: Option<SyncSender<Arc<Session>>>,
}
fn new_engine(sr: f32) -> MixEngine {
@ -112,6 +119,7 @@ impl AudioState {
let nch = main_channels(&session);
AudioState {
garbage,
old_sessions: None,
fold: fold_gains(&session),
rx,
shared,
@ -155,8 +163,12 @@ impl AudioState {
self.read = 0.0;
}
self.fold = fold_gains(&s);
self.session = s;
let old = std::mem::replace(&mut self.session, s);
if let Some(tx) = &self.old_sessions {
let _ = tx.try_send(old);
}
}
Cmd::OldSessions(tx) => self.old_sessions = Some(tx),
Cmd::Play { from, end, looped } => {
self.pos = from;
self.end = end;
@ -176,6 +188,7 @@ impl AudioState {
self.pos = at;
self.pending_len = 0;
self.read = 0.0;
self.mix.reset();
}
}
}
@ -326,14 +339,28 @@ struct Side {
/// Editor handles of the live instances.
editors: HashMap<(TrackId, usize), Box<dyn PluginEditor>>,
garbage: Receiver<Vec<Box<dyn Plugin>>>,
old_sessions: Option<Receiver<Arc<Session>>>,
}
impl Side {
/// Drops retired instances (closing their editors first, inside the plugin's drop).
/// Drops retired instances (closing their editors first, inside the plugin's drop) and
/// replaced session snapshots.
fn collect_garbage(&mut self) {
while let Ok(g) = self.garbage.try_recv() {
drop(g);
for p in g {
log::debug!("plugin instance released: {}", p.info().name);
drop(p);
}
}
if let Some(rx) = &self.old_sessions {
rx.try_iter().for_each(drop);
}
}
fn return_old_sessions(&mut self, tx: &Sender<Cmd>) {
let (otx, orx) = sync_channel(OLD_SESSIONS);
self.old_sessions = Some(orx);
let _ = tx.send(Cmd::OldSessions(otx));
}
fn forget(&mut self, key: (TrackId, usize)) {
@ -365,6 +392,7 @@ impl Player {
/// Open the default output device. Never fails: without a device it runs a silent clock.
pub fn new(session: Arc<Session>) -> Player {
let p = Player::open(Arc::clone(&session));
p.side().return_old_sessions(&p.tx);
// Hand the initial session's third-party plugins over.
p.update_session(session);
p
@ -376,7 +404,13 @@ impl Player {
fn open(session: Arc<Session>) -> Player {
let (gtx, grx) = channel();
let side = Mutex::new(Side { shadow: HashMap::new(), rate: session.sample_rate.as_f64() as f32, editors: HashMap::new(), garbage: grx });
let side = Mutex::new(Side {
shadow: HashMap::new(),
rate: session.sample_rate.as_f64() as f32,
editors: HashMap::new(),
garbage: grx,
old_sessions: None,
});
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
@ -681,6 +715,7 @@ impl Side {
}
self.forget(key);
let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);
log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some());
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
if let Some(mut plugin) = made {
if let Some(e) = plugin.editor() {
@ -916,7 +951,7 @@ mod tests {
fn side_creates_each_missing_instance_once() {
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
let (_gtx, grx) = channel();
let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx };
let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None };
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:does.not.exist"));
@ -930,6 +965,34 @@ mod tests {
assert!(side.shadow.is_empty());
}
#[test]
fn replaced_sessions_are_freed_on_the_ui_thread() {
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let (gtx, grx) = channel();
let mut st = AudioState::new(rx, shared, Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx);
let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None };
side.return_old_sessions(&tx);
let mut out = vec![0.0f32; 256];
let mut watched = Vec::new();
for _ in 0..3 * OLD_SESSIONS {
let s = Arc::new(soundcraft_model::Session::default());
watched.push(Arc::downgrade(&s));
side.collect_garbage();
tx.send(Cmd::Session(s, Vec::new())).unwrap();
st.fill(&mut out, 2);
let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i));
assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread");
}
side.collect_garbage();
assert_eq!(watched.iter().filter(|w| w.strong_count() > 0).count(), 1, "only the live snapshot is left");
}
#[test]
fn looping_wraps() {
let s = Arc::new(soundcraft_model::Session::default());
@ -949,6 +1012,49 @@ mod tests {
assert!(shared.playing.load(Ordering::Relaxed));
}
#[test]
fn locate_while_playing_drops_the_old_position_audio() {
use soundcraft_model::{ChannelFormat, Clip, Insert, SourceAudio, SourceId, TrackKind};
for (plugin, params) in [("compressor", &[("lookahead", 5.0), ("ratio", 1.0)][..]), ("room_reverb", &[][..])] {
let mut s = soundcraft_model::Session::default();
s.edit.click = false;
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(
SourceId(5),
Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })),
);
let id = s.new_clip_id();
let tr = s.track_mut(t).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 4800));
let mut ins = Insert::new(plugin);
for (k, v) in params {
ins.params.insert((*k).into(), *v);
}
tr.mixer.inserts[0] = Some(ins);
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
let mut out = vec![0.0f32; 2 * 512];
let mut before = 0.0f32;
for _ in 0..9 {
st.fill(&mut out, 2);
before = out.iter().fold(before, |m, x| m.max(x.abs()));
}
assert!(before > 0.1, "{plugin}: the clip plays before the locate");
tx.send(Cmd::Locate(1_000_000)).unwrap();
for _ in 0..8 {
st.fill(&mut out, 2);
assert!(out.iter().all(|x| x.abs() < 1e-6), "{plugin}: audio from before the locate");
}
}
}
#[test]
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
use soundcraft_model::{ChannelFormat, TrackKind};

View File

@ -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<Samples, TimeError> {
pub fn parse_position(
text: &str,
fmt: TimeFormat,
sr: SampleRate,
map: &TempoMap,
rate: FrameRate,
tc_start: Samples,
ff_start: i64,
) -> Result<Samples, TimeError> {
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<i64> =
@ -136,11 +145,20 @@ mod tests {
#[test]
fn formats_like_pro_tools_counters() {
let (sr, map, rate) = ctx();
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345");
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345");
}
#[test]
fn minimum_bar_parses_without_overflow() {
let (sr, map, rate) = ctx();
let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0);
assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0));
assert_eq!(p("2|3|000").ok(), Some(144_000));
assert_eq!(p("0|1|000").ok(), Some(-96_000));
}
#[test]
@ -148,8 +166,8 @@ mod tests {
let (sr, map, rate) = ctx();
for fmt in TimeFormat::ALL {
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
let text = format_position(s, fmt, sr, &map, rate, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap();
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
}
}
@ -158,9 +176,9 @@ mod tests {
#[test]
fn parse_rejects_garbage() {
let (sr, map, rate) = ctx();
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err());
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
}
#[test]

View File

@ -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<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
}
match self {
GridValue::Note { value, dotted, triplet } => {
let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0);
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
}
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds),
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
}
}
fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 {
let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() };
let mut t = base;
@ -199,4 +217,19 @@ mod tests {
let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false };
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
}
#[test]
fn values_step_to_the_next_larger_or_smaller() {
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false));
assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true));
assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false));
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
}
}

View File

@ -96,13 +96,31 @@ impl TempoMap {
}
/// Insert or replace the meter at `tick` (snapped to the bar line at or before it).
/// Later meter changes keep their bar numbers, so they stay on bar lines.
pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> {
if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) {
return Err(TimeError::Meter(numerator, denominator));
}
let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) };
let ev = MeterEvent { tick, numerator, denominator };
match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) {
let later = self.take_meters_after(tick);
self.put_meter(MeterEvent { tick, numerator, denominator });
self.restore_meters_at_bars(later);
Ok(())
}
/// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers.
pub fn remove_meter(&mut self, tick: i64) -> bool {
if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) {
return false;
}
let later = self.take_meters_after(tick);
self.meters.retain(|e| e.tick != tick);
self.restore_meters_at_bars(later);
true
}
fn put_meter(&mut self, ev: MeterEvent) {
match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) {
Ok(i) => {
if let Some(e) = self.meters.get_mut(i) {
*e = ev;
@ -110,16 +128,21 @@ impl TempoMap {
}
Err(i) => self.meters.insert(i, ev),
}
Ok(())
}
pub fn remove_meter(&mut self, tick: i64) -> bool {
if tick == 0 {
return false;
/// Remove the meters after `tick`, returning each with the bar it starts.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
self.meters.retain(|e| e.tick <= tick);
later
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
for (bar, ev) in later {
let tick = self.bar_start_tick(bar);
self.put_meter(MeterEvent { tick, ..ev });
}
let before = self.meters.len();
self.meters.retain(|e| e.tick != tick);
before != self.meters.len()
}
/// Tempo in effect at `tick`.
@ -200,7 +223,7 @@ impl TempoMap {
let mut cur_tick = 0i64;
let first = self.meter_at_tick(0);
if bar < 1 {
return (bar - 1).saturating_mul(first.ticks_per_bar());
return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar());
}
for (i, m) in self.meters.iter().enumerate() {
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
@ -288,6 +311,13 @@ mod tests {
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
}
#[test]
fn minimum_bar_saturates_instead_of_overflowing() {
let m = TempoMap::default();
assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN);
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
}
#[test]
fn tempo_change_is_integrated() {
let mut m = TempoMap::default();

View File

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

View File

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

View File

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

View File

@ -15,6 +15,7 @@ pub enum Dialog {
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
RenameTrack { id: TrackId, name: String },
RenameClip { id: ClipId, name: String },
RenameMarker { id: soundcraft_model::MarkerId, name: String },
ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
@ -42,6 +43,7 @@ impl Dialogs {
Dialog::Bounce { .. } => "bounce",
Dialog::RenameTrack { .. } => "rename_track",
Dialog::RenameClip { .. } => "rename_clip",
Dialog::RenameMarker { .. } => "rename_marker",
Dialog::ClearClips { .. } => "clear_clips",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
@ -63,6 +65,10 @@ impl Dialogs {
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
}
pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) {
self.open = Some(Dialog::RenameMarker { id, name: name.to_string() });
}
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
@ -261,6 +267,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Bounce { .. } => "Bounce Mix",
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::RenameMarker { .. } => "Rename Marker",
Dialog::ClearClips { .. } => "Clear",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
@ -379,6 +386,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
}
}
Dialog::RenameMarker { id, name } => {
name_field(ui, name);
if buttons(ui, "OK", enter) {
action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name})));
}
}
Dialog::ClearClips { clips, sources, message } => {
ui.set_max_width(340.0);
ui.label(message.as_str());

View File

@ -1,27 +1,43 @@
//! The Edit window: toolbar, rulers, track headers and playlists.
use crate::theme::{Tokens, bold, clip_colors, regular, rgb};
use crate::widgets::{rec_toggle, selector_box, text_toggle};
use crate::{Gesture, SoundApp, icons, toolbar};
use crate::widgets::{groove, level_pos, meter_pos, raised_key, shade};
use crate::{Gesture, SoundApp, toolbar};
use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Sense, Shape, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{AutoParam, Clip, ClipContent, ClipId, Session, Tool, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_time::{GridValue, NoteValue, Range, Samples, TimeFormat, format_position};
pub const HEADER_W: f32 = 236.0;
pub const HEADER_W: f32 = 136.0;
pub const COLUMN_W: f32 = 112.0;
/// Insert, send and instrument columns: Pro Tools' narrow stacks of slots.
const PLUGIN_COLUMN_W: f32 = 75.0;
/// The track colour strip at the left of each track header.
const STRIP_W: f32 = 14.0;
/// The row under the rulers that holds the column titles, when any column is shown.
const COLUMN_TITLES_H: f32 = 16.0;
/// Optional Edit-window columns (View › Edit Window Views) that are switched on.
pub fn edit_columns(s: &Session) -> Vec<&'static str> {
/// An optional Edit-window column: view id, menu label, title and width.
type Column = (&'static str, &'static str, &'static str, f32);
const COLUMNS: [Column; 7] = [
("instruments", "Instruments", "INSTRUMENT", PLUGIN_COLUMN_W),
("inserts_ae", "Inserts A-E", "INSERTS A-E", PLUGIN_COLUMN_W),
("inserts_fj", "Inserts F-J", "INSERTS F-J", PLUGIN_COLUMN_W),
("sends_ae", "Sends A-E", "SENDS A-E", PLUGIN_COLUMN_W),
("sends_fj", "Sends F-J", "SENDS F-J", PLUGIN_COLUMN_W),
("io", "I/O", "I/O", 94.0),
("comments", "Comments", "COMMENTS", COLUMN_W),
];
/// Optional Edit-window columns (View › Edit Window Views) that are switched on, left to right.
pub fn edit_columns(s: &Session) -> Vec<Column> {
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::<f32>()
}
pub const RULER_H: f32 = 17.0;
@ -186,7 +202,7 @@ pub(crate) fn main_area(app: &mut SoundApp, ui: &mut Ui) {
if session.edit.flag("view.ruler.tempo_editor") && session.edit.rulers.iter().any(|ruler| ruler == "tempo") {
ruler_height_total += ruler_height("tempo_editor");
}
let minimum = vec2(header_width(session) + 64.0, ruler_height_total + 100.0);
let minimum = vec2(header_width(&edit_columns(session)) + 64.0, ruler_height_total + 100.0);
crate::docking::minimum_body(ui, minimum, |ui| main_area_content(app, ui));
}
@ -200,9 +216,11 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
{
rulers.insert(i + 1, "tempo_editor".into());
}
let rulers_h = rulers.iter().map(|r| ruler_height(r)).sum::<f32>() + 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::<f32>() + 6.0 + titles_h;
let hscroll_h = 14.0;
let hw = header_width(app.engine.session());
let hw = header_width(&cols);
let header = Rect::from_min_max(full.min, pos2(full.min.x + hw, full.max.y - hscroll_h));
let tl = Rect::from_min_max(pos2(full.min.x + hw, full.min.y + rulers_h), pos2(full.max.x - 12.0, full.max.y - hscroll_h));
let rulers_rect = Rect::from_min_max(pos2(full.min.x, full.min.y), pos2(full.max.x, full.min.y + rulers_h));
@ -210,6 +228,8 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_filled(full, 0.0, t.window_bg);
scroll_and_zoom(app, ui, full, tl);
draw_rulers(app, ui, rulers_rect, tl, &rulers);
views_button(app, ui, rulers_rect);
column_titles(ui, rulers_rect, &cols);
// Tracks.
let mut y = tl.min.y - app.edit_layout.scroll_y;
app.edit_layout.rows.clear();
@ -227,13 +247,18 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
if row.max.y >= clip_rect.min.y && row.min.y <= clip_rect.max.y {
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(row));
child.set_clip_rect(clip_rect);
track_row(app, &mut child, TrackId(id), row, tl);
track_row(app, &mut child, TrackId(id), row, tl, &cols);
}
}
// Empty area click clears track selection.
// Below the tracks a click clears the selection and a double-click opens New Tracks.
let empty = Rect::from_min_max(pos2(full.min.x, y.max(tl.min.y)), tl.max);
if empty.height() > 4.0 && ui.interact(empty, ui.id().with("empty"), Sense::click()).clicked() {
let _ = app.run("edit.select_none", json!({}));
if empty.height() > 4.0 {
let resp = ui.interact(empty, ui.id().with("empty"), Sense::click());
if resp.double_clicked() {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
} else if resp.clicked() {
let _ = app.run("edit.select_none", json!({}));
}
}
overlay(app, ui, tl, Rect::from_min_max(pos2(tl.min.x, rulers_rect.min.y), tl.max));
let _ = header;
@ -425,7 +450,7 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
for (i, id) in rulers.iter().enumerate() {
let h = ruler_height(id);
let row = Rect::from_min_max(pos2(tl.min.x, y0), pos2(tl.max.x, y0 + h));
let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(tl.min.x, y0 + h));
let lab = Rect::from_min_max(pos2(area.min.x, y0), pos2(area.min.x + HEADER_W, y0 + h));
y0 += h;
let painter = ui.painter().with_clip_rect(row);
ui.painter().text(pos2(lab.max.x - 12.0, lab.center().y), Align2::RIGHT_CENTER, ruler_label(id), bold(11.0), t.ruler_text);
@ -535,18 +560,23 @@ fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect, rulers: &[
));
painter.text(pos2(x + 8.0, row.center().y), Align2::LEFT_CENTER, &m.name, bold(11.0), t.marker_text);
}
// Click on the marker ruler: recall a marker; double-click adds one.
// Click on the marker ruler: recall a marker; double-click renames it, or adds one.
let resp = ui.interact(row, ui.id().with(("marker_ruler", lane)), Sense::click());
if let Some(p) = resp.interact_pointer_pos() {
let hit = s
.markers
.iter()
.filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0))
.find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0);
if resp.double_clicked() {
let at = sample_at(&s, tl, p.x).max(0);
let _ = app.run("markers.add", json!({"at": at, "ruler": lane}));
match hit {
Some(m) => app.dialogs.open_rename_marker(m.id, &m.name),
None => {
let _ = app.run("markers.add", json!({"at": sample_at(&s, tl, p.x).max(0), "ruler": lane}));
}
}
} else if resp.clicked()
&& let Some(m) = s
.markers
.iter()
.filter(|m| m.ruler == lane || (lane == 1 && m.ruler == 0))
.find(|m| (x_of(&s, tl, m.start) - p.x).abs() < 8.0)
&& let Some(m) = hit
{
let _ = app.run("markers.recall", json!({"number": m.number}));
}
@ -655,13 +685,9 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row:
TimeFormat::BarsBeats => s.tempo.bar_beat_at(at, sr).bar.to_string(),
TimeFormat::MinSecs => {
let secs = sr.seconds(at);
if secs.fract().abs() < 1e-9 {
format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60)
} else {
format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start)
}
if secs.fract().abs() < 1e-9 { format!("{}:{:02}", (secs / 60.0) as i64, (secs as i64) % 60) } else { s.format_position(at, f) }
}
_ => format_position(at, f, sr, &s.tempo, s.frame_rate, s.timecode_start),
_ => s.format_position(at, f),
};
painter.text(pos2(x + 3.0, row.center().y), Align2::LEFT_CENTER, label, regular(11.0), t.ruler_text);
} else {
@ -673,31 +699,81 @@ fn timebase_ruler(_ui: &Ui, painter: &egui::Painter, s: &Session, tl: Rect, row:
}
}
/// Captions over the header columns, boxed in their own row under the rulers.
fn column_titles(ui: &Ui, rulers: Rect, cols: &[Column]) {
if cols.is_empty() {
return;
}
let t = Tokens::current();
let line = Stroke::new(1.0, t.border);
let mut x = rulers.min.x + HEADER_W;
let end = rulers.min.x + header_width(cols);
let row = Rect::from_min_max(pos2(x, rulers.max.y - COLUMN_TITLES_H - 3.0), pos2(end, rulers.max.y));
ui.painter().rect_filled(row, 0.0, t.panel_bg2);
ui.painter().hline(rulers.min.x..=end, row.min.y + 0.5, line);
ui.painter().hline(rulers.min.x..=end, row.max.y - 0.5, line);
ui.painter().vline(end - 0.5, row.y_range(), line);
for &(_, _, title, w) in cols {
ui.painter().vline(x + 0.5, row.y_range(), line);
ui.painter().text(pos2(x + w / 2.0, row.center().y), Align2::CENTER_CENTER, title, bold(10.0), t.section_label);
x += w;
}
}
/// The Edit window view selector: which columns sit beside the track headers.
fn views_button(app: &mut SoundApp, ui: &mut Ui, rulers: Rect) {
let r = Rect::from_min_size(rulers.min + vec2(6.0, 5.0), vec2(50.0, 18.0));
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r));
let resp = crate::widgets::selector_box(&mut child, r.width(), r.height(), "Views", Tokens::current().text);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, child.is_enabled(), "Views"));
let resp = resp.on_hover_text("Columns beside the track headers: inserts, sends, I/O…");
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
let shown = edit_columns(app.engine.session());
for (id, label, _, _) in COLUMNS {
if ui.add(egui::Button::new(label).selected(shown.iter().any(|c| c.0 == id))).clicked() {
let _ = app.run(&format!("view.edit_{id}"), json!({}));
}
}
ui.separator();
if ui.button("All").clicked() {
let _ = app.run("view.edit_all", json!({}));
}
if ui.button("Minimal").clicked() {
let _ = app.run("view.edit_minimal", json!({}));
}
});
}
// ---- track rows ------------------------------------------------------------------------------
fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect) {
fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect, cols: &[Column]) {
let t = Tokens::current();
let s = app.engine.session();
let Some(track) = s.track(id).cloned() else { return };
let selected = s.edit.selected_tracks.contains(&id);
let head = Rect::from_min_max(row.min, pos2(row.min.x + HEADER_W, row.max.y));
let cols = edit_columns(app.engine.session());
for (i, c) in cols.iter().enumerate() {
let cr = Rect::from_min_size(pos2(head.max.x + i as f32 * COLUMN_W, row.min.y), vec2(COLUMN_W, row.height()));
header_column(app, ui, &track, c, cr);
let (head_bg, lane_bg) = crate::theme::track_backgrounds(track.color, selected, app.ui.track_tint);
let mut x = head.max.x;
for &(col, _, _, w) in cols {
header_column(app, ui, &track, col, Rect::from_min_size(pos2(x, row.min.y), vec2(w, row.height())), head_bg);
x += w;
}
let main_h = track.height.points();
let lane = lane_rect(&track, row, tl);
// Header background.
ui.painter().rect_filled(head, 0.0, if selected { t.header_selected } else { t.panel_bg2 });
ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(5.0, head.height())), 0.0, rgb(track.color));
ui.painter().rect_filled(head, 0.0, head_bg);
ui.painter().rect_filled(Rect::from_min_size(head.min, vec2(STRIP_W, head.height())), 0.0, rgb(track.color));
ui.painter().line_segment([pos2(head.min.x, row.max.y + 0.5), pos2(tl.max.x, row.max.y + 0.5)], Stroke::new(1.0, t.border));
track_header(app, ui, &track, head, selected);
ui.painter().hline(head.min.x + STRIP_W..=head.max.x, head.min.y + 0.5, Stroke::new(1.0, t.edge_light));
track_header(app, ui, &track, head, selected, head_bg);
// Lane.
ui.painter().vline(tl.min.x - 0.5, row.y_range(), Stroke::new(1.0, t.border));
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
painter.rect_filled(lane, 0.0, if selected { t.playlist_alt } else { t.playlist_bg });
painter.rect_filled(lane, 0.0, lane_bg);
let s = app.engine.session().clone();
grid_lines(&painter, &s, tl, lane);
// Shown grid lines go over the clips; otherwise the bar lines stay behind them.
if !s.edit.grid_lines {
grid_lines(&painter, &s, tl, lane);
}
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
let auto_view = AutoParam::parse(&track.view);
for clip in track.clips() {
@ -707,6 +783,9 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect)
draw_clip(app, &painter, &s, &track, clip, tl, lane, auto_view.is_some());
}
crate::video_track::draw_lane(app, &painter, &s, &track, tl, lane);
if s.edit.grid_lines {
grid_lines(&painter, &s, tl, lane);
}
if let Some(p) = &auto_view {
draw_automation(&painter, &s, &track, p, tl, lane);
}
@ -768,7 +847,7 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
}
}
fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool) {
fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, selected: bool, head_bg: Color32) {
let t = Tokens::current();
let id = track.id;
// Drag the bottom edge to resize (snaps to the height presets).
@ -786,7 +865,6 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
let _ = app.run("track.height", json!({"tracks": [id.0], "height": best.label()}));
}
}
let x0 = head.min.x + 12.0;
let h = head.height();
// Click on header background selects the track.
let bg = ui.interact(head, ui.id().with(("hdr", id.0)), Sense::click());
@ -794,28 +872,61 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
click_track(app, id, ui.input(|i| i.modifiers), in_edit_window);
}
bg.context_menu(|ui| track_context_menu(app, ui, id));
// Name field.
let name_r = Rect::from_min_size(pos2(x0, head.min.y + 4.0), vec2(150.0, 17.0));
ui.painter().rect(
name_r,
CornerRadius::same(2),
if selected { t.name_field_sel } else { t.name_field },
Stroke::new(1.0, Color32::from_rgb(20, 20, 20)),
StrokeKind::Inside,
);
let at = |x: f32, y: f32, w: f32, h: f32| Rect::from_min_size(pos2(head.min.x + x, head.min.y + y), vec2(w, h));
let strip = rgb(track.color);
let ink = shade(strip, -0.8);
// The strip's round button opens a folder, or any other track's options.
let round = at(2.0, 2.0, 10.0, 10.0);
let rresp = ui.interact(round, ui.id().with(("options", id.0)), Sense::click());
ui.painter().circle_filled(round.center(), 5.0, shade(strip, if rresp.hovered() { 0.6 } else { 0.45 }));
arrow(ui.painter(), round.center(), !track.is_folder() || track.folder_open, ink);
if track.is_folder() {
if rresp.on_hover_text(if track.folder_open { "Close folder" } else { "Open folder" }).clicked() {
let _ = app.run("track.folder_toggle", json!({"track": id.0}));
}
} else {
egui::Popup::menu(&rresp.on_hover_text("Track options")).show(|ui| track_context_menu(app, ui, id));
}
if h >= 40.0 && track.kind.has_playlist() {
let ar = at(3.0, h - 12.0, 9.0, 9.0);
let aresp = ui.interact(ar, ui.id().with(("auto_lane", id.0)), Sense::click());
let showing = AutoParam::parse(&track.view).is_some();
ui.painter().rect_filled(ar, 1.5, shade(strip, if aresp.hovered() || showing { 0.6 } else { 0.45 }));
let (a, b) = (ar.min + vec2(2.5, 2.5), ar.max - vec2(2.5, 2.5));
ui.painter().line_segment([a, b], Stroke::new(1.0, ink));
ui.painter().circle_filled(a, 1.3, ink);
ui.painter().circle_filled(b, 1.3, ink);
if aresp.on_hover_text(if showing { "Hide automation" } else { "Show volume automation" }).clicked() {
let view = if !showing {
"volume"
} else if track.kind.is_midi() {
"notes"
} else {
"waveform"
};
let _ = app.run("track.view", json!({"track": id.0, "view": view}));
}
}
// Name, highlighted when the track is selected, and the playlist menu beside it.
let name_r = at(16.0, 1.5, 97.0, 15.0);
if selected {
ui.painter().rect_filled(name_r, 2.0, t.name_field_sel);
}
let shown_name = if app.engine.session().edit.flag("edit_view.track_number") {
let n = app.engine.session().track_index(id).map_or(0, |i| i + 1);
format!("{n} {}", track.name)
} else {
track.name.clone()
};
ui.painter().with_clip_rect(name_r).text(name_r.center(), Align2::CENTER_CENTER, &shown_name, bold(12.0), t.text_dark);
let name_color = if selected { t.text_dark } else { t.text.lerp_to_gamma(t.text_dim, 0.4) };
ui.painter().with_clip_rect(name_r).text(pos2(name_r.min.x + 1.5, name_r.center().y), Align2::LEFT_CENTER, &shown_name, bold(12.5), name_color);
let nresp = ui.interact(name_r, ui.id().with(("name", id.0)), Sense::click_and_drag());
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
} else if nresp.clicked() {
click_track(app, id, ui.input(|i| i.modifiers), in_edit_window);
}
nresp.context_menu(|ui| track_context_menu(app, ui, id));
// Drag the name plate to reorder tracks.
if nresp.dragged()
&& let Some(p) = ui.ctx().pointer_latest_pos()
@ -847,11 +958,11 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
let _ = app.run("track.move", json!({"track": id.0, "to": to}));
}
}
// Playlist selector arrow.
let pl_r = Rect::from_min_size(pos2(name_r.max.x + 3.0, name_r.min.y), vec2(16.0, 17.0));
icons::draw(ui.painter(), pl_r.shrink(3.0), "triangle_down", t.text);
let pl_r = at(114.0, 1.0, 11.0, 14.5);
let presp = ui.interact(pl_r, ui.id().with(("pl", id.0)), Sense::click());
egui::Popup::menu(&presp).show(|ui| {
ui.painter().rect_filled(pl_r, 1.5, shade(head_bg, if presp.hovered() { -0.2 } else { -0.35 }));
arrow(ui.painter(), pos2(pl_r.center().x, pl_r.min.y + 5.75), true, t.text);
egui::Popup::menu(&presp.on_hover_text("Playlists")).show(|ui| {
if ui.button("New...").clicked() {
let _ = app.run("track.playlist_new", json!({"track": id.0}));
}
@ -868,172 +979,237 @@ fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, sele
}
}
});
if track.is_folder() {
let fr = Rect::from_min_size(pos2(head.min.x + 6.0, name_r.min.y), vec2(10.0, 17.0));
icons::draw(ui.painter(), fr, if track.folder_open { "triangle_down" } else { "triangle_right" }, t.text);
if ui.interact(fr, ui.id().with(("fold", id.0)), Sense::click()).clicked() {
let _ = app.run("track.folder_toggle", json!({"track": id.0}));
}
}
let kind_label = match track.kind {
TrackKind::Audio => "",
TrackKind::Aux => "AUX",
TrackKind::Master => "MASTER",
TrackKind::Midi => "MIDI",
TrackKind::Instrument => "INST",
TrackKind::Vca => "VCA",
TrackKind::Folder => "FOLDER",
TrackKind::Video => "VIDEO",
};
if !kind_label.is_empty() {
let kr = ui.painter().text(pos2(pl_r.max.x + 4.0, name_r.center().y), Align2::LEFT_CENTER, kind_label, bold(9.0), t.text_dim);
if track.kind == TrackKind::Instrument {
// The instrument picker (and the instrument's editor) behind the INST label.
let name = track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("none", |p| p.name);
let resp = ui
.interact(kr.expand(3.0), ui.id().with(("instrument", id.0)), Sense::click())
.on_hover_text(format!("Instrument: {name} (click to change or open its editor)"));
egui::Popup::menu(&resp).show(|ui| crate::mix_window::instrument_menu(app, ui, id));
}
}
if h < 30.0 {
return;
}
// Buttons row.
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_size(pos2(x0, name_r.max.y + 5.0), vec2(170.0, 20.0)))
.layout(egui::Layout::left_to_right(egui::Align::Min)),
);
child.spacing_mut().item_spacing.x = 4.0;
let m = &track.mixer;
let bs = vec2(26.0, 18.0);
if track.kind.has_playlist() {
if rec_toggle(&mut child, bs, m.record_arm, "Record enable").clicked() {
let _ = app.run("mix.record_arm", json!({"track": id.0}));
}
if text_toggle(&mut child, bs, "I", m.input_monitor, t.input, "Input monitoring").clicked() {
let _ = app.run("mix.input_monitor", json!({"track": id.0}));
}
} else {
child.add_space(bs.x * 2.0 + 4.0);
}
if track.kind != TrackKind::Master {
if text_toggle(&mut child, bs, "S", m.solo, t.solo, "Solo").clicked() {
let _ = app.run("mix.solo", json!({"track": id.0}));
}
if text_toggle(&mut child, bs, "M", m.mute, t.mute, "Mute").clicked() {
let _ = app.run("mix.mute", json!({"track": id.0}));
}
}
if h < 60.0 {
return;
}
// View selector.
let view_r = Rect::from_min_size(pos2(x0, head.min.y + 49.0), vec2(118.0, 16.0));
let mut c2 = ui.new_child(egui::UiBuilder::new().max_rect(view_r));
let vresp = selector_box(&mut c2, view_r.width(), view_r.height(), &view_label(&track.view), t.text);
egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track));
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0, head.min.y + 68.0), vec2(118.0, 16.0));
let mode = m.automation_mode;
let col = match mode {
soundcraft_model::AutomationMode::Read => t.auto_read,
soundcraft_model::AutomationMode::Write => t.auto_write,
soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch,
soundcraft_model::AutomationMode::Latch => t.auto_latch,
_ => t.text_dim,
};
let mut c3 = ui.new_child(egui::UiBuilder::new().max_rect(am_r));
let aresp = selector_box(&mut c3, am_r.width(), am_r.height(), mode.label(), col);
egui::Popup::menu(&aresp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()}));
if h >= 37.0 {
let (records, mixes) = (track.kind.has_playlist(), track.kind != TrackKind::Master);
let implied = !m.solo && app.engine.session().tracks.iter().any(|t| t.mixer.solo);
let keys = [
(HeaderKey::Rec, m.record_arm, false, "mix.record_arm", records),
(HeaderKey::Input, m.input_monitor, false, "mix.input_monitor", records),
(HeaderKey::Solo, m.solo, false, "mix.solo", mixes),
(HeaderKey::Mute, m.mute, implied, "mix.mute", mixes),
];
for (i, (key, lit, implied, cmd, shown)) in keys.into_iter().enumerate() {
if shown && header_toggle(ui, at(23.0 + i as f32 * 26.0, 20.0, 16.0, 16.0), id, key, lit, implied).clicked() {
let _ = app.run(cmd, json!({"track": id.0}));
}
}
});
// Inline header meter.
let md = app.meters.get(&id).copied().unwrap_or_default();
let mr = Rect::from_min_max(pos2(head.max.x - 16.0, head.min.y + 4.0), pos2(head.max.x - 6.0, head.max.y - 4.0));
if track.channels() >= 2 {
let w = mr.width() / 2.0 - 0.5;
crate::widgets::meter(ui, Rect::from_min_size(mr.min, vec2(w, mr.height())), md.level[0], md.hold[0], md.clip);
crate::widgets::meter(ui, Rect::from_min_size(pos2(mr.min.x + w + 1.0, mr.min.y), vec2(w, mr.height())), md.level[1], md.hold[1], md.clip);
} else {
crate::widgets::meter(ui, mr, md.level[0], md.hold[0], md.clip);
}
// Volume readout and inserts summary.
let info = format!("vol {} pan {}", crate::widgets::db_text(m.volume_db), m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p)));
ui.painter().text(pos2(view_r.max.x + 8.0, view_r.center().y), Align2::LEFT_CENTER, info, regular(10.0), t.text_dim);
let ins: Vec<&str> = m.inserts.iter().flatten().filter_map(|i| soundcraft_dsp::plugin_info(&i.plugin).map(|p| p.short_name)).collect();
if !ins.is_empty() {
ui.painter().with_clip_rect(Rect::from_min_max(pos2(am_r.max.x + 6.0, am_r.min.y), pos2(head.max.x - 20.0, am_r.max.y))).text(
pos2(am_r.max.x + 8.0, am_r.center().y),
Align2::LEFT_CENTER,
ins.join(" · "),
regular(10.0),
t.text_dim,
);
if h >= 58.0 {
let vresp = header_selector(ui, at(17.0, 39.0, 105.0, 17.0), ui.id().with(("view", id.0)), &view_label(&track.view), t.text, true);
egui::Popup::menu(&vresp).show(|ui| view_menu(app, ui, track));
}
if h >= 76.0 {
if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Aux) {
let r = at(17.0, 57.0, 13.0, 17.0);
let resp = ui.interact(r, ui.id().with(("freeze", id.0)), Sense::click());
raised_key(ui.painter(), r, if track.frozen { t.slot_bypassed } else { t.slot_face }, resp.hovered());
for a in [0.0f32, 60.0, 120.0] {
let d = vec2(a.to_radians().sin(), a.to_radians().cos()) * 3.5;
ui.painter().line_segment([r.center() - d, r.center() + d], Stroke::new(1.5, t.text));
}
if resp.on_hover_text(if track.frozen { "Unfreeze" } else { "Freeze" }).clicked() {
let _ = app.run("track.freeze", json!({"tracks": [id.0]}));
}
}
if !matches!(track.kind, TrackKind::Master | TrackKind::Vca | TrackKind::Folder) {
let (voice, color) = if track.inactive { ("off", t.text_dim) } else { ("dyn", t.text) };
let resp = header_selector(ui, at(31.0, 57.0, 31.0, 17.0), ui.id().with(("voice", id.0)), voice, color, false);
egui::Popup::menu(&resp.on_hover_text("Voice: dyn plays the track, off makes it inactive")).show(|ui| {
for (label, inactive) in [("dyn", false), ("off", true)] {
if ui.selectable_label(track.inactive == inactive, label).clicked() {
let _ = app.run("track.make_inactive", json!({"tracks": [id.0], "inactive": inactive}));
}
}
});
}
let mode = m.automation_mode;
let col = match mode {
soundcraft_model::AutomationMode::Read => t.auto_read,
soundcraft_model::AutomationMode::Write => t.auto_write,
soundcraft_model::AutomationMode::Touch | soundcraft_model::AutomationMode::TouchLatch => t.auto_touch,
soundcraft_model::AutomationMode::Latch => t.auto_latch,
_ => t.text_dim,
};
let aresp = header_selector(ui, at(63.0, 57.0, 59.0, 17.0), ui.id().with(("auto_mode", id.0)), mode.label(), col, true);
egui::Popup::menu(&aresp).show(|ui| {
for m in soundcraft_model::AutomationMode::ALL {
if ui.selectable_label(m == mode, m.label()).clicked() {
let _ = app.run("mix.automation_mode", json!({"track": id.0, "mode": m.label()}));
}
}
});
}
if h >= 94.0 {
if track.kind.has_playlist() {
let r = at(17.0, 75.0, 13.0, 17.0);
let resp = ui.interact(r, ui.id().with(("timebase", id.0)), Sense::click());
raised_key(ui.painter(), r, t.slot_face, resp.hovered());
let c = r.center();
if track.ticks_timebase {
let green = Color32::from_rgb(86, 172, 103);
ui.painter().add(Shape::convex_polygon(
vec![c + vec2(-3.0, 3.25), c + vec2(3.0, 3.25), c + vec2(1.0, -3.25), c + vec2(-1.0, -3.25)],
green,
Stroke::NONE,
));
ui.painter().line_segment([c + vec2(0.0, 2.0), c + vec2(2.0, -3.0)], Stroke::new(1.0, t.text));
} else {
ui.painter().circle_filled(c, 3.25, Color32::from_rgb(100, 184, 249));
ui.painter().line_segment([c, c + vec2(0.0, -3.25)], Stroke::new(1.0, t.slot_face[1]));
ui.painter().line_segment([c, c + vec2(3.25, 0.0)], Stroke::new(1.0, t.slot_face[1]));
}
let tip = if track.ticks_timebase { "Timebase: ticks" } else { "Timebase: samples" };
egui::Popup::menu(&resp.on_hover_text(tip)).show(|ui| {
for (label, ticks) in [("Samples", false), ("Ticks", true)] {
if ui.selectable_label(track.ticks_timebase == ticks, label).clicked() {
let _ = app.run("track.timebase", json!({"tracks": [id.0], "ticks": ticks}));
}
}
});
}
let field = at(30.0, 75.0, 92.0, 17.0);
if matches!(track.kind, TrackKind::Audio | TrackKind::Instrument) {
let resp = ui.interact(field, ui.id().with(("field", id.0)), Sense::click());
ui.painter().rect_filled(field, 2.0, shade(head_bg, if resp.hovered() { -0.02 } else { -0.1 }));
if track.kind == TrackKind::Instrument {
let name =
track.instrument.as_ref().and_then(|i| crate::mix_window::plugin_info(&i.plugin)).map_or("no instrument", |p| p.short_name);
ui.painter().with_clip_rect(field).text(pos2(field.min.x + 5.0, field.center().y), Align2::LEFT_CENTER, name, bold(10.0), t.text);
egui::Popup::menu(&resp.on_hover_text("Instrument")).show(|ui| crate::mix_window::instrument_menu(app, ui, id));
} else {
let c = pos2(field.min.x + 7.0, field.center().y);
ui.painter().line_segment([c + vec2(0.0, -3.5), c + vec2(0.0, 1.0)], Stroke::new(1.0, t.text));
ui.painter().add(Shape::convex_polygon(vec![c + vec2(0.0, 0.5), c + vec2(3.0, 3.5), c + vec2(-3.0, 3.5)], t.text, Stroke::NONE));
if let Some(alg) = &track.elastic {
ui.painter().with_clip_rect(field).text(pos2(field.min.x + 14.0, field.center().y), Align2::LEFT_CENTER, alg, bold(10.0), t.text);
}
egui::Popup::menu(&resp.on_hover_text("Elastic Audio")).show(|ui| {
for alg in ["none", "polyphonic", "rhythmic", "monophonic", "varispeed", "x-form"] {
let current = track.elastic.as_deref().unwrap_or("none") == alg;
if ui.selectable_label(current, alg).clicked() {
let _ = app.run("track.elastic", json!({"tracks": [id.0], "algorithm": alg}));
}
}
});
}
}
}
let md = app.meters.get(&id).copied().unwrap_or_default();
header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2);
}
/// A small menu arrow, pointing down, or right for a closed folder.
fn arrow(p: &egui::Painter, c: Pos2, down: bool, color: Color32) {
let pts = if down {
vec![c + vec2(-2.5, -1.75), c + vec2(2.5, -1.75), c + vec2(0.0, 1.75)]
} else {
vec![c + vec2(-1.75, -2.5), c + vec2(1.75, 0.0), c + vec2(-1.75, 2.5)]
};
p.add(Shape::convex_polygon(pts, color, Stroke::NONE));
}
/// A header selector (track view, automation mode…): the value centred, and for menus an arrow
/// in the top-right corner.
fn header_selector(ui: &Ui, r: Rect, id: egui::Id, text: &str, color: Color32, menu: bool) -> egui::Response {
let resp = ui.interact(r, id, Sense::click());
raised_key(ui.painter(), r, Tokens::current().slot_face, resp.hovered());
let tr = if menu { r.with_max_x(r.max.x - 7.0) } else { r };
ui.painter().with_clip_rect(tr.shrink(1.0)).text(tr.center(), Align2::CENTER_CENTER, text, bold(10.0), color);
if menu {
arrow(ui.painter(), pos2(r.max.x - 5.5, r.min.y + 5.75), true, Tokens::current().text);
}
resp
}
#[derive(Clone, Copy, PartialEq)]
enum HeaderKey {
Rec,
Input,
Solo,
Mute,
}
/// Record, input, solo or mute: `lit` gives the key its colour, `implied` a dark shade of it (a
/// mute that another track's solo implies). Record keys stay dark red when off, as in Pro Tools.
fn header_toggle(ui: &Ui, r: Rect, track: TrackId, key: HeaderKey, lit: bool, implied: bool) -> egui::Response {
let t = Tokens::current();
let (label, color) = match key {
HeaderKey::Rec => ("Record enable", t.rec),
HeaderKey::Input => ("Input monitoring", t.input),
HeaderKey::Solo => ("Solo", t.solo),
HeaderKey::Mute => ("Mute", t.mute),
};
let resp = ui.interact(r, ui.id().with((label, track.0)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), lit, label));
let rec = key == HeaderKey::Rec;
let (tint, k) = if lit {
(color, 0.85)
} else if implied {
(shade(color, -0.35), 0.55)
} else if rec {
(shade(color, -0.5), 0.35)
} else {
(color, 0.0)
};
let [top, bottom] = t.slot_face.map(|f| f.lerp_to_gamma(tint, k));
raised_key(ui.painter(), r, [shade(top, 0.08), shade(bottom, -0.12)], resp.hovered());
let ink = if lit {
t.text_dark
} else if implied {
t.text.lerp_to_gamma(color, 0.3)
} else {
t.text
};
if rec {
ui.painter().circle_filled(r.center(), 4.0, if lit { Color32::WHITE } else { t.text.lerp_to_gamma(color, 0.3) });
} else {
ui.painter().text(r.center(), Align2::CENTER_CENTER, label.get(..1).unwrap_or_default(), bold(11.0), ink);
}
resp.on_hover_text(label)
}
/// The header's level meter: black-rimmed bars, dim where unlit, with a clip light on top.
fn header_meter(ui: &Ui, r: Rect, md: &crate::MeterDisplay, stereo: bool) {
let t = Tokens::current();
let p = ui.painter();
p.rect_filled(r, 0.0, Color32::BLACK);
let inner = r.shrink(1.0);
let clip = inner.with_max_y(inner.min.y + 4.0);
p.rect_filled(clip, 0.0, if md.clip { t.meter_red } else { t.meter_red.gamma_multiply(0.18) });
let body = inner.with_min_y(clip.max.y + 1.0);
let n = if stereo { 2 } else { 1 };
let w = (body.width() - (n - 1) as f32) / n as f32;
let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0));
for ch in 0..n {
let bar = Rect::from_min_size(pos2(body.min.x + ch as f32 * (w + 1.0), body.min.y), vec2(w, body.height()));
let y = |f: f32| bar.max.y - bar.height() * f;
let lv = level_pos(md.level[ch]);
for (lo, hi, col) in [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)] {
p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(lo)), 0.0, col.gamma_multiply(0.18));
if lv > lo {
p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(lv.min(hi))..=y(lo)), 0.0, col);
}
}
let hv = level_pos(md.hold[ch]);
if hv > 0.01 {
p.hline(bar.x_range(), y(hv), Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow }));
}
}
}
/// A header column's own Ui, so its slots' ids can't collide with the track header's.
fn column_ui(ui: &mut Ui, col: &str, r: Rect) -> Ui {
ui.new_child(egui::UiBuilder::new().id_salt(("edit_column", col)).max_rect(r))
}
/// One optional Edit-window column for a track (I/O, inserts, sends, comments).
fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) {
let t = Tokens::current();
ui.painter().rect_filled(r, 0.0, t.panel_bg);
ui.painter().line_segment([pos2(r.min.x, r.min.y), pos2(r.min.x, r.max.y)], Stroke::new(1.0, t.border));
let line_h = 15.0;
let rows = ((r.height() - 4.0) / line_h).floor().max(1.0) as usize;
let item = |i: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 3.0 + i as f32 * line_h), vec2(r.width() - 8.0, line_h - 2.0));
let text = |ui: &Ui, rr: Rect, s: &str, c: Color32| {
ui.painter().rect_filled(rr, 2.0, t.slot_bg);
ui.painter().with_clip_rect(rr).text(pos2(rr.min.x + 4.0, rr.center().y), Align2::LEFT_CENTER, s, regular(10.0), c);
};
let route = |r: &soundcraft_model::Route| match r {
soundcraft_model::Route::None => "none".to_string(),
soundcraft_model::Route::Main => "Out 1-2".to_string(),
soundcraft_model::Route::Bus(b) => app.engine.session().bus(*b).map_or("bus".into(), |b| b.name.clone()),
soundcraft_model::Route::Hardware(h) => h.clone(),
};
fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect, head_bg: Color32) {
match col {
"io" => {
if rows >= 1 {
text(ui, item(0), &format!("in: {}", route(&track.mixer.input)), t.text);
}
if rows >= 2 {
text(ui, item(1), &format!("out: {}", route(&track.mixer.output)), t.text);
}
}
"inserts_ae" | "inserts_fj" => {
let off = if col == "inserts_ae" { 0 } else { 5 };
for i in 0..5.min(rows) {
let name = track
.mixer
.inserts
.get(off + i)
.cloned()
.flatten()
.and_then(|x| soundcraft_dsp::plugin_info(&x.plugin).map(|p| p.name))
.unwrap_or("");
text(ui, item(i), name, t.text);
}
}
"sends_ae" | "sends_fj" => {
let off = if col == "sends_ae" { 0 } else { 5 };
for i in 0..5.min(rows) {
let s = track
.mixer
.sends
.get(off + i)
.cloned()
.flatten()
.map(|x| format!("{} {:.0}", route(&x.target), x.level_db.max(-99.0)))
.unwrap_or_default();
text(ui, item(i), &s, t.text);
}
}
_ => {
"io" => io_column(app, ui, track, r),
"comments" => {
ui.painter().rect_filled(r, 0.0, head_bg);
ui.painter().vline(r.min.x, r.y_range(), Stroke::new(1.0, Tokens::current().border));
let mut c = track.comments.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r.shrink(3.0)));
if child.add(egui::TextEdit::multiline(&mut c).desired_width(r.width() - 6.0).font(regular(10.0))).changed() {
@ -1041,6 +1217,82 @@ fn header_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: R
app.engine.execute_merged("track.comments", &json!({"track": track.id.0, "comments": c}), &format!("comments:{}", track.id.0));
}
}
_ => plugin_column(app, ui, track, col, r),
}
}
/// The I/O column as Pro Tools draws it: input and output selectors, then volume and pan.
fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let t = Tokens::current();
let id = track.id;
ui.painter().rect_filled(r, 0.0, t.slot_well);
let ui = column_ui(ui, "io", r);
let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h)));
if let Some(input) = row(4.0, 15.0) {
let resp = header_selector(&ui, input, ui.id().with(("in", id.0)), &crate::mix_window::input_name(app, track), t.text, true);
crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true);
}
if let Some(out) = row(21.0, 17.0) {
let (sel, fader) = (out.with_max_x(out.max.x - 11.0), out.with_min_x(out.max.x - 11.0));
let resp = ui.interact(sel, ui.id().with(("out", id.0)), Sense::click());
let fresp = ui.interact(fader, ui.id().with(("out_fader", id.0)), Sense::click());
raised_key(ui.painter(), out, t.slot_face, resp.hovered() || fresp.hovered());
groove(ui.painter(), fader.min.x - 0.5, out.min.y + 1.0..=out.max.y - 1.0, t.slot_face[0]);
let name = crate::mix_window::route_name(app, &track.mixer.output);
ui.painter().with_clip_rect(sel.shrink(1.0)).text(sel.center(), Align2::CENTER_CENTER, name, bold(10.0), t.text);
let c = fader.center() + vec2(0.5, 0.0);
ui.painter().vline(c.x, c.y - 5.0..=c.y + 5.0, Stroke::new(1.0, t.text));
ui.painter().rect_filled(Rect::from_center_size(c + vec2(0.0, -1.5), vec2(5.0, 2.5)), 0.5, t.text);
crate::mix_window::route_popup(app, &resp.on_hover_text("Output"), id, false);
if fresp.on_hover_text("Show in the Mix window").clicked() {
let _ = app.run("edit.select", json!({"tracks": [id.0]}));
let _ = app.run("window.mix", json!({}));
}
}
if let Some(vr) = row(45.0, 30.0).map(|vr| vr.shrink2(vec2(1.0, 0.0))) {
ui.painter().rect_filled(vr, 2.0, Color32::from_gray(15));
ui.painter().rect_filled(vr.shrink(1.5), 1.5, Color32::BLACK);
let m = &track.mixer;
let pan = m.pan.first().map_or("-".into(), |p| crate::widgets::pan_text(*p));
// The readout is black in both themes, so it takes the dark palette's green.
let ink = Tokens::DARK.counter_text;
for (y, label, value) in [(7.5, "vol", crate::widgets::db_text(m.volume_db)), (22.0, "pan", pan)] {
ui.painter().text(pos2(vr.min.x + 7.0, vr.min.y + y), Align2::LEFT_CENTER, label, bold(10.0), ink);
ui.painter().text(pos2(vr.max.x - 6.5, vr.min.y + y), Align2::RIGHT_CENTER, value, bold(10.0), ink);
}
ui.painter().hline(vr.min.x + 7.0..=vr.max.x - 6.0, vr.min.y + 14.5, Stroke::new(1.0, Color32::from_gray(35)));
}
}
/// Inserts, sends or the instrument, laid out as Pro Tools does: 15-point cells every 18 points
/// on a neutral well, or on tracks too short for three of them, a row of squares.
fn plugin_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, col: &str, r: Rect) {
let t = Tokens::current();
ui.painter().rect_filled(r, 0.0, t.slot_well);
let rows = if r.height() < 16.0 { 0 } else { ((r.height() - 16.0) / 18.0) as usize + 1 };
let cell = |row: usize| Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 1.0 + row as f32 * 18.0), vec2(r.width() - 8.0, 15.0));
let square = |i: usize, row: usize| Rect::from_min_size(cell(row).min + vec2(i as f32 * 14.0, 0.0), vec2(10.0, 15.0));
let mut ui = column_ui(ui, col, r);
if col == "instruments" {
if rows > 0 && track.kind == TrackKind::Instrument {
crate::mix_window::instrument_slot(app, &mut ui, track, cell(0));
}
return;
}
let off = if col.ends_with("ae") { 0 } else { 5 };
let slot = if col.starts_with("inserts") { crate::mix_window::insert_slot } else { crate::mix_window::send_slot };
let n = match rows {
0 => 0,
1 | 2 => 5,
_ => rows.min(5),
};
for i in 0..n {
slot(app, &mut ui, track, off + i, if rows < 3 { square(i, 0) } else { cell(i) });
}
if rows == 2 {
for i in 0..5 {
ui.painter().rect_filled(square(i, 1), 1.5, t.slot_empty);
}
}
}
@ -1079,7 +1331,7 @@ fn view_menu(app: &mut SoundApp, ui: &mut Ui, track: &Track) {
}
}
fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
pub(crate) fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
let tid = id.0;
let items: [(&str, &str); 10] = [
("Rename...", "ui.rename_track"),
@ -1130,22 +1382,35 @@ fn grid_lines(painter: &egui::Painter, s: &Session, tl: Rect, lane: Rect) {
let bar = GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false };
let bar_px = bar.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if bar_px > 6.0 {
let col = grid_veil(t.bar_line, t.playlist_bg);
for at in bar.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 600) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.bar_line));
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
}
if s.edit.grid_lines {
let step_px = s.edit.grid.step_samples(view.start, sr, &s.tempo, s.frame_rate) as f64 / s.edit.zoom.samples_per_px;
if step_px > 7.0 {
let col = grid_veil(t.grid_line, t.playlist_bg);
for at in s.edit.grid.lines(view.start, view.end, sr, &s.tempo, s.frame_rate, 2000) {
let x = x_of(s, tl, at);
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, t.grid_line));
painter.line_segment([pos2(x, lane.min.y), pos2(x, lane.max.y)], Stroke::new(1.0, col));
}
}
}
}
/// The translucent white or black that turns `bg` into `line`, so grid lines also show over clips.
fn grid_veil(line: Color32, bg: Color32) -> Color32 {
let level = |c: Color32| (f32::from(c.r()) + f32::from(c.g()) + f32::from(c.b())) / 3.0;
let (l, b) = (level(line), level(bg));
if l > b {
Color32::from_white_alpha(((l - b) / (255.0 - b) * 255.0).round() as u8)
} else {
Color32::from_black_alpha(((b - l) / b.max(1.0) * 255.0).round() as u8)
}
}
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
let x0 = x_of(s, tl, clip.start);
let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);
@ -1325,7 +1590,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
painter.line_segment([pos2(x, r.min.y + name_h), pos2(x, r.max.y)], Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 255, 255, 90)));
}
let time_text = if s.edit.flag("view.clip.time.current") {
Some(format_position(clip.start, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start))
Some(s.format_position(clip.start, s.edit.main_counter))
} else if s.edit.flag("view.clip.time.original") || s.edit.flag("view.clip.time.user") {
clip.source().and_then(|src| s.source(src)).map(|src| {
format_position(
@ -1335,6 +1600,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
&s.tempo,
s.frame_rate,
0,
0,
)
})
} else {
@ -1636,7 +1902,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
Tool::Pencil => {
if let Some(param) = &auto_view {
let (lo, hi, _) = param.range();
let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) { (-60.0, 12.0) } else { (lo, hi) };
let (lo, hi) = if matches!(param, AutoParam::Volume | AutoParam::SendLevel(_)) {
(-60.0, 12.0)
} else if matches!(param, AutoParam::Plugin { .. }) {
plugin_range(track, param)
} else {
(lo, hi)
};
let k = ((lane.max.y - 4.0 - p.y) / (lane.height() - 8.0)).clamp(0.0, 1.0);
let _ = app.run(
"automation.set_point",
@ -2494,4 +2766,24 @@ mod tests {
let want: Vec<TrackId> = [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(&param)).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);
}
}

View File

@ -123,9 +123,6 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
"triangle_down" => {
painter.add(Shape::convex_polygon(vec![p(r, 0.25, 0.35), p(r, 0.75, 0.35), p(r, 0.5, 0.68)], c, Stroke::NONE));
}
"triangle_right" => {
painter.add(Shape::convex_polygon(vec![p(r, 0.35, 0.25), p(r, 0.68, 0.5), p(r, 0.35, 0.75)], c, Stroke::NONE));
}
"note" => {
painter.circle_filled(p(r, 0.38, 0.72), r.width() * 0.13, c);
painter.line_segment([p(r, 0.5, 0.72), p(r, 0.5, 0.2)], s);

View File

@ -68,8 +68,13 @@ pub struct UiState {
pub mix_views: Vec<String>,
/// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color.
pub edit_views: Vec<String>,
/// 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<String>,
pub status: String,
pub show_automation: bool,
@ -128,7 +133,9 @@ impl Default for UiState {
narrow_mix: false,
mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()],
edit_views: vec!["color".into()],
track_tint: 0.5,
plugin_windows: Vec::new(),
send_window: None,
audiosuite: None,
status: String::new(),
show_automation: false,
@ -260,17 +267,24 @@ pub struct SoundApp {
pub(crate) docking_context: Option<egui::Context>,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
/// 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<TrackId>,
/// 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<f64>,
last_meter_poll: f64,
/// Movies for the Video window and Video track (decoded in the background).
pub video: video_track::VideoPool,
/// Test-only: owns the process-wide palette lock while this app exists, so frames painted
/// and colour assertions in one test can never straddle another test's palette change.
#[cfg(test)]
_palette_lock: theme::TestPaletteGuard,
}
/// Whether another automation-write pass is due: the transport moved at least
@ -319,6 +333,7 @@ impl SoundApp {
theme_applied: None,
docking_context: None,
sim: None,
record_hint: None,
quit_requested: false,
arrange_request: false,
touched: std::collections::HashSet::new(),
@ -327,6 +342,8 @@ impl SoundApp {
last_frame: None,
last_meter_poll: f64::NEG_INFINITY,
video: video_track::VideoPool::default(),
#[cfg(test)]
_palette_lock: theme::lock_test_palette(),
}
}
@ -370,6 +387,13 @@ impl SoundApp {
self.engine.transport.playing
}
/// Why the last Record press did nothing, for a few seconds and while it still holds.
pub(crate) fn record_hint(&self) -> Option<&'static str> {
let (at, why) = self.record_hint?;
let fresh = self.last_frame.unwrap_or_default() - at < 4.0;
(fresh && !self.engine.session().tracks.iter().any(|t| t.mixer.record_arm)).then_some(why)
}
/// Current transport position (playhead while playing, else the edit insertion).
pub fn position(&self) -> Samples {
if self.engine.transport.playing { self.engine.transport.position } else { self.engine.session().edit.selection.start }
@ -384,6 +408,7 @@ impl SoundApp {
}
self.engine.transport.playing = true;
self.engine.transport.position = from;
self.last_write_at = i64::MIN;
// A fresh start resumes the playhead follow: clear a manual hold and
// re-sync the follow tracker so the current view is not mistaken for
// an outside move on the first frame.
@ -436,7 +461,13 @@ impl SoundApp {
fn start_recording(&mut self) {
let armed = self.engine.session().tracks.iter().any(|t| t.mixer.record_arm);
if !armed {
self.ui.status = "Record-enable a track first (the red button in its header).".into();
let why = if self.engine.session().tracks.is_empty() {
"Nothing to record on yet: add a track with Track › New…, then click its R button."
} else {
"No track is record-enabled: click the R button on the track to record."
};
self.ui.status = why.into();
self.record_hint = Some((self.last_frame.unwrap_or_default(), why));
return;
}
self.ensure_input();
@ -604,23 +635,26 @@ impl SoundApp {
}
/// While playing, record volume automation from fader positions for tracks in Write mode, and
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. One undo step per pass.
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the
/// automation since the previous one, and Stop writes a last point. A pass and whatever is
/// dragged during it, its fader included, are one undo step.
fn write_automation(&mut self, ctx: &egui::Context) {
use soundcraft_model::AutomationMode as M;
if !self.is_playing() {
if !self.touched.is_empty() {
self.touched.clear();
self.engine.end_merge();
}
const PASS: &str = "automation_pass";
let playing = self.is_playing();
let may_write =
playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch));
self.engine.hold_merge(may_write.then_some(PASS));
if !playing && self.touched.is_empty() {
self.last_write_at = i64::MIN;
return;
}
let pos = self.position();
let pos = self.engine.transport.position;
let step = self.engine.session().sample_rate.samples(0.02);
if !automation_step_due(pos, self.last_write_at, step) {
if playing && !automation_step_due(pos, self.last_write_at, step) {
return;
}
self.last_write_at = pos;
let from = std::mem::replace(&mut self.last_write_at, if playing { pos } else { i64::MIN });
let dragging = ctx.input(|i| i.pointer.any_down());
let fader_track = match &self.gesture {
Some(Gesture::Fader { track, .. }) => Some(*track),
@ -645,11 +679,15 @@ impl SoundApp {
.collect();
for (t, v) in writes {
self.touched.insert(t);
let _ = self.engine.execute_merged(
"automation.set_point",
&serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}),
"automation_pass",
);
let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v});
if from != i64::MIN {
point["from"] = from.into();
}
let _ = self.engine.execute_merged("automation.set_point", &point, PASS);
}
if !playing {
self.touched.clear();
self.engine.end_merge();
}
}
@ -719,6 +757,7 @@ impl SoundApp {
if let Some(sim) = &mut self.sim {
sim.0 = at;
}
self.last_write_at = i64::MIN;
}
self.engine.transport.position = at;
}
@ -999,6 +1038,29 @@ mod tests {
assert_eq!(app.engine.transport.position, 0);
}
#[test]
fn record_with_nothing_armed_says_what_to_do() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.run("transport.record", json!({})).unwrap();
app.handle_transport(0.25);
assert!(!app.is_playing() && !app.engine.transport.recording);
assert!(app.record_hint().is_some_and(|why| why.contains("Track › New")), "{:?}", app.record_hint());
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
app.run("transport.record", json!({})).unwrap();
app.handle_transport(0.25);
assert!(app.record_hint().is_some_and(|why| why.contains("R button")), "{:?}", app.record_hint());
assert_eq!(Some(app.ui.status.as_str()), app.record_hint());
app.last_frame = Some(5.0);
assert_eq!(app.record_hint(), None, "the hint shows for a few seconds");
app.last_frame = None;
app.run("transport.record", json!({})).unwrap();
app.handle_transport(0.25);
app.run("mix.record_arm", json!({})).unwrap();
assert_eq!(app.record_hint(), None, "arming a track answers the hint");
}
#[test]
fn a_hidden_window_still_autosaves() {
let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id()));
@ -1061,6 +1123,67 @@ mod tests {
assert!(automation_step_due(0, 1_000_000, 960));
}
#[test]
fn a_write_pass_replaces_the_automation_it_plays_over() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let fader = app.engine.session().track_by_name("Kick").unwrap().mixer.volume_db;
let q = i64::from(app.engine.session().sample_rate.hz()) / 4;
// The old ride: a point every 10 ms for 3 s.
for at in (0..12 * q).step_by(usize::try_from(q / 25).unwrap()) {
app.run("automation.set_point", json!({"track": "Kick", "at": at, "value": -30.0})).unwrap();
}
app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap();
app.run("transport.locate", json!({"at": 0})).unwrap();
app.run("transport.play", json!({})).unwrap();
// Passes a quarter second apart, sparse like a covered window's.
let ctx = egui::Context::default();
let pass = |app: &mut SoundApp| {
app.handle_transport(0.25);
app.write_automation(&ctx);
};
pass(&mut app);
pass(&mut app);
// Two jumps: the stretches they skip keep the old ride.
app.run("transport.locate", json!({"at": 4 * q})).unwrap();
pass(&mut app);
app.run("edit.select", json!({"tracks": ["Kick"], "start": 7 * q, "end": 11 * q})).unwrap();
app.run("transport.play_selection", json!({})).unwrap();
pass(&mut app);
app.handle_transport(0.25);
app.run("transport.stop", json!({})).unwrap();
app.handle_transport(0.0);
app.write_automation(&ctx);
let lane = app.engine.session().track_by_name("Kick").unwrap().lane(&soundcraft_model::AutoParam::Volume).unwrap();
let values = |from, to| lane.points.iter().filter(|p| (from..=to).contains(&p.at)).map(|p| p.value).collect::<Vec<_>>();
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();

View File

@ -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<MenuNode> {
insert_path(&mut roots, line);
}
insert_path(&mut roots, "Window > Reset Panel Layout");
// Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282).
// It creates a clip rather than acting on the selected ones, so it leads the Clip menu.
insert_path(&mut roots, "Clip > New MIDI Clip");
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
clip.children.rotate_right(1);
}
for (path, _) in MENU_WINDOWS {
insert_path(&mut roots, path);
}
@ -490,7 +496,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
toggle(&mut app.ui.show_audio_health)
}
"window.about" => toggle(&mut app.ui.show_about),
"window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor),
"window.midi_editor" => toggle(&mut app.ui.show_midi_editor),
"window.midi_front" => {
app.ui.show_midi_editor = true;
json!({"value": true})
}
"window.midi_back" => {
app.ui.show_midi_editor = false;
json!({})
@ -557,6 +567,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
app.ui.show_undo_history = false;
app.ui.show_session_info = false;
app.ui.show_audio_health = false;
app.ui.show_automation = false;
app.ui.plugin_windows.clear();
app.ui.audiosuite = None;
json!({})

View File

@ -36,7 +36,7 @@ pub fn target_clip(app: &SoundApp) -> Option<ClipId> {
let s = app.engine.session();
for id in &s.edit.selected_clips {
if let Some((_, c)) = s.find_clip(*id)
&& !c.is_audio()
&& matches!(c.content, ClipContent::Midi { .. })
{
return Some(*id);
}
@ -101,6 +101,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
regular(11.0),
t.text_dim,
);
let grid_ticks = match app.engine.session().edit.grid {
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
let mut g = value.ticks();
if dotted {
g = g * 3 / 2;
}
if triplet {
g = g * 2 / 3;
}
g.max(1)
}
_ => TICKS_PER_QUARTER / 4,
};
// Header toolbar: whole-clip MIDI operations.
let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0));
let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
@ -116,7 +129,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
op = Some(("event.change_velocity", json!({"add": 10})));
}
if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() {
op = Some(("event.quantize", json!({"grid": "1/16", "strength": 100})));
op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100})));
}
if let Some((id, mut p)) = op {
p["clips"] = json!([cid.0]);
@ -126,19 +139,6 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let sr = s.sample_rate;
let base_tick = s.tempo.samples_to_ticks(clip.start, sr);
let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1);
let grid_ticks = match s.edit.grid {
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
let mut g = value.ticks();
if dotted {
g = g * 3 / 2;
}
if triplet {
g = g * 2 / 3;
}
g.max(1)
}
_ => TICKS_PER_QUARTER / 4,
};
let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0));
let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y));
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
@ -317,12 +317,13 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid.
if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() {
let (all, up, down, left, right, shift) = ui.input(|i| {
let arrows = !i.modifiers.alt && !i.modifiers.command && !i.modifiers.ctrl;
(
i.modifiers.command && i.key_pressed(egui::Key::A),
i.key_pressed(egui::Key::ArrowUp),
i.key_pressed(egui::Key::ArrowDown),
i.key_pressed(egui::Key::ArrowLeft),
i.key_pressed(egui::Key::ArrowRight),
arrows && i.key_pressed(egui::Key::ArrowUp),
arrows && i.key_pressed(egui::Key::ArrowDown),
arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowLeft),
arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowRight),
i.modifiers.shift,
)
});
@ -347,7 +348,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
}
}
if !app.midi.selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
if !app.midi.selected.is_empty()
&& !ui.ctx().egui_wants_keyboard_input()
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
{
let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected}));
app.midi.selected.clear();
}

View File

@ -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<f32
lv
}
fn section_label(ui: &Ui, r: Rect, text: &str) {
ui.painter().text(pos2(r.center().x, r.min.y + 7.0), Align2::CENTER_CENTER, text, bold(10.0), Tokens::current().section_label);
/// Pro Tools' names for sections in strips too narrow for the full ones.
fn short_label(text: &str) -> &str {
match text {
"INSERTS A-E" => "I A-E",
"INSERTS F-J" => "I F-J",
"SENDS A-E" => "S A-E",
"SENDS F-J" => "S F-J",
"INSTRUMENT" => "INST",
_ => text,
}
}
fn route_name(app: &SoundApp, r: &Route) -> String {
fn section_label(ui: &Ui, r: Rect, text: &str) {
let p = ui.painter();
let color = Tokens::current().section_label;
let mut galley = p.layout_no_wrap(text.to_owned(), bold(10.0), color);
if galley.size().x > r.width() - 8.0 {
galley = p.layout_no_wrap(short_label(text).to_owned(), bold(10.0), color);
}
p.galley(Align2::CENTER_CENTER.anchor_size(pos2(r.center().x, r.min.y + 7.0), galley.size()).min, galley, color);
}
pub(crate) fn route_name(app: &SoundApp, r: &Route) -> String {
match r {
Route::None => "no output".into(),
Route::Main => app.engine.session().outputs.first().map_or_else(|| "Out 1-2".into(), |o| o.name.clone()),
@ -88,6 +110,16 @@ fn route_name(app: &SoundApp, r: &Route) -> String {
}
}
pub(crate) fn input_name(app: &SoundApp, track: &Track) -> String {
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) }
}
/// The input or output menu under `resp`. It closes only on a click outside, so its bus search
/// field can be typed in.
pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId, input: bool) {
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
}
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) {
let t = Tokens::current();
let Some(track) = app.engine.session().track(id).cloned() else { return };
@ -96,7 +128,9 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let h = ui.available_height().max(560.0);
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
let selected = app.engine.session().edit.selected_tracks.contains(&id);
ui.painter().rect_filled(r, 0.0, if selected { t.strip_selected } else { t.strip_bg });
let bg = if selected { t.strip_selected } else { t.strip_bg };
vgradient(ui.painter(), r, shade(bg, 0.06), bg);
ui.painter().vline(r.min.x + 0.5, r.y_range(), Stroke::new(1.0, t.edge_light));
let mut y = r.min.y + 4.0;
let inner_w = w - 8.0;
let x0 = r.min.x + 4.0;
@ -109,7 +143,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
if all || has("mic_preamps") {
// Input gain stage ahead of the inserts (the track's trim).
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0));
ui.painter().rect_filled(sec, 2.0, t.strip_section);
inset(ui.painter(), sec, t.strip_section);
section_label(ui, sec, "PREAMP");
if has_input {
let kr = Rect::from_min_size(pos2(x0 + 6.0, sec.min.y + 15.0), vec2(inner_w - 12.0, 16.0));
@ -127,7 +161,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
}
if all || has("instruments") {
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 34.0));
ui.painter().rect_filled(sec, 2.0, t.strip_section);
inset(ui.painter(), sec, t.strip_section);
section_label(ui, sec, "INSTRUMENT");
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 16.0), vec2(inner_w - 4.0, 15.0));
let slot = track.mixer.inserts.iter().position(|i| i.as_ref().is_some_and(|i| plugin_info(&i.plugin).is_some_and(|p| p.is_instrument)));
@ -163,7 +197,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
continue;
}
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 5.0 * 17.0));
ui.painter().rect_filled(sec, 2.0, t.strip_section);
inset(ui.painter(), sec, t.strip_section);
section_label(ui, sec, title);
for k in 0..5 {
let sr = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + k as f32 * 17.0), vec2(inner_w - 4.0, 15.0));
@ -208,25 +242,24 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 44.0));
let mut c = track.comments.clone();
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(sec));
if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text("comments").font(regular(10.0))).changed() {
let hint = if narrow { "" } else { "comments" };
if child.add(egui::TextEdit::multiline(&mut c).desired_width(inner_w).desired_rows(2).hint_text(hint).font(regular(10.0))).changed() {
let _ = app.engine.execute_merged("track.comments", &json!({"track": id.0, "comments": c}), &format!("comments:{}", id.0));
}
y = sec.max.y + 4.0;
}
if all || has("io") {
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0));
ui.painter().rect_filled(sec, 2.0, t.strip_section);
inset(ui.painter(), sec, t.strip_section);
section_label(ui, sec, "I / O");
let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0));
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let input =
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) };
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text);
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true));
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input_name(app, &track), t.text);
route_popup(app, &resp, id, true);
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text);
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false));
route_popup(app, &resp, id, false);
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
@ -258,7 +291,8 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
y = sec.max.y + 4.0;
}
// Group selector.
let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or("no group".to_string(), |g| g.name.clone());
let no_group = if narrow { "nogrp" } else { "no group" };
let gname = app.engine.session().groups.iter().find(|g| g.members.contains(&id)).map_or(no_group.to_string(), |g| g.name.clone());
let gr = Rect::from_min_size(pos2(x0 + 2.0, y), vec2(inner_w - 4.0, 16.0));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(gr));
let _ = selector_box(&mut c, gr.width(), gr.height(), &gname, t.text_dim);
@ -292,7 +326,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let ks = if n == 2 { (inner_w / 2.0 - 6.0).min(32.0) } else { 34.0 };
for i in 0..n {
let cx = if n == 2 { x0 + inner_w * (0.25 + 0.5 * i as f32) } else { x0 + inner_w * 0.5 };
let kr = Rect::from_center_size(pos2(cx, y + ks * 0.5), vec2(ks, ks));
let kr = Rect::from_center_size(pos2(cx, y + 17.0), vec2(ks, ks));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(kr));
let mut v = track.mixer.pan.get(i).copied().unwrap_or(0.0);
let before = v;
@ -301,14 +335,15 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
}
let _ = resp;
let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0));
ui.painter().rect_filled(pr, 1.0, t.counter_bg);
let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan);
let pr = Rect::from_center_size(pos2(cx, y + 42.0), vec2(ks + 8.0, 13.0));
inset(ui.painter(), pr, t.counter_bg);
let (_, entered) =
editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan, 0.0);
if let Some(v) = entered {
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
}
}
y += ks + 20.0;
y += 54.0;
}
// Rec / Input / Solo / Mute.
let bw = (inner_w - 6.0) / 2.0;
@ -370,7 +405,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
}
// Volume readout and name.
let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));
ui.painter().rect_filled(vr, 1.0, t.counter_bg);
inset(ui.painter(), vr, t.counter_bg);
let (_, entered) = editable_readout(
ui,
vr,
@ -378,6 +413,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
&db_text(track.mixer.volume_db),
&format!("{:.1}", track.mixer.volume_db),
parse_db,
0.0,
);
if let Some(db) = entered {
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
@ -409,6 +445,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
}
nresp.context_menu(|ui| crate::edit_window::track_context_menu(app, ui, id));
if app.ui.mix_views.iter().any(|v| v == "color") {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x, r.max.y - 5.0), vec2(w, 5.0)), 0.0, rgb(track.color));
}
@ -417,21 +454,16 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
/// An instrument track's INSTRUMENT row: click opens a hosted instrument's plugin window,
/// right-click (or click, when none is set) picks another instrument.
fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
pub(crate) fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let t = Tokens::current();
let (id, slot) = (track.id, soundcraft_mix::INSTRUMENT_SLOT);
let ins = track.instrument.as_ref();
let info = ins.and_then(|i| plugin_info(&i.plugin));
let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());
let fill = if info.is_some() { t.insert_on } else { t.slot_bg };
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
Stroke::new(1.0, t.slot_border),
StrokeKind::Inside,
);
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text);
match info {
Some(info) => used_slot(ui.painter(), r, t.slot_face, Mark::Dot, info.short_name, t.text, None),
None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot),
}
let hosted = ins.is_some_and(|i| soundcraft_mix::is_third_party(&i.plugin));
if resp.clicked() && hosted && !app.ui.plugin_windows.contains(&(id, slot)) {
app.ui.plugin_windows.push((id, slot));
@ -441,33 +473,101 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
popup.show(|ui| instrument_menu(app, ui, id));
}
fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
/// What a plugin slot shows at its left: a dot for inserts, the send's letter, or an arrow for
/// a send on a short track.
#[derive(Clone, Copy)]
enum Mark {
Dot,
Letter(char),
Arrow,
}
/// Slots narrower than this are a short track's squares: a mark, no name.
const COMPACT_SLOT: f32 = 30.0;
const NAME_SIZE: f32 = 9.5;
fn draw_mark(p: &egui::Painter, r: Rect, mark: Mark, color: Color32) {
let at = r.min + vec2(4.75, 4.75);
match mark {
Mark::Dot => {
p.circle_filled(at, 2.0, color);
}
Mark::Letter(l) => {
p.text(pos2(r.min.x + 2.5, r.center().y), Align2::LEFT_CENTER, l, bold(NAME_SIZE), color);
}
Mark::Arrow => {
let at = at + vec2(0.0, 0.5);
p.rect_filled(Rect::from_min_max(at + vec2(-2.5, -1.5), at + vec2(0.0, 1.5)), 0.0, color);
p.add(egui::Shape::convex_polygon(vec![at + vec2(-0.5, -3.5), at + vec2(2.75, 0.0), at + vec2(-0.5, 3.5)], color, Stroke::NONE));
}
}
}
fn empty_slot(p: &egui::Painter, r: Rect, hovered: bool, mark: Mark) {
let t = Tokens::current();
p.rect_filled(r, 2.0, if hovered { shade(t.slot_empty, 0.06) } else { t.slot_empty });
let color = if matches!(mark, Mark::Letter(_)) { t.slot_mark.lerp_to_gamma(t.text, 0.55) } else { t.slot_mark };
draw_mark(p, r, mark, color);
}
/// A used slot, a button a point bigger than the empty cell: the mark, a groove, the name
/// centred, and for sends a level box at the right. It doesn't brighten on hover, which would
/// cost its name contrast.
fn used_slot(p: &egui::Painter, r: Rect, faces: [Color32; 2], mark: Mark, name: &str, color: Color32, level: Option<f32>) {
let t = Tokens::current();
let outer = r.expand(1.0);
let top = raised_key(p, outer, faces, false);
draw_mark(p, r, mark, if matches!(mark, Mark::Dot) { t.slot_mark.lerp_to_gamma(color, 0.35) } else { color });
if r.width() < COMPACT_SLOT {
return;
}
let groove_x = r.min.x + 10.5;
groove(p, groove_x, r.y_range(), top);
let mut text = r.with_min_x(groove_x + 1.5);
if let Some(k) = level {
let well = level_icon(r);
p.rect_filled(well, 1.0, Color32::BLACK);
let inner = well.shrink(1.0);
p.rect_filled(inner, 0.0, Color32::from_rgb(36, 38, 8));
p.rect_filled(inner.with_min_y(inner.max.y - inner.height() * k.clamp(0.0, 1.0)), 0.0, Color32::from_rgb(51, 49, 11));
text.max.x = well.min.x - 1.0;
}
let g = p.layout_no_wrap(name.to_string(), bold(NAME_SIZE), color);
let x = if g.size().x <= text.width() { text.center().x - g.size().x / 2.0 } else { text.min.x + 1.0 };
p.with_clip_rect(text).galley(pos2(x, text.center().y - g.size().y / 2.0), g, color);
}
/// A used send's level icon: the small fader at the right of its slot.
fn level_icon(r: Rect) -> Rect {
let outer = r.expand(1.0);
Rect::from_min_max(pos2(outer.max.x - 9.0, outer.min.y + 2.0), pos2(outer.max.x - 4.0, outer.max.y - 2.0))
}
pub(crate) fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::current();
let id = track.id;
let ins = track.mixer.inserts.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("ins", id.0, slot)), Sense::click());
let fill = match &ins {
Some(i) if i.bypass => t.insert_bypass,
Some(_) => t.insert_on,
None => t.slot_bg,
};
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
Stroke::new(1.0, t.slot_border),
StrokeKind::Inside,
);
let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name);
if label.is_empty() {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
} else {
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, label, regular(10.5), t.text);
let info = ins.as_ref().and_then(|i| plugin_info(&i.plugin));
let name = ins.as_ref().map(|i| info.map_or(i.plugin.as_str(), |p| p.short_name)).unwrap_or_default();
match &ins {
None => empty_slot(ui.painter(), r, resp.hovered(), Mark::Dot),
Some(i) => {
let faces = if i.bypass { t.slot_bypassed } else { t.slot_face };
let color = if info.is_some() { t.text } else { t.text_dim };
used_slot(ui.painter(), r, faces, Mark::Dot, name, color, None);
}
}
if resp.clicked() && ins.is_some() && !app.ui.plugin_windows.contains(&(id, slot)) {
app.ui.plugin_windows.push((id, slot));
if resp.clicked() && ins.is_some() {
if ui.input(|i| i.modifiers.command) {
let _ = app.run("mix.insert_bypass", json!({"track": id.0, "slot": slot}));
} else if !app.ui.plugin_windows.contains(&(id, slot)) {
app.ui.plugin_windows.push((id, slot));
}
}
let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text("Click: open plugin · right-click: change") };
let cmd = if ui.ctx().os().is_mac() { "⌘" } else { "Ctrl+" };
let tip = format!("{name}: click to open · {cmd}click to bypass · right-click to change");
let menu_resp = if ins.is_none() { resp.clone() } else { resp.clone().on_hover_text(tip) };
let popup = if ins.is_none() { egui::Popup::menu(&menu_resp) } else { egui::Popup::context_menu(&menu_resp) };
popup.show(|ui| plugin_menu(app, ui, id, slot, ins.is_some()));
}
@ -616,67 +716,52 @@ pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> {
.or_else(|| soundcraft_au_host::plugin_info(id))
}
fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
let t = Tokens::current();
let id = track.id;
let snd = track.mixer.sends.get(slot).cloned().flatten();
let fill = if snd.is_some() { t.send_on } else { t.slot_bg };
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside);
let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max);
let compact = r.width() < COMPACT_SLOT;
// As in Pro Tools, only the fader icon opens a send's level; the rest of the slot picks its
// destination. A short track's square has no icon, so all of it is the level.
let level_r = if compact { r } else { level_icon(r).expand2(vec2(3.0, 0.0)).with_min_y(r.min.y).with_max_y(r.max.y) };
let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y));
let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click());
let mark = if compact { Mark::Arrow } else { Mark::Letter((b'a' + slot as u8) as char) };
match &snd {
Some(s) => {
let name = route_name(app, &s.target);
// Level bar.
let k = soundcraft_model::fader_db_to_pos(s.level_db);
ui.painter().rect_filled(
Rect::from_min_max(pos2(r.min.x + 1.0, r.max.y - 3.0), pos2(r.min.x + 1.0 + (r.width() - 2.0) * k, r.max.y - 1.0)),
0.0,
t.counter_text,
);
ui.painter().with_clip_rect(route_r).text(
pos2(r.min.x + 4.0, r.center().y - 1.0),
Align2::LEFT_CENTER,
name,
regular(9.0),
if s.mute { t.mute } else { t.text },
);
let (level_resp, entered) = editable_readout(
ui,
level_r,
ui.id().with(("send-value", id.0, slot)),
&format!("{} dB", db_text(s.level_db)),
&format!("{:.1}", s.level_db),
parse_db,
);
if let Some(db) = entered {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
let level = soundcraft_model::fader_db_to_pos(s.level_db);
let color = if s.mute { t.mute } else { t.text };
used_slot(ui.painter(), r, t.slot_face, mark, &name, color, Some(level));
let which = (b'A' + slot as u8) as char;
let shown = app.ui.send_window == Some((id, slot));
let level_resp = ui.interact(level_r, ui.id().with(("send-value", id.0, slot)), Sense::click_and_drag());
level_resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, format!("Send {which} level")));
if shown {
ui.painter().rect_stroke(level_r.shrink(0.5), 1.0, Stroke::new(1.5, t.accent), StrokeKind::Inside);
}
if level_resp.as_ref().is_some_and(egui::Response::dragged) {
if level_resp.clicked() {
if ui.input(|i| i.modifiers.alt) {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
} else {
app.ui.send_window = (!shown).then_some((id, slot));
}
}
if level_resp.dragged() {
// Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so
// applying it to the already-updated value made trackpad adjustment accelerate.
let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));
let db_per_point = if fine { 0.02 } else { 0.15 };
let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0);
let db = dragged_send_db(s.level_db, dy, fine);
let _ =
app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
}
if level_resp.as_ref().is_some_and(egui::Response::double_clicked) {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
}
if let Some(resp) = level_resp {
let _ = resp.clone().on_hover_text(format!(
"Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options",
(b'A' + slot as u8) as char,
db_text(s.level_db)
));
resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));
}
}
None => {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
let _ = level_resp.clone().on_hover_text(format!(
"Send {which} to {name}: {} dB\nClick for the send fader · drag vertically to adjust · Shift-drag for fine control · Alt-click for 0 dB · right-click for options",
db_text(s.level_db)
));
level_resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));
}
None => empty_slot(ui.painter(), r, ui.rect_contains_pointer(r), mark),
}
if let Some(send) = &snd {
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
@ -704,6 +789,82 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
}
}
/// The Send window, as in Pro Tools: one send's fader, level, pan, Mute and Pre-fader. Clicking a
/// send's level opens it, moves it to that send, or closes it, and so does Escape.
pub(crate) fn send_window(app: &mut SoundApp, ctx: &egui::Context) {
// egui closes menus and drops text focus on Escape before this runs, so whether this Escape
// was theirs comes from the last frame. A dialog or the search on top takes it too.
let busy_id = egui::Id::new("send_window_escape");
let busy = ctx.data(|d| d.get_temp(busy_id)).unwrap_or(false) || app.dialogs.open.is_some() || app.ui.show_search;
if !busy && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
app.ui.send_window = None;
}
send_window_contents(app, ctx);
let busy = egui::Popup::is_any_open(ctx) || ctx.text_edit_focused();
ctx.data_mut(|d| d.insert_temp(busy_id, busy));
}
fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) {
let Some((id, slot)) = app.ui.send_window else { return };
let Some((name, send)) = app.engine.session().track(id).and_then(|t| Some((t.name.clone(), t.mixer.sends.get(slot).cloned().flatten()?))) else {
app.ui.send_window = None;
return;
};
let t = Tokens::current();
let mut open = true;
egui::Window::new(format!("{name} · Send {}", (b'A' + slot as u8) as char))
.id(egui::Id::new("send_window"))
.open(&mut open)
.auto_sized()
.collapsible(false)
.default_pos(ctx.pointer_latest_pos().map_or(pos2(200.0, 200.0), |p| p + vec2(24.0, -160.0)))
.show(ctx, |ui| {
ui.set_width(150.0);
ui.vertical_centered(|ui| {
ui.label(egui::RichText::new(route_name(app, &send.target)).color(t.counter_text));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
ui.add_space(((ui.available_width() - 62.0) / 2.0).max(0.0));
if text_toggle(ui, vec2(36.0, 18.0), "PRE", send.pre_fader, t.accent, "Pre-fader").clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": !send.pre_fader}));
}
if text_toggle(ui, vec2(22.0, 18.0), "M", send.mute, t.mute, "Mute send").clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": !send.mute}));
}
});
let mut pan = send.pan;
pan_knob(ui, 32.0, &mut pan, "Send pan (drag; double-click centres)");
if (pan - send.pan).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.send_level",
&json!({"track": id.0, "slot": slot, "pan": pan}),
&format!("send-pan:{}:{slot}", id.0),
);
}
ui.label(egui::RichText::new(pan_text(send.pan)).color(t.counter_text).small());
let (fr, fresp) = ui.allocate_exact_size(vec2(56.0, 220.0), Sense::hover());
fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Slider, true, "Send level"));
if let Some(db) = fader(ui, fr, send.level_db, ui.id().with("send_fader")) {
let _ = app.engine.execute_merged(
"mix.send_level",
&json!({"track": id.0, "slot": slot, "db": db}),
&format!("send:{}:{slot}", id.0),
);
}
let (rr, _) = ui.allocate_exact_size(vec2(64.0, 15.0), Sense::hover());
ui.painter().rect_filled(rr, 1.0, t.counter_bg);
let shown = db_text(send.level_db);
let (_, typed) = editable_readout(ui, rr, ui.id().with("send_level_value"), &shown, &format!("{:.1}", send.level_db), parse_db, 0.0);
if let Some(db) = typed {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
}
});
});
if !open {
app.ui.send_window = None;
}
}
/// Secondary controls live behind the amount readout, separate from the bus selector like Logic.
fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) {
ui.set_min_width(230.0);
@ -751,8 +912,16 @@ fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize,
}
}
/// A send level after a vertical drag of `dy` points: along the fader's taper, like the level bar,
/// so it runs smoothly down to −∞ and back. About 0.15 dB a point near 0 dB (0.02 when fine).
fn dragged_send_db(db: f32, dy: f32, fine: bool) -> f32 {
let per_point = if fine { 0.0004 } else { 0.003 };
soundcraft_model::fader_pos_to_db(soundcraft_model::fader_db_to_pos(db) - dy * per_point)
}
/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away
/// commits a valid value; Escape cancels. The interaction response remains available for dragging.
/// commits a valid value; Escape cancels, and so does the field losing focus while hidden.
/// Alt-click returns `reset`. The interaction response remains available for dragging.
fn editable_readout(
ui: &mut Ui,
r: Rect,
@ -760,6 +929,7 @@ fn editable_readout(
display: &str,
edit_initial: &str,
parse: fn(&str) -> Option<f32>,
reset: f32,
) -> (Option<egui::Response>, Option<f32>) {
let t = Tokens::current();
let active_id = id.with("active");
@ -767,9 +937,14 @@ fn editable_readout(
let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false);
let mut started = false;
let mut response = None;
let mut value = None;
if !active {
let resp = ui.interact(r, id, Sense::click_and_drag());
if resp.clicked() && !resp.double_clicked() {
let alt = ui.input(|i| i.modifiers.alt);
if resp.clicked() && alt {
value = Some(reset);
}
if resp.clicked() && !alt && !resp.double_clicked() {
active = true;
started = true;
ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string()));
@ -778,7 +953,6 @@ fn editable_readout(
}
response = Some(resp);
}
let mut value = None;
if active {
let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string());
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r));
@ -796,13 +970,15 @@ fn editable_readout(
let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
if escape {
active = false;
} else if enter || edit_resp.lost_focus() {
} else if (enter && edit_resp.has_focus()) || edit_resp.lost_focus() {
if let Some(parsed) = parse(&text) {
value = Some(parsed);
active = false;
} else if edit_resp.lost_focus() {
active = false;
}
} else if !started && !edit_resp.has_focus() {
active = false;
}
ui.data_mut(|d| d.insert_temp(text_id, text));
}
@ -837,7 +1013,7 @@ fn send_level_scale(ui: &mut Ui, db: f32) {
let t = Tokens::current();
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover());
let line_y = r.min.y + 4.0;
ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border));
ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.border));
for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() {
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark);
ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim));
@ -936,7 +1112,8 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
#[cfg(test)]
mod tests {
use super::{parse_db, parse_pan};
use super::{dragged_send_db, editable_readout, parse_db, parse_pan};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
#[test]
fn typed_db_accepts_console_notation_and_clamps() {
@ -956,4 +1133,47 @@ mod tests {
assert_eq!(parse_pan("0.5"), Some(0.5));
assert_eq!(parse_pan("center"), None);
}
#[test]
fn send_drags_follow_the_fader_taper_down_to_minus_infinity() {
assert!((dragged_send_db(0.0, -10.0, false) - 1.44).abs() < 0.01);
assert!((dragged_send_db(0.0, -10.0, true) - 0.192).abs() < 0.01);
assert_eq!(dragged_send_db(0.0, 400.0, false), -144.0);
let up = dragged_send_db(-144.0, -1.0, false);
assert!(up > -144.0 && up < -100.0, "one point up leaves −∞ gently, got {up}");
assert_eq!(dragged_send_db(12.0, -50.0, false), 12.0);
}
#[test]
fn readouts_reset_on_alt_click_and_drop_a_field_that_lost_focus() {
let ctx = egui::Context::default();
let r = Rect::from_min_size(pos2(10.0, 10.0), vec2(40.0, 14.0));
let click = |mods: Modifiers| {
let press = |pressed| Event::PointerButton { pos: r.center(), button: PointerButton::Primary, pressed, modifiers: mods };
vec![Event::ModifiersChanged(mods), Event::PointerMoved(r.center()), press(true), press(false)]
};
let mut time = 0.0;
let mut frame = |events: Vec<Event>, 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");
}
}

View File

@ -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, "<ALL>", 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<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOutput<R>) {
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::<serde_json::Value>(&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<String, serde_json::Value> = 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();
}

View File

@ -86,6 +86,8 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
let _ = app.run(c.0, json!({}));
}
}
// Tab moves the insertion point here, so egui must not also hand focus to the first widget: that would block every shortcut.
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
@ -120,6 +122,16 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("markers.add", json!({}));
true
}
// Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +.
Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => {
let step = if key == Key::Minus { -1 } else { 1 };
let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step}));
true
}
Key::F12 if plain => {
let _ = app.run("transport.record", json!({}));
true
}
Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => {
let mode = match key {
Key::F1 => "shuffle",
@ -147,11 +159,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("edit.nudge", json!({"direction": dir}));
true
}
Key::Tab if plain || m.alt => {
// Alt+Tab goes back; off macOS the desktop takes Alt+Tab, so Ctrl+Tab does. Cmd+Alt+Tab is Tab to Transient.
Key::Tab if plain || (m.alt && !m.command) || (!mac && m.ctrl && !m.alt) => {
// Tab to next clip boundary (or transient when enabled).
let s = app.engine.session();
let at = s.edit.selection.start;
let back = m.alt;
let back = !plain;
if s.edit.tab_to_transient
&& !back
&& let Some(t) = s.edit.selected_tracks.first().copied()
@ -175,6 +188,20 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
}
true
}
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 };
let _ = app.run("view.waveform_zoom", json!({"factor": factor}));
true
}
Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
let _ = app.run("clip.rating", json!({"rating": digit(key)}));
true
}
Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::CTRL, m, mac) => {
let _ = app.run("view.zoom_preset", json!({"preset": digit(key)}));
true
}
Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => {
let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" };
let _ = app.run(id, json!({}));
@ -187,7 +214,15 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
true
}
// Commands Keyboard Focus: single-key editing (Edit window only).
k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true,
k if !m.command
&& !m.alt
&& !m.ctrl
&& app.ui.window == MainWindow::Edit
&& app.engine.session().edit.keyboard_focus == "commands"
&& commands_focus(app, k, m.shift) =>
{
true
}
Key::R | Key::T if plain => {
// Zoom out / in on the timeline (Commands Focus style).
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
@ -199,18 +234,36 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
}
/// Single-key commands. Returns true when `key` was handled.
fn commands_focus(app: &mut SoundApp, key: Key) -> bool {
fn commands_focus(app: &mut SoundApp, key: Key, shift: bool) -> bool {
let id = match key {
Key::Z if shift => "edit.redo",
Key::P if shift => "edit.extend_selection_up",
Key::Semicolon | Key::Colon if shift => "edit.extend_selection_down",
_ if shift => return false,
Key::A => "edit.trim_start_to_insertion",
Key::S => "edit.trim_end_to_insertion",
Key::D => "edit.fade_to_start",
Key::G => "edit.fade_to_end",
Key::F => "edit.fades_create",
Key::Comma | Key::Period | Key::M | Key::Slash => {
let direction = if matches!(key, Key::Period | Key::Slash) { 1 } else { -1 };
let _ = app.run("edit.nudge", json!({"direction": direction, "next": matches!(key, Key::M | Key::Slash)}));
return true;
}
Key::Num0 => "edit.timeline_to_selection",
Key::O => "edit.selection_to_timeline",
Key::OpenBracket => "transport.play_selection",
Key::CloseBracket => "edit.play_timeline",
Key::B => "edit.separate",
Key::X => "edit.cut",
Key::C => "edit.copy",
Key::V => "edit.paste",
Key::Z => "edit.undo",
Key::N => "options.insertion_follows_playback",
Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 => {
let _ = app.run("view.zoom_preset", json!({"preset": digit(key)}));
return true;
}
Key::P => {
move_selection_track(app, -1);
return true;
@ -255,6 +308,10 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
}
}
fn digit(key: Key) -> u8 {
key.name().parse().unwrap_or(0)
}
/// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd
/// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing.
pub fn shortcut_label(s: &str, mac: bool) -> String {

View File

@ -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<usize> = const { Cell::new(0) };
static GUARD: RefCell<Option<MutexGuard<'static, ()>>> = const { RefCell::new(None) };
}
pub(super) fn acquire() {
DEPTH.with(|depth| {
if depth.get() == 0 {
GUARD.with(|guard| *guard.borrow_mut() = Some(PALETTE.lock().unwrap_or_else(PoisonError::into_inner)));
}
depth.set(depth.get() + 1);
});
}
pub(super) fn release() {
DEPTH.with(|depth| {
let left = depth.get().saturating_sub(1);
depth.set(left);
if left == 0 {
GUARD.with(|guard| *guard.borrow_mut() = None);
}
});
}
}
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
@ -57,6 +110,8 @@ pub struct Tokens {
pub toolbar_group: Color32,
pub border: Color32,
pub border_light: Color32,
/// The lit top/left edge of raised panels: track headers and Mix strips.
pub edge_light: Color32,
pub text: Color32,
pub text_dim: Color32,
pub text_dark: Color32,
@ -146,16 +201,15 @@ pub struct Tokens {
pub knob_ring: Color32,
/// Pan knob pointer.
pub knob_pointer: Color32,
/// Mix insert slot holding a plugin.
pub insert_on: Color32,
/// Mix insert slot, bypassed.
pub insert_bypass: Color32,
/// Mix send slot in use.
pub send_on: Color32,
/// Mix insert/send slot outline.
pub slot_border: Color32,
/// Mix-window section captions (INSERTS A-E, I/O…).
pub section_label: Color32,
/// Plugin slots, as Pro Tools draws them: the well they sit in, an empty slot, a used slot's
/// face (top, bottom), a bypassed one's, and the dots, letters and arrows on them.
pub slot_well: Color32,
pub slot_empty: Color32,
pub slot_face: [Color32; 2],
pub slot_bypassed: [Color32; 2],
pub slot_mark: Color32,
}
impl Tokens {
@ -167,6 +221,7 @@ impl Tokens {
toolbar_group: Color32::from_rgb(24, 24, 25),
border: Color32::from_rgb(14, 14, 14),
border_light: Color32::from_rgb(64, 64, 66),
edge_light: Color32::from_rgba_premultiplied(20, 20, 20, 20),
text: Color32::from_rgb(214, 214, 214),
text_dim: Color32::from_rgb(150, 150, 152),
text_dark: Color32::from_rgb(16, 16, 16),
@ -233,51 +288,53 @@ impl Tokens {
meter_bg: Color32::from_rgb(8, 8, 8),
meter_clip_off: Color32::from_rgb(60, 20, 20),
knob_bg: Color32::from_rgb(26, 26, 28),
knob_ring: Color32::from_rgb(90, 90, 94),
knob_ring: Color32::from_rgb(150, 150, 154),
knob_pointer: Color32::from_rgb(230, 230, 230),
insert_on: Color32::from_rgb(52, 62, 80),
insert_bypass: Color32::from_rgb(70, 56, 30),
send_on: Color32::from_rgb(46, 66, 56),
slot_border: Color32::from_rgb(16, 16, 16),
section_label: Color32::from_rgb(200, 200, 200),
slot_well: Color32::from_rgb(43, 43, 43),
slot_empty: Color32::from_rgb(39, 39, 39),
slot_face: [Color32::from_rgb(92, 92, 92), Color32::from_rgb(75, 75, 75)],
slot_bypassed: [Color32::from_rgb(66, 96, 146), Color32::from_rgb(50, 76, 120)],
slot_mark: Color32::from_rgb(159, 159, 159),
};
pub const LIGHT: Tokens = Tokens {
window_bg: Color32::from_rgb(238, 239, 242),
panel_bg: Color32::from_rgb(248, 249, 251),
panel_bg2: Color32::from_rgb(255, 255, 255),
toolbar_bg: Color32::from_rgb(230, 232, 236),
toolbar_group: Color32::from_rgb(242, 243, 246),
border: Color32::from_rgb(190, 193, 200),
border_light: Color32::from_rgb(210, 213, 220),
text: Color32::from_rgb(35, 36, 40),
text_dim: Color32::from_rgb(95, 98, 106),
text_dark: Color32::from_rgb(20, 21, 24),
header_text: Color32::from_rgb(45, 47, 53),
counter_bg: Color32::from_rgb(224, 226, 231),
counter_text: Color32::from_rgb(22, 116, 45),
counter_label: Color32::from_rgb(65, 68, 75),
window_bg: Color32::from_rgb(184, 186, 190),
panel_bg: Color32::from_rgb(204, 206, 210),
panel_bg2: Color32::from_rgb(216, 218, 222),
toolbar_bg: Color32::from_rgb(194, 196, 200),
toolbar_group: Color32::from_rgb(208, 210, 214),
border: Color32::from_rgb(100, 103, 109),
border_light: Color32::from_rgb(150, 153, 159),
edge_light: Color32::from_rgba_premultiplied(150, 150, 150, 150),
text: Color32::from_rgb(24, 25, 28),
text_dim: Color32::from_rgb(68, 70, 76),
text_dark: Color32::from_rgb(16, 17, 20),
header_text: Color32::from_rgb(30, 32, 36),
counter_bg: Color32::from_rgb(224, 226, 228),
counter_text: Color32::from_rgb(18, 108, 40),
counter_label: Color32::from_rgb(54, 57, 63),
accent: Color32::from_rgb(62, 116, 180),
accent_dark: Color32::from_rgb(43, 88, 145),
mode_on: Color32::from_rgb(75, 158, 88),
mode_on_text: Color32::from_rgb(255, 255, 255),
mode_off_text: Color32::from_rgb(48, 122, 62),
button: Color32::from_rgb(224, 226, 230),
button_hi: Color32::from_rgb(210, 213, 219),
button_border: Color32::from_rgb(175, 179, 187),
name_field: Color32::from_rgb(255, 255, 255),
name_field_sel: Color32::from_rgb(236, 242, 252),
ruler_bg: Color32::from_rgb(226, 228, 233),
ruler_label_bg: Color32::from_rgb(226, 228, 233),
ruler_text: Color32::from_rgb(70, 73, 80),
ruler_tick: Color32::from_rgb(130, 134, 142),
tempo_ruler: Color32::from_rgb(104, 170, 124),
meter_ruler: Color32::from_rgb(92, 158, 184),
marker_ruler: Color32::from_rgb(210, 213, 219),
playlist_bg: Color32::from_rgb(246, 247, 249),
playlist_alt: Color32::from_rgb(238, 240, 244),
grid_line: Color32::from_rgb(210, 213, 219),
bar_line: Color32::from_rgb(185, 189, 197),
mode_off_text: Color32::from_rgb(38, 106, 52),
button: Color32::from_rgb(214, 216, 220),
button_hi: Color32::from_rgb(200, 203, 208),
button_border: Color32::from_rgb(108, 111, 117),
name_field: Color32::from_rgb(236, 237, 239),
name_field_sel: Color32::from_rgb(224, 232, 244),
ruler_bg: Color32::from_rgb(198, 200, 204),
ruler_label_bg: Color32::from_rgb(198, 200, 204),
ruler_text: Color32::from_rgb(48, 50, 56),
ruler_tick: Color32::from_rgb(108, 111, 118),
tempo_ruler: Color32::from_rgb(92, 156, 112),
meter_ruler: Color32::from_rgb(80, 144, 172),
marker_ruler: Color32::from_rgb(186, 189, 194),
playlist_bg: Color32::from_rgb(210, 212, 216),
playlist_alt: Color32::from_rgb(202, 204, 208),
grid_line: Color32::from_rgb(188, 191, 196),
bar_line: Color32::from_rgb(162, 166, 172),
selection: Color32::from_rgba_premultiplied(100, 145, 205, 90),
playhead: Color32::from_rgb(210, 48, 42),
insertion: Color32::from_rgb(35, 36, 40),
@ -292,37 +349,38 @@ impl Tokens {
meter_green: Color32::from_rgb(36, 160, 58),
meter_yellow: Color32::from_rgb(190, 162, 20),
meter_red: Color32::from_rgb(195, 42, 35),
fader_track: Color32::from_rgb(198, 201, 207),
strip_bg: Color32::from_rgb(235, 237, 241),
strip_section: Color32::from_rgb(244, 245, 247),
slot_bg: Color32::from_rgb(248, 249, 251),
automation_line: Color32::from_rgb(40, 42, 48),
menu_bar: Color32::from_rgb(222, 224, 229),
group_border: Color32::from_rgb(190, 193, 200),
mode_off: Color32::from_rgb(236, 240, 237),
counter_off: Color32::from_rgb(214, 230, 217),
counter_border: Color32::from_rgb(190, 193, 200),
counter_rule: Color32::from_rgb(204, 207, 213),
tool_bracket_off: Color32::from_rgb(200, 203, 210),
row_selected: Color32::from_rgb(198, 216, 240),
visible_dot: Color32::from_rgb(110, 113, 120),
big_counter_bg: Color32::from_rgb(224, 226, 231),
header_selected: Color32::from_rgb(206, 219, 238),
strip_selected: Color32::from_rgb(206, 219, 238),
marker_text: Color32::from_rgb(35, 36, 40),
playlist_lane: Color32::from_rgb(232, 234, 238),
universe_bg: Color32::from_rgb(228, 230, 234),
fader_track: Color32::from_rgb(120, 123, 129),
strip_bg: Color32::from_rgb(198, 200, 204),
strip_section: Color32::from_rgb(210, 212, 216),
slot_bg: Color32::from_rgb(194, 196, 201),
automation_line: Color32::from_rgb(30, 32, 38),
menu_bar: Color32::from_rgb(204, 206, 210),
group_border: Color32::from_rgb(118, 121, 127),
mode_off: Color32::from_rgb(202, 212, 204),
counter_off: Color32::from_rgb(192, 212, 198),
counter_border: Color32::from_rgb(118, 121, 127),
counter_rule: Color32::from_rgb(188, 191, 196),
tool_bracket_off: Color32::from_rgb(172, 175, 181),
row_selected: Color32::from_rgb(174, 196, 226),
visible_dot: Color32::from_rgb(86, 89, 96),
big_counter_bg: Color32::from_rgb(224, 226, 228),
header_selected: Color32::from_rgb(184, 200, 224),
strip_selected: Color32::from_rgb(184, 200, 224),
marker_text: Color32::from_rgb(24, 25, 28),
playlist_lane: Color32::from_rgb(200, 202, 206),
universe_bg: Color32::from_rgb(198, 200, 204),
universe_view: Color32::from_rgb(40, 42, 48),
meter_bg: Color32::from_rgb(196, 199, 206),
meter_clip_off: Color32::from_rgb(232, 200, 198),
knob_bg: Color32::from_rgb(252, 252, 253),
knob_ring: Color32::from_rgb(150, 154, 162),
knob_pointer: Color32::from_rgb(40, 42, 48),
insert_on: Color32::from_rgb(206, 218, 236),
insert_bypass: Color32::from_rgb(240, 222, 186),
send_on: Color32::from_rgb(204, 230, 214),
slot_border: Color32::from_rgb(175, 179, 187),
section_label: Color32::from_rgb(95, 98, 106),
meter_bg: Color32::from_rgb(52, 54, 58),
meter_clip_off: Color32::from_rgb(92, 54, 54),
knob_bg: Color32::from_rgb(232, 233, 236),
knob_ring: Color32::from_rgb(116, 120, 128),
knob_pointer: Color32::from_rgb(30, 32, 38),
section_label: Color32::from_rgb(56, 58, 64),
slot_well: Color32::from_rgb(212, 214, 218),
slot_empty: Color32::from_rgb(198, 200, 205),
slot_face: [Color32::from_rgb(244, 245, 247), Color32::from_rgb(214, 216, 220)],
slot_bypassed: [Color32::from_rgb(190, 210, 240), Color32::from_rgb(160, 186, 228)],
slot_mark: Color32::from_rgb(92, 95, 102),
};
pub fn current() -> Tokens {
@ -344,6 +402,20 @@ pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
}
}
/// A track's header and lane backgrounds in the Edit window, tinted with its colour by `tint` (0..1).
pub fn track_backgrounds(color: [u8; 3], selected: bool, tint: f32) -> (Color32, Color32) {
let t = Tokens::current();
let k = tint.clamp(0.0, 1.0);
if k <= 0.0 {
return if selected { (t.header_selected, t.playlist_alt) } else { (t.panel_bg2, t.playlist_bg) };
}
let c = rgb(color);
let header = t.panel_bg2.lerp_to_gamma(c, k * 0.55);
let header = if selected { crate::widgets::shade(header, 0.1) } else { header };
let lane = if selected { t.playlist_alt } else { t.playlist_bg }.lerp_to_gamma(c, k * 0.2);
(header, lane)
}
/// Track colours tinted for clip bodies.
pub fn clip_colors(rgb: [u8; 3], selected: bool) -> (Color32, Color32, Color32) {
let [r, g, b] = rgb;
@ -415,6 +487,26 @@ mod tests {
use crate::{Services, SoundApp, UiState};
use serde_json::json;
#[test]
fn test_palette_lock_is_reentrant_and_excludes_other_threads() {
// Regression for the parallel-run flakes: the process-wide palette must be owned for a
// whole test, re-entrant on the owning thread (so `SoundApp::new` can take it too), and
// exclusive against other threads (so a frame cannot flip it mid-test).
let outer = lock_test_palette();
let inner = lock_test_palette();
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _held = lock_test_palette();
let _ = tx.send(());
});
assert!(rx.recv_timeout(std::time::Duration::from_millis(100)).is_err(), "another thread must wait for the palette lock");
drop(inner);
drop(outer);
drop(app);
assert!(rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "the palette lock must be released when every holder drops it");
}
#[test]
fn dark_is_the_default() {
assert_eq!(ThemeMode::default(), ThemeMode::Dark);
@ -424,6 +516,12 @@ mod tests {
assert_eq!(old.theme, ThemeMode::Dark);
}
#[test]
fn prefs_saved_before_track_tint_get_the_default() {
let ui: UiState = serde_json::from_value(json!({"theme": "light"})).unwrap();
assert!((ui.track_tint - 0.5).abs() < f32::EPSILON);
}
#[test]
fn modes_round_trip_through_prefs() {
for mode in ThemeMode::ALL {

View File

@ -1,12 +1,12 @@
//! The Edit window toolbar.
use crate::theme::{Tokens, bold, mono, regular};
use crate::widgets::icon_button;
use crate::widgets::{icon_button, key, key_fill};
use crate::{SoundApp, icons};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{EditMode, Tool};
use soundcraft_time::{TimeFormat, format_length, format_position};
use soundcraft_time::{TimeFormat, format_length};
pub const HEIGHT: f32 = 78.0;
@ -23,38 +23,45 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let full = ui.max_rect();
ui.painter().rect_filled(full, 0.0, t.toolbar_bg);
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.spacing_mut().item_spacing.x = 8.0;
edit_modes(app, ui);
zoom_group(app, ui);
tools(app, ui);
counters(app, ui);
grid_nudge(app, ui);
transport(app, ui);
tempo_meter(app, ui);
output_meter(app, ui);
});
egui::ScrollArea::horizontal()
.id_salt("edit_toolbar_scroll")
.scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::VisibleWhenNeeded)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.spacing_mut().item_spacing.x = 8.0;
edit_modes(app, ui);
zoom_group(app, ui);
tools(app, ui);
counters(app, ui);
grid_nudge(app, ui);
transport(app, ui);
tempo_meter(app, ui);
output_meter(app, ui);
});
});
}
fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let cur = app.engine.session().edit.edit_mode;
group(ui, 112.0, 52.0, |ui, r| {
let modes = [
(EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"),
(EditMode::Spot, 1, 0, "SPOT", "F3"),
(EditMode::Slip, 0, 1, "SLIP", "F2"),
(EditMode::Grid, 1, 1, "GRID", "F4"),
];
let widest =
modes.iter().map(|m| m.3).chain(["REL GRID"]).map(|l| ui.painter().layout_no_wrap(l.into(), bold(10.5), t.text).size().x).fold(0.0, f32::max);
group(ui, 2.0 * widest + 44.0, 52.0, |ui, r| {
let inner = r.shrink(6.0);
let cw = inner.width() / 2.0;
let ch = inner.height() / 2.0;
let modes = [
(EditMode::Shuffle, 0, 0, "SHUFFLE", "F1"),
(EditMode::Spot, 1, 0, "SPOT", "F3"),
(EditMode::Slip, 0, 1, "SLIP", "F2"),
(EditMode::Grid, 1, 1, "GRID", "F4"),
];
for (m, cx, cy, label, key) in modes {
for (m, cx, cy, label, fkey) in modes {
let cell = Rect::from_min_size(pos2(inner.min.x + cw * cx as f32, inner.min.y + ch * cy as f32), vec2(cw, ch)).shrink(1.0);
let resp = ui.interact(cell, ui.id().with(("mode", label)), Sense::click());
let on = cur == m || (m == EditMode::Grid && cur == EditMode::GridRelative);
ui.painter().rect_filled(cell, 1.0, if on { t.mode_on } else { t.mode_off });
key(ui.painter(), cell, &resp, if on { t.mode_on } else { t.mode_off });
let txt = if m == EditMode::Grid && cur == EditMode::GridRelative { "REL GRID" } else { label };
ui.painter().text(cell.center(), Align2::CENTER_CENTER, txt, bold(10.5), if on { t.mode_on_text } else { t.mode_off_text });
if resp.clicked() {
@ -67,7 +74,7 @@ fn edit_modes(app: &mut SoundApp, ui: &mut Ui) {
};
let _ = app.run("edit.mode", json!({"mode": id}));
}
resp.on_hover_text(format!("{} mode ({key})", label.to_lowercase()));
resp.on_hover_text(format!("{} mode ({fkey})", label.to_lowercase()));
}
});
}
@ -173,7 +180,7 @@ fn tools(app: &mut SoundApp, ui: &mut Ui) {
fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
let s = app.engine.session();
format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)
s.format_position(at, f)
}
fn counters(app: &mut SoundApp, ui: &mut Ui) {
@ -228,9 +235,10 @@ fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(170.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let gl = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 8.0), vec2(48.0, 16.0));
ui.painter().rect_filled(gl, 1.0, if lines { t.mode_on } else { t.counter_off });
let resp = ui.interact(gl, ui.id().with("grid_lines"), Sense::click());
key(ui.painter(), gl, &resp, if lines { t.mode_on } else { t.counter_off });
ui.painter().text(gl.center(), Align2::CENTER_CENTER, "Grid", bold(11.0), if lines { t.mode_on_text } else { t.mode_off_text });
if ui.interact(gl, ui.id().with("grid_lines"), Sense::click()).on_hover_text("Show grid lines").clicked() {
if resp.on_hover_text("Show grid lines").clicked() {
let _ = app.run("view.grid_lines", json!({}));
}
let gr = Rect::from_min_max(pos2(r.min.x + 60.0, r.min.y + 6.0), pos2(r.max.x - 4.0, r.min.y + 26.0));
@ -275,7 +283,7 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
let playing = app.is_playing();
let recording = app.engine.transport.recording;
let looped = app.engine.session().edit.loop_playback;
group(ui, 176.0, 64.0, |ui, _| {
group(ui, 176.0, 64.0, |ui, g| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 4.0);
ui.horizontal(|ui| {
@ -284,51 +292,27 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.run("options.loop_playback", json!({}));
}
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(3),
if resp.hovered() { t.button_hi } else { t.button },
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
key(ui.painter(), r, &resp, key_fill(&resp));
icons::draw(ui.painter(), r.shrink(5.0), "stop", if playing { t.text } else { t.accent });
if resp.on_hover_text("Stop (Space)").clicked() {
let _ = app.run("transport.stop", json!({}));
}
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(3),
if playing {
Color32::from_rgb(40, 90, 50)
} else if resp.hovered() {
t.button_hi
} else {
t.button
},
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
key(ui.painter(), r, &resp, if playing { Color32::from_rgb(40, 90, 50) } else { key_fill(&resp) });
icons::draw(ui.painter(), r.shrink(5.0), "play", if playing { Color32::from_rgb(120, 255, 140) } else { t.counter_text });
if resp.on_hover_text("Play (Space)").clicked() {
let _ = app.run("transport.play", json!({}));
}
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(3),
if recording {
Color32::from_rgb(110, 30, 30)
} else if resp.hovered() {
t.button_hi
} else {
t.button
},
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) });
icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec);
if resp.on_hover_text("Record (Cmd+Space)").clicked() {
let resp = resp.on_hover_text("Record (Cmd+Space)");
if let Some(why) = app.record_hint() {
// Below the whole group, so the hint covers none of its buttons.
let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y));
egui::Tooltip::always_open(ui.ctx().clone(), resp.layer_id, resp.id, below).show(|ui| ui.label(why));
}
if resp.clicked() {
let _ = app.run("transport.record", json!({}));
}
});
@ -360,9 +344,10 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(150.0, 64.0), Sense::hover());
ui.painter().rect(r, CornerRadius::same(4), t.counter_bg, Stroke::new(1.0, t.counter_border), StrokeKind::Inside);
let co = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 6.0), vec2(64.0, 15.0));
ui.painter().rect_filled(co, 1.0, if countoff { t.mode_on } else { t.counter_off });
let resp = ui.interact(co, ui.id().with("countoff"), Sense::click());
key(ui.painter(), co, &resp, if countoff { t.mode_on } else { t.counter_off });
ui.painter().text(co.center(), Align2::CENTER_CENTER, "Count Off", bold(10.0), if countoff { t.mode_on_text } else { t.mode_off_text });
if ui.interact(co, ui.id().with("countoff"), Sense::click()).clicked() {
if resp.clicked() {
let _ = app.run("options.countoff", json!({}));
}
ui.painter().text(pos2(r.max.x - 8.0, co.center().y), Align2::RIGHT_CENTER, format!("{bars} bars"), mono(11.0), t.counter_text);
@ -370,7 +355,7 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 32.0), Align2::RIGHT_CENTER, meter, mono(11.0), t.counter_text);
ui.painter().text(pos2(r.min.x + 70.0, r.min.y + 48.0), Align2::RIGHT_CENTER, "Tempo", regular(11.0), t.counter_label);
icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 82.0, r.min.y + 48.0), vec2(13.0, 13.0)), "note", t.counter_text);
ui.painter().text(pos2(r.max.x - 8.0, r.min.y + 48.0), Align2::RIGHT_CENTER, format!("{bpm:.4}"), mono(11.0), t.counter_text);
tempo_value(app, ui, r.max.x - 8.0, r.min.y + 48.0, bpm);
let clk = Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 40.0), vec2(18.0, 18.0));
icons::draw(ui.painter(), clk, "click", if click { t.counter_text } else { t.text_dim });
if ui.interact(clk, ui.id().with("click"), Sense::click()).on_hover_text("Click").clicked() {
@ -378,6 +363,36 @@ fn tempo_meter(app: &mut SoundApp, ui: &mut Ui) {
}
}
/// The tempo in effect at the playhead, right-aligned at `right`: drag it (Shift for fine steps),
/// or click and type.
fn tempo_value(app: &mut SoundApp, ui: &mut Ui, right: f32, y: f32, bpm: f64) {
let t = Tokens::current();
let w = ui.painter().layout_no_wrap(format!("{bpm:.4}"), mono(11.0), t.counter_text).size().x;
let rect = Rect::from_min_max(pos2(right - w - 4.0, y - 8.0), pos2(right + 4.0, y + 8.0));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(rect));
c.style_mut().override_font_id = Some(mono(11.0));
let v = c.visuals_mut();
v.override_text_color = Some(t.counter_text);
for w in [&mut v.widgets.inactive, &mut v.widgets.hovered, &mut v.widgets.active] {
w.weak_bg_fill = Color32::TRANSPARENT;
w.bg_stroke = Stroke::NONE;
}
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.counter_border);
let mut value = bpm;
let resp = c.add_sized(rect.size(), egui::DragValue::new(&mut value).speed(0.1).range(5.0..=1000.0).fixed_decimals(4));
if resp.changed() {
let s = app.engine.session();
let tick = s.tempo.samples_to_ticks(app.position(), s.sample_rate);
let event = s.tempo.tempos().iter().rev().find(|e| e.tick <= tick).map_or(0, |e| e.tick);
let at = s.tempo.tick_to_samples(event, s.sample_rate);
let _ = app.engine.execute_merged("event.tempo", &json!({"bpm": value, "at": at}), "toolbar_tempo");
}
if resp.drag_stopped() || resp.lost_focus() {
app.engine.end_merge();
}
resp.on_hover_text("Drag to change the tempo (Shift: fine steps), or click to type it");
}
fn output_meter(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(26.0, 64.0), Sense::hover());
let m = app.main_meter;

View File

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

View File

@ -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<egui::Rangef>, 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<f32> {
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);
}

View File

@ -143,6 +143,8 @@ fn color_palette(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
ui.separator();
ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window"));
});
app.ui.show_color_palette = open;
}
@ -202,10 +204,7 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) {
("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::<usize>().to_string()),
("Audio files", s.sources.len().to_string()),
("Markers", s.markers.len().to_string()),
(
"Length",
soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0),
),
("Length", s.format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs)),
] {
ui.label(k);
ui.label(v);
@ -334,7 +333,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
}
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase();
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
.then_some((name, is_dir))
})
.collect()

View File

@ -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<String> {
fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet<String>) {
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");
}

View File

@ -0,0 +1,38 @@
use serde_json::{Value, json};
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp, control, menus};
fn commands(params: Value) -> Vec<Value> {
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"));
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ClippedShape>,
}
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<Event>) {
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<String> {
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<Item = &'a TextShape> + '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<f32> = 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}");
}

View File

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

View File

@ -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<Event> {
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::<Vec<_>>(), 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<Event>| {
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::<Rect>(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);
}

View File

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

Some files were not shown because too many files have changed in this diff Show More