Merge main into #219

This commit is contained in:
Brandon Thomas 2026-10-13 06:01:05 -04:00
commit 82eff0f561
166 changed files with 17465 additions and 2217 deletions

36
Cargo.lock generated
View File

@ -3424,7 +3424,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@ -3442,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"serde",
@ -3452,7 +3452,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3464,7 +3464,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"serde_json",
@ -3474,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"clap-sys",
"libloading",
@ -3486,7 +3486,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@ -3497,7 +3497,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3508,7 +3508,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3530,7 +3530,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3540,7 +3540,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"rayon",
@ -3556,7 +3556,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3569,7 +3569,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"cpal",
"log",
@ -3582,7 +3582,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3592,7 +3592,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
@ -3618,7 +3618,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"image",
"proptest",
@ -3629,7 +3629,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"libloading",
"log",
@ -3644,7 +3644,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.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.5.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 {
@ -150,6 +151,9 @@ impl WebShell {
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) {
return;
}
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {

View File

@ -1,13 +1,28 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
use serde_json::{Value, json};
use soundcraft_ui_egui::ControlRequest;
use std::io::{BufRead, BufReader, Write};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::time::Duration;
/// Longest accepted request line (bytes, without the newline). The largest payload a request
/// carries is a plugin state for `mix.insert_state`: base64 of at most `b64::MAX_DECODED_BYTES`
/// (64 MiB, so about 85 MiB of text). Room for that and 1 MiB of JSON around it; replies
/// (screenshots) aren't bounded by this.
const MAX_LINE: usize = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4 + (1 << 20);
// With MAX_CONNECTIONS, at most 1.5 GiB of request lines are buffered at once.
const _: () = assert!(MAX_LINE <= 96 << 20);
/// Connections served at once; further ones get an error line and are closed.
const MAX_CONNECTIONS: usize = 16;
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let (tx, rx) = channel::<ControlRequest>();
let listener = match TcpListener::bind(("127.0.0.1", port)) {
@ -20,28 +35,85 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
log::info!("control server listening on 127.0.0.1:{actual}");
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || {
for stream in listener.incoming().flatten() {
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || serve(stream, tx, ctx));
}
});
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
if let Err(e) = spawned {
log::error!("control thread failed: {e}");
}
rx
}
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
/// its thread can't be started, or panics).
struct Slot(Arc<AtomicUsize>);
impl Drop for Slot {
fn drop(&mut self) {
self.0.fetch_sub(1, Ordering::SeqCst);
}
}
fn accept_loop(listener: TcpListener, tx: Sender<ControlRequest>, ctx: egui::Context) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in listener.incoming().flatten() {
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
open.fetch_sub(1, Ordering::SeqCst);
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "too many control connections"}));
continue;
}
let slot = Slot(Arc::clone(&open));
let tx = tx.clone();
let ctx = ctx.clone();
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || {
let _slot = slot;
serve(stream, tx, ctx);
});
}
}
/// Read one `\n`-terminated line of at most `max` bytes into `buf` (newline stripped).
/// `Ok(false)` at end of stream; `Err` when the line is too long or the read fails.
fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -> Result<bool, String> {
buf.clear();
// `take` bounds the bytes buffered for one line: a peer can't make us allocate more than `max`.
let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1);
let n = reader.take(limit).read_until(b'\n', buf).map_err(|e| e.to_string())?;
if n == 0 {
return Ok(false);
}
if buf.last() == Some(&b'\n') {
buf.pop();
if buf.last() == Some(&b'\r') {
buf.pop();
}
return Ok(true);
}
if buf.len() > max {
return Err(format!("request line longer than {max} bytes; closing the connection"));
}
// Last line without a newline, then end of stream.
Ok(true)
}
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
for line in BufReader::new(read).lines() {
let Ok(line) = line else { break };
let mut reader = BufReader::new(read);
let mut buf = Vec::new();
loop {
match read_bounded_line(&mut reader, &mut buf, MAX_LINE) {
Ok(true) => {}
Ok(false) => break,
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
break;
}
}
// Bytes that aren't UTF-8 end the connection, as `BufRead::lines` did.
let Ok(line) = std::str::from_utf8(&buf) else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
@ -64,3 +136,80 @@ fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lines_are_read_up_to_the_limit() {
let mut buf = Vec::new();
let mut input: &[u8] = b"aaaaaaaaaa\nbb\r\ncc";
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"aaaaaaaaaa", "a line exactly at the limit is accepted");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"bb", "CRLF is stripped");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(true));
assert_eq!(buf, b"cc", "a last line without a newline is still read");
assert_eq!(read_bounded_line(&mut input, &mut buf, 10), Ok(false), "then end of stream");
}
#[test]
fn an_overlong_line_is_refused_without_reading_the_rest() {
let mut buf = Vec::new();
let long = [b'x'; 100];
let mut input: &[u8] = &long;
assert!(read_bounded_line(&mut input, &mut buf, 10).is_err());
assert!(buf.len() <= 11, "at most limit + 1 bytes are buffered, got {}", buf.len());
assert_eq!(input.len(), 100 - 11, "the rest of the line is left unread");
}
#[test]
fn the_largest_plugin_state_fits_in_a_line() {
let state = soundcraft_engine::model::b64::MAX_DECODED_BYTES.div_ceil(3) * 4;
let request = json!({"id": 1, "method": "engine.execute", "params": {"command": "mix.insert_state", "params": {"track": "Audio 1", "slot": 0, "state": ""}}});
assert!(state + request.to_string().len() <= MAX_LINE);
}
/// The control server on a loopback port, with a stand-in UI that echoes each method.
fn serve_echo() -> std::net::SocketAddr {
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = channel::<ControlRequest>();
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
std::thread::spawn(move || {
for req in rx {
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
}
});
addr
}
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
let mut r = BufReader::new(c);
for (id, method) in [(3, "ping"), (4, "pong")] {
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!((v["id"].as_i64(), v["method"].as_str()), (Some(id), Some(method)), "{line}");
}
}
#[test]
fn extra_connections_are_refused() {
let addr = serve_echo();
// Idle connections are accepted in order and held open.
let held: Vec<TcpStream> = (0..MAX_CONNECTIONS).map(|_| TcpStream::connect(addr).unwrap()).collect();
let mut extra = TcpStream::connect(addr).unwrap();
extra.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let mut line = String::new();
BufReader::new(&mut extra).read_line(&mut line).unwrap();
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
}

View File

@ -44,7 +44,7 @@ impl eframe::App for App {
if let Some(menu) = &mut self.2 {
menu.process(&mut self.0);
}
self.0.logic(ctx);
self.0.guarded(|app| app.logic(ctx));
#[cfg(target_os = "macos")]
if let Some(menu) = &mut self.2 {
menu.sync(&self.0);
@ -85,9 +85,10 @@ impl eframe::App for App {
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.0.guarded(|app| app.ui(ui));
}
fn on_exit(&mut self) {
self.0.close_autosave();
save_prefs(&self.0.ui);
if let Some(logger) = self.1 {
logger.report_audio_thread();
@ -162,14 +163,20 @@ fn services() -> Services {
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
_ => d.add_filter(
"Audio",
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
),
};
d.pick_file().map(|p| p.to_string_lossy().into_owned())
})),
pick_save: Some(Box::new(|_purpose: &str, default: &str| {
pick_save: Some(Box::new(|purpose: &str, default: &str| {
let path = std::path::Path::new(default);
let mut d = rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]);
let mut d = match purpose {
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &["wav", "aif", "aiff", "flac"]),
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
};
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir);
}
if let Some(n) = path.file_name().and_then(|n| n.to_str()) {
d = d.set_file_name(n);
}
@ -258,6 +265,7 @@ fn main() -> eframe::Result {
let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) };
let mut app = SoundApp::new(engine, player, services());
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.leftovers = app.autosave_dir.as_deref().map(soundcraft_engine::autosave::leftovers).unwrap_or_default();
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;

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

@ -2,11 +2,14 @@
//! thin safe wrapper over one Audio Unit instance.
//!
//! Every pointer handed to the Audio Unit points into memory this module owns for at least as
//! long as the unit can use it: the input state the render callback reads is a leaked `Box`
//! freed only after the unit is disposed, and output buffers are re-pointed before each render.
//! long as the unit can use it: the input state the render callback reads and the host time the
//! host callbacks read are leaked `Box`es freed only after the unit is disposed, and output buffers
//! are re-pointed before each render.
use soundcraft_dsp::Transport;
use std::ffi::{CStr, c_char, c_void};
use std::ptr;
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, AtomicU64, Ordering};
type OSStatus = i32;
type AudioComponent = *mut c_void;
@ -40,6 +43,7 @@ const PROP_MAX_FRAMES: u32 = 14;
const PROP_PARAMETER_VALUE_STRINGS: u32 = 16;
const PROP_TAIL_TIME: u32 = 20;
const PROP_SET_RENDER_CALLBACK: u32 = 23;
const PROP_HOST_CALLBACKS: u32 = 27;
const SCOPE_GLOBAL: u32 = 0;
const SCOPE_INPUT: u32 = 1;
@ -140,6 +144,153 @@ struct AudioBufferListHeader {
first: AudioBuffer,
}
type BeatAndTempoProc = unsafe extern "C" fn(*mut c_void, *mut f64, *mut f64) -> OSStatus;
type MusicalTimeProc = unsafe extern "C" fn(*mut c_void, *mut u32, *mut f32, *mut u32, *mut f64) -> OSStatus;
type TransportStateProc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus;
type TransportState2Proc = unsafe extern "C" fn(*mut c_void, *mut u8, *mut u8, *mut u8, *mut f64, *mut u8, *mut f64, *mut f64) -> OSStatus;
#[repr(C)]
struct HostCallbackInfo {
host_user_data: *mut c_void,
beat_and_tempo: Option<BeatAndTempoProc>,
musical_time_location: Option<MusicalTimeProc>,
transport_state: Option<TransportStateProc>,
transport_state2: Option<TransportState2Proc>,
}
/// What the host callbacks report: the transport of the block being rendered. Atomics, because a
/// unit may also ask from another thread.
struct HostTime {
playing: AtomicBool,
recording: AtomicBool,
changed: AtomicBool,
samples: AtomicI64,
tempo: AtomicU64,
quarters: AtomicU64,
bar_start: AtomicU64,
numerator: AtomicU32,
denominator: AtomicU32,
sample_rate: AtomicU64,
}
impl HostTime {
fn new() -> HostTime {
HostTime {
playing: AtomicBool::new(false),
recording: AtomicBool::new(false),
changed: AtomicBool::new(false),
samples: AtomicI64::new(0),
tempo: AtomicU64::new(120f64.to_bits()),
quarters: AtomicU64::new(0),
bar_start: AtomicU64::new(0),
numerator: AtomicU32::new(4),
denominator: AtomicU32::new(4),
sample_rate: AtomicU64::new(48_000f64.to_bits()),
}
}
fn set(&self, t: &Transport) {
if self.playing.swap(t.playing, Ordering::Relaxed) != t.playing {
self.changed.store(true, Ordering::Relaxed);
}
self.recording.store(t.recording, Ordering::Relaxed);
self.samples.store(t.samples, Ordering::Relaxed);
self.tempo.store(t.tempo.to_bits(), Ordering::Relaxed);
self.quarters.store(t.quarters.to_bits(), Ordering::Relaxed);
self.bar_start.store(t.bar_start.to_bits(), Ordering::Relaxed);
self.numerator.store(t.numerator, Ordering::Relaxed);
self.denominator.store(t.denominator, Ordering::Relaxed);
}
}
fn load_f64(a: &AtomicU64) -> f64 {
f64::from_bits(a.load(Ordering::Relaxed))
}
/// Writes through an out-pointer the unit may leave null.
///
/// # Safety
/// `p` is null or valid for writing a `T`.
unsafe fn put<T>(p: *mut T, v: T) {
// SAFETY: per the contract above.
if let Some(p) = unsafe { p.as_mut() } {
*p = v;
}
}
unsafe extern "C" fn beat_and_tempo(user: *mut c_void, beat: *mut f64, tempo: *mut f64) -> OSStatus {
// SAFETY: `user` is the `HostTime` leaked by `Instance::new`, alive until after the unit is
// disposed.
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
// SAFETY: the unit's out-pointers (or null).
unsafe {
put(beat, load_f64(&t.quarters));
put(tempo, load_f64(&t.tempo));
}
0
}
unsafe extern "C" fn musical_time(
user: *mut c_void,
to_next_beat: *mut u32,
numerator: *mut f32,
denominator: *mut u32,
downbeat: *mut f64,
) -> OSStatus {
// SAFETY: as in `beat_and_tempo`.
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
let q = load_f64(&t.quarters);
let per_quarter = load_f64(&t.sample_rate) * 60.0 / load_f64(&t.tempo).max(1.0);
let next = ((q.ceil() - q) * per_quarter).round();
let next = if next.is_finite() { next.clamp(0.0, f64::from(u32::MAX)) as u32 } else { 0 };
// SAFETY: the unit's out-pointers (or null).
unsafe {
put(to_next_beat, next);
put(numerator, t.numerator.load(Ordering::Relaxed) as f32);
put(denominator, t.denominator.load(Ordering::Relaxed));
put(downbeat, load_f64(&t.bar_start));
}
0
}
unsafe extern "C" fn transport_state2(
user: *mut c_void,
playing: *mut u8,
recording: *mut u8,
changed: *mut u8,
sample: *mut f64,
cycling: *mut u8,
cycle_start: *mut f64,
cycle_end: *mut f64,
) -> OSStatus {
// SAFETY: as in `beat_and_tempo`.
let Some(t) = (unsafe { (user as *const HostTime).as_ref() }) else { return -50 };
// SAFETY: the unit's out-pointers (or null).
unsafe {
put(playing, u8::from(t.playing.load(Ordering::Relaxed)));
put(recording, u8::from(t.recording.load(Ordering::Relaxed)));
put(changed, u8::from(t.changed.swap(false, Ordering::Relaxed)));
put(sample, t.samples.load(Ordering::Relaxed) as f64);
put(cycling, 0);
put(cycle_start, 0.0);
put(cycle_end, 0.0);
}
0
}
unsafe extern "C" fn transport_state(
user: *mut c_void,
playing: *mut u8,
changed: *mut u8,
sample: *mut f64,
cycling: *mut u8,
cycle_start: *mut f64,
cycle_end: *mut f64,
) -> OSStatus {
// SAFETY: the same pointers as `transport_state2`, without the recording flag.
unsafe { transport_state2(user, playing, ptr::null_mut(), changed, sample, cycling, cycle_start, cycle_end) }
}
type AuRenderCallback = unsafe extern "C" fn(
ref_con: *mut c_void,
action_flags: *mut u32,
@ -361,6 +512,8 @@ pub struct Instance {
unit: AudioUnit,
/// Leaked `Box<InputState>` read by `input_callback`; freed in `Drop` after disposal.
input: *mut InputState,
/// Leaked `Box<HostTime>` read by the host callbacks; freed in `Drop` after disposal.
time: *mut HostTime,
outputs: Vec<Vec<f32>>,
abl: AudioBufferList,
has_input: bool,
@ -369,11 +522,13 @@ pub struct Instance {
max_frames: usize,
sample_rate: f64,
sample_time: f64,
link: std::sync::Arc<StateLink>,
}
// SAFETY: an Audio Unit instance may be used from any thread as long as calls are not
// concurrent; `Instance` is only reachable through `&mut`/`&` of its owner, which the `Plugin`
// contract keeps on one thread at a time. The input state is owned exclusively by it.
// contract keeps on one thread at a time. The input state and host time are owned exclusively by
// it (the host time is all atomics).
unsafe impl Send for Instance {}
impl Instance {
@ -398,19 +553,42 @@ impl Instance {
return Err(format!("AudioComponentInstanceNew failed ({st})"));
}
let input = Box::into_raw(Box::new(InputState { bufs: Vec::new(), frames: 0 }));
let time = Box::into_raw(Box::new(HostTime::new()));
let empty = AudioBuffer { number_channels: 1, data_byte_size: 0, data: ptr::null_mut() };
Ok(Instance {
let inst = Instance {
unit,
input,
time,
outputs: Vec::new(),
abl: AudioBufferList { number_buffers: 0, buffers: [empty; MAX_AU_CHANNELS] },
has_input,
link: std::sync::Arc::new(StateLink(std::sync::Mutex::new(LinkedUnit(Some(unit))))),
initialized: false,
channels: 0,
max_frames: 0,
sample_rate: 48_000.0,
sample_time: 0.0,
})
};
let callbacks = HostCallbackInfo {
host_user_data: time.cast(),
beat_and_tempo: Some(beat_and_tempo),
musical_time_location: Some(musical_time),
transport_state: Some(transport_state),
transport_state2: Some(transport_state2),
};
// Units that never ask for host time may refuse it.
let _ = inst.set_property(PROP_HOST_CALLBACKS, SCOPE_GLOBAL, 0, &callbacks);
Ok(inst)
}
fn time(&self) -> &HostTime {
// SAFETY: `time` is the `HostTime` leaked in `new`, freed only in `Drop`.
unsafe { &*self.time }
}
/// The transport the host callbacks report during the next renders.
pub fn set_transport(&self, t: &Transport) {
self.time().set(t);
}
fn set_property<T>(&self, id: u32, scope: u32, element: u32, value: &T) -> OSStatus {
@ -490,6 +668,7 @@ impl Instance {
self.initialized = true;
self.channels = ch;
self.sample_rate = sample_rate;
self.time().sample_rate.store(sample_rate.to_bits(), Ordering::Relaxed);
self.outputs = (0..ch).map(|_| vec![0.0; mf]).collect();
// SAFETY: the callback only runs inside `AudioUnitRender`, which is not running now
// (we hold `&mut self`), so this exclusive access does not alias.
@ -678,35 +857,12 @@ impl Instance {
out
}
/// The unit's `ClassInfo` as a binary property list.
pub fn save_state(&self) -> Option<Vec<u8>> {
let mut plist: CFTypeRef = ptr::null();
let mut size = std::mem::size_of::<CFTypeRef>() as u32;
// SAFETY: the property is a `CFPropertyListRef` returned +1; `plist` is a local pointer.
let st = unsafe { AudioUnitGetProperty(self.unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) };
if st != 0 || plist.is_null() {
release(plist);
return None;
}
let mut err: CFTypeRef = ptr::null();
// SAFETY: `plist` is a live property list; `err` is a local out pointer.
let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) };
release(err);
release(plist);
if data.is_null() {
return None;
}
// SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length.
let out = unsafe {
let len = CFDataGetLength(data);
let p = CFDataGetBytePtr(data);
if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) }
};
release(data);
out
/// The link for state handles.
pub fn state_link(&self) -> std::sync::Arc<StateLink> {
std::sync::Arc::clone(&self.link)
}
/// Restores a blob from [`Instance::save_state`] (hostile input: parsed by CoreFoundation,
/// Restores a blob from [`StateLink::save_state`] (hostile input: parsed by CoreFoundation,
/// must be a dictionary, and the unit validates it).
pub fn load_state(&self, bytes: &[u8]) -> bool {
if bytes.is_empty() || bytes.len() > MAX_STATE {
@ -733,16 +889,71 @@ impl Instance {
}
}
struct LinkedUnit(Option<AudioUnit>);
// SAFETY: the pointer is only used under the link's mutex, while the instance keeps the unit alive.
unsafe impl Send for LinkedUnit {}
/// The link between an instance and its state handles: the unit's state is read on the main
/// thread while the instance renders on the audio thread (hosts make property calls from other
/// threads during rendering). The instance clears the link under its lock before it disposes of
/// the unit, so a handle never reads a dead unit.
pub(crate) struct StateLink(std::sync::Mutex<LinkedUnit>);
impl StateLink {
fn lock(&self) -> std::sync::MutexGuard<'_, LinkedUnit> {
self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The unit's `ClassInfo` as a binary property list (`None` once the instance is gone).
pub fn save_state(&self) -> Option<Vec<u8>> {
let g = self.lock();
let unit = g.0?;
let mut plist: CFTypeRef = ptr::null();
let mut size = std::mem::size_of::<CFTypeRef>() as u32;
// SAFETY: the unit is live while the link holds it (see the type); the property is a
// `CFPropertyListRef` returned +1; `plist` is a local pointer.
let st = unsafe { AudioUnitGetProperty(unit, PROP_CLASS_INFO, SCOPE_GLOBAL, 0, (&mut plist as *mut CFTypeRef).cast(), &mut size) };
if st != 0 || plist.is_null() {
release(plist);
return None;
}
let mut err: CFTypeRef = ptr::null();
// SAFETY: `plist` is a live property list; `err` is a local out pointer.
let data = unsafe { CFPropertyListCreateData(ptr::null(), plist, CF_PROPERTY_LIST_BINARY_FORMAT, 0, &mut err) };
release(err);
release(plist);
if data.is_null() {
return None;
}
// SAFETY: `data` is a live CFData we own; its byte pointer is valid for its length.
let out = unsafe {
let len = CFDataGetLength(data);
let p = CFDataGetBytePtr(data);
if len <= 0 || len as usize > MAX_STATE || p.is_null() { None } else { Some(std::slice::from_raw_parts(p, len as usize).to_vec()) }
};
release(data);
out
}
fn kill(&self) {
self.lock().0 = None;
}
}
impl Drop for Instance {
fn drop(&mut self) {
self.link.kill();
// SAFETY: the unit is live (disposed only here); after disposal nothing can call the
// input callback, so the leaked input state can be freed.
// input callback or the host callbacks, so the leaked input state and host time can be
// freed.
unsafe {
if self.initialized {
AudioUnitUninitialize(self.unit);
}
AudioComponentInstanceDispose(self.unit);
drop(Box::from_raw(self.input));
drop(Box::from_raw(self.time));
}
}
}
@ -751,6 +962,31 @@ impl Drop for Instance {
mod tests {
use super::*;
#[test]
fn host_callbacks_report_the_transport() {
let time = HostTime::new();
time.set(&Transport { playing: true, tempo: 120.0, numerator: 3, denominator: 4, samples: 48_000, quarters: 2.25, ..Transport::default() });
let user = ptr::from_ref(&time).cast_mut().cast::<c_void>();
let (mut beat, mut tempo) = (0.0, 0.0);
// SAFETY: `user` points at a live `HostTime`; the out-pointers are locals.
assert_eq!(unsafe { beat_and_tempo(user, &mut beat, &mut tempo) }, 0);
assert_eq!((beat, tempo), (2.25, 120.0));
let (mut next, mut num, mut den, mut down) = (0u32, 0f32, 0u32, -1.0);
// SAFETY: as above.
assert_eq!(unsafe { musical_time(user, &mut next, &mut num, &mut den, &mut down) }, 0);
assert_eq!((next, num, den, down), (18_000, 3.0, 4, 0.0));
let (mut playing, mut recording, mut changed, mut sample) = (0u8, 1u8, 0u8, 0.0);
let (no_flag, no_beat) = (ptr::null_mut::<u8>(), ptr::null_mut::<f64>());
// SAFETY: as above; the unit may pass null for what it doesn't want.
let st = unsafe { transport_state2(user, &mut playing, &mut recording, &mut changed, &mut sample, no_flag, no_beat, no_beat) };
assert_eq!((st, playing, recording, changed, sample), (0, 1, 0, 1, 48_000.0));
// SAFETY: as above.
unsafe { transport_state(user, &mut playing, &mut changed, no_beat, no_flag, no_beat, no_beat) };
assert_eq!(changed, 0, "a change is reported once");
// SAFETY: a null user pointer is refused before anything is read.
assert_eq!(unsafe { beat_and_tempo(ptr::null_mut(), &mut beat, &mut tempo) }, -50);
}
#[test]
fn struct_layouts_match_the_c_headers() {
assert_eq!(std::mem::size_of::<AudioComponentDescription>(), 20);
@ -761,6 +997,7 @@ mod tests {
assert_eq!(std::mem::offset_of!(AudioBufferList, buffers), 8);
assert_eq!(std::mem::offset_of!(AudioBufferListHeader, first), 8);
assert_eq!(std::mem::size_of::<AuRenderCallbackStruct>(), 16);
assert_eq!(std::mem::size_of::<HostCallbackInfo>(), 40);
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, unit_name), 56);
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, cf_name_string), 72);
assert_eq!(std::mem::offset_of!(AudioUnitParameterInfo, flags), 96);

View File

@ -23,8 +23,10 @@
//! (`kAudioUnitProperty_ClassInfo`) as a binary plist; [`Plugin::load_state`] parses it back and
//! sets it, then re-reads the parameter values.
//!
//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses,
//! parameters outside the global scope, and host callbacks (tempo, transport).
//! Host callbacks report the session's tempo, meter, position and play state.
//!
//! Not hosted yet: Audio Unit editor views (Cocoa `AUCocoaUIBase`), sidechain/extra buses, and
//! parameters outside the global scope.
//!
//! Off macOS (and on `wasm32`) the crate compiles to stubs: scans are empty and nothing can be
//! created.

View File

@ -1,7 +1,7 @@
//! `AuPlugin`: a loaded Audio Unit behind the safe `soundcraft_dsp::Plugin` trait.
use crate::ffi::Instance;
use soundcraft_dsp::{Plugin, PluginInfo};
use soundcraft_dsp::{Plugin, PluginInfo, Transport};
/// Most MIDI events queued between two blocks (extra events are dropped).
const EVENT_CAP: usize = 1024;
@ -170,13 +170,12 @@ impl Plugin for AuPlugin {
if ch == 1 && au_ch >= 2 {
if let (Some(l), Some(r)) = (self.inst.output(0, n), self.inst.output(1, n)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
let v = 0.5 * (a + b);
*d = if v.is_finite() { v } else { 0.0 };
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.inst.output(c.min(au_ch.saturating_sub(1)), n) {
for (d, &s) in dst.iter_mut().zip(src) {
*d = if s.is_finite() { s } else { 0.0 };
*d = s;
}
}
}
@ -200,6 +199,10 @@ impl Plugin for AuPlugin {
self.latency
}
fn set_transport(&mut self, transport: &Transport) {
self.inst.set_transport(transport);
}
fn tail_samples(&self) -> usize {
self.tail
}
@ -227,6 +230,13 @@ impl Plugin for AuPlugin {
}
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
let [status, data1, data2] = msg;
if status & 0xF0 >= 0xA0 && status & 0xF0 < 0xF0 {
self.push_event(MidiEvent { time: u32::try_from(offset).unwrap_or(u32::MAX), status, data1: data1 & 0x7F, data2: data2 & 0x7F });
}
}
fn all_notes_off(&mut self) {
for k in 0..128u8 {
if self.held.get(k as usize).copied().unwrap_or(false) {
@ -237,10 +247,6 @@ impl Plugin for AuPlugin {
self.push_event(MidiEvent { time: 0, status: 0xB0, data1: 123, data2: 0 });
}
fn save_state(&mut self) -> Option<Vec<u8>> {
self.inst.save_state()
}
fn load_state(&mut self, data: &[u8]) -> bool {
let ok = self.inst.load_state(data);
if ok {
@ -248,4 +254,9 @@ impl Plugin for AuPlugin {
}
ok
}
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
let link = self.inst.state_link();
Some(Box::new(move || link.save_state()))
}
}

View File

@ -100,6 +100,35 @@ fn lowpass_attenuates_highs() {
assert!(run_tone(p.as_mut(), 100.0).is_finite());
}
#[test]
fn state_reader_reads_while_another_thread_renders() {
if !available(LOWPASS) {
return;
}
let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap();
let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap();
let mut p = soundcraft_au_host::create(LOWPASS).unwrap();
p.prepare(48_000.0, 512, 2);
assert!(p.set_param(cutoff.id, 500.0));
let mut reader = p.state_reader().expect("every unit has a ClassInfo");
let audio = std::thread::spawn(move || {
for _ in 0..4 {
run_tone(p.as_mut(), 100.0);
}
p
});
while !audio.is_finished() {
assert!(reader.save_state().is_some());
}
let p = audio.join().unwrap();
let state = reader.save_state().expect("ClassInfo");
drop(p);
let mut q = soundcraft_au_host::create(LOWPASS).unwrap();
assert_eq!(reader.save_state(), None);
assert!(q.load_state(&state));
assert!((q.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", q.param(cutoff.id));
}
#[test]
fn delay_runs_mono_stereo_and_odd_blocks() {
if !available(DELAY) {
@ -167,4 +196,23 @@ fn dls_synth_plays_a_note() {
assert!(e > 1e-3, "{e}");
p.note_off(0, 64);
p.reset();
// Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating
// two DLS instances at once on two threads crashes inside the unit.)
p.prepare(48_000.0, 512, 2);
let loudest = |p: &mut Box<dyn Plugin>, volume: u8| {
p.midi(0, [0xB0, 7, volume]);
p.note_on(0, 60, 110);
let mut energy = 0.0f32;
for _ in 0..20 {
let mut io = vec![vec![0.0f32; 512]; 2];
p.process(&mut io, 512);
energy = energy.max(rms(&io[0]));
}
p.note_off(0, 60);
p.reset();
energy
};
let full = loudest(&mut p, 127);
let muted = loudest(&mut p, 0);
assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}");
}

View File

@ -1,8 +1,9 @@
//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the
//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, clamp_float, deinterleave, tag, validate_buffer};
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
/// AIFF-C version 1 timestamp for the FVER chunk.
const AIFC_VERSION_1: u32 = 0xA280_5140;
@ -162,25 +163,82 @@ fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32).
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let channels = buf.channels.len();
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
let frames = buf.frames();
let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len();
let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames);
let total = head_len.saturating_add(data_len).saturating_add(1);
let mut out = Vec::new();
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// An AIFF written block by block: a placeholder header first, then the real one (the same
/// length) written over it by [`Writer::finish`].
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
depth: BitDepth,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
validate_layout(channels, sample_rate)?;
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new() })
}
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
header(self.channels, self.sample_rate, self.depth, total)?;
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
let total = self.check_room(frames as u64)?;
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true)?;
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
}
let head = header(self.channels, self.sample_rate, self.depth, self.frames)?;
self.out.seek(SeekFrom::Start(self.start))?;
self.out.write_all(&head)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't
/// depend on `frames`.
fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result<Vec<u8>> {
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?;
let (bits, is_float): (u16, bool) = match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let data_len =
bytes.checked_mul(channels).and_then(|b| b.checked_mul(frames)).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let (bits, is_float) = sample_format(depth);
let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames);
let mut comm = Vec::with_capacity(40);
comm.extend_from_slice(&channels_i16.to_be_bytes());
comm.extend_from_slice(&frames_u32.to_be_bytes());
comm.extend_from_slice(&bits.to_be_bytes());
comm.extend_from_slice(&u32_to_extended(buf.sample_rate));
comm.extend_from_slice(&u32_to_extended(sample_rate));
if is_float {
comm.extend_from_slice(b"fl32");
let name = b"32-bit float";
@ -204,29 +262,10 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
head.extend_from_slice(&ssnd_len.to_be_bytes());
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
let total = head.len().saturating_add(data_len).saturating_add(data_len & 1);
let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1);
let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?;
if let Some(slot) = head.get_mut(4..8) {
slot.copy_from_slice(&form_len.to_be_bytes());
}
let mut out = Vec::new();
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
out.extend_from_slice(&head);
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
for f in 0..frames {
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
let s = ch.get(f).copied().unwrap_or(0.0);
if is_float {
out.extend_from_slice(&clamp_float(s).to_be_bytes());
} else {
let v = q.quantize(s).to_be_bytes();
out.extend_from_slice(v.get(4 - bytes..).unwrap_or_default());
}
}
}
if data_len % 2 == 1 {
out.push(0);
}
Ok(out)
Ok(head)
}

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,9 +1,10 @@
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
//! partitioned Rice-coded residuals.
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
use crate::pcm::{Quantizer, validate_buffer};
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
use crate::{AudioBuffer, AudioError, BitDepth, Result};
use std::io::{Cursor, Seek, SeekFrom, Write};
const BLOCK_SIZE: usize = 4096;
const MAX_PARTITION_ORDER: u32 = 6;
@ -269,31 +270,24 @@ fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
}
}
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let channels = buf.channels.len();
/// Checks that FLAC can hold the layout; returns the bits per sample.
fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result<u32> {
crate::pcm::validate_layout(channels, sample_rate)?;
if channels > 8 {
return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}")));
}
if buf.sample_rate > 655_350 {
return Err(AudioError::Unsupported(format!("FLAC sample rate {} exceeds 655350 Hz", buf.sample_rate)));
if sample_rate > 655_350 {
return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz")));
}
let bps: u32 = match depth {
BitDepth::Int16 => 16,
BitDepth::Int24 => 24,
BitDepth::Int32 => 32,
BitDepth::Float32 => return Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
};
let frames = buf.frames();
let total = frames as u64;
if total >= 1u64 << 36 {
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
match depth {
BitDepth::Int16 => Ok(16),
BitDepth::Int24 => Ok(24),
BitDepth::Int32 => Ok(32),
BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
}
}
let mut out = Vec::new();
out.try_reserve(frames.saturating_mul(channels).saturating_mul(bps as usize / 8) / 2)
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
out.extend_from_slice(b"fLaC");
fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec<u8> {
let mut si = BitWriter::new();
si.write(1, 1); // last metadata block
si.write(0, 7); // STREAMINFO
@ -302,7 +296,7 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
si.write(BLOCK_SIZE as u64, 16);
si.write(0, 24);
si.write(0, 24);
si.write(u64::from(buf.sample_rate), 20);
si.write(u64::from(sample_rate), 20);
si.write(channels as u64 - 1, 3);
si.write(u64::from(bps - 1), 5);
si.write(total >> 32, 4);
@ -310,25 +304,87 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
for _ in 0..4 {
si.write(0, 32); // MD5 unknown
}
out.extend_from_slice(&si.into_bytes());
si.into_bytes()
}
let (sr_code, sr_extra) = sample_rate_code(buf.sample_rate);
let mut quantizers = Quantizer::per_channel(bps, dither, channels);
let mut block: Vec<i64> = Vec::with_capacity(BLOCK_SIZE);
let mut frame_no: u64 = 0;
let mut start = 0usize;
while start < frames {
let len = (frames - start).min(BLOCK_SIZE);
/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count.
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
bps: u32,
quantizers: Vec<Quantizer>,
pending: Vec<Vec<i64>>,
frames: u64,
frame_no: u64,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
let bps = check(channels, sample_rate, depth)?;
let start = out.stream_position()?;
out.write_all(b"fLaC")?;
out.write_all(&stream_info(channels, sample_rate, bps, 0))?;
Ok(Writer {
out,
start,
channels,
sample_rate,
bps,
quantizers: Quantizer::per_channel(bps, dither, channels),
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
frames: 0,
frame_no: 0,
})
}
/// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
if total >= 1u64 << 36 {
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
}
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
let total = self.check_room(frames as u64)?;
let mut done = 0;
while done < frames {
let held = self.pending.first().map_or(0, Vec::len);
let n = (BLOCK_SIZE - held).min(frames - done);
for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() {
let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default();
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
}
done += n;
if held + n == BLOCK_SIZE {
self.write_frame()?;
}
}
self.frames = total;
Ok(())
}
fn write_frame(&mut self) -> Result<()> {
let len = self.pending.first().map_or(0, Vec::len);
if len == 0 {
return Ok(());
}
let (sr_code, sr_extra) = sample_rate_code(self.sample_rate);
let mut w = BitWriter::new();
w.write(0x3FFE, 14);
w.write(0, 1);
w.write(0, 1); // fixed blocksize stream
w.write(7, 4); // 16-bit (blocksize-1) follows
w.write(sr_code, 4);
w.write(channels as u64 - 1, 4);
w.write(bps_code(bps), 3);
w.write(self.channels as u64 - 1, 4);
w.write(bps_code(self.bps), 3);
w.write(0, 1);
write_utf8_number(&mut w, frame_no);
write_utf8_number(&mut w, self.frame_no);
w.write(len as u64 - 1, 16);
if let Some((v, n)) = sr_extra {
w.write(v, n);
@ -339,19 +395,87 @@ pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result
w.write(u64::from(b), 8);
}
w.write(u64::from(crc8(&header)), 8);
for (ch, q) in buf.channels.iter().zip(quantizers.iter_mut()) {
block.clear();
block.extend(ch.get(start..start + len).unwrap_or_default().iter().map(|&s| i64::from(q.quantize(s))));
write_subframe(&mut w, &block, bps);
for p in &mut self.pending {
write_subframe(&mut w, p, self.bps);
p.clear();
}
let mut frame = w.into_bytes();
let crc = crc16(&frame);
frame.extend_from_slice(&crc.to_be_bytes());
out.extend_from_slice(&frame);
start += len;
frame_no += 1;
self.out.write_all(&frame)?;
self.frame_no += 1;
Ok(())
}
Ok(out)
pub(crate) fn finish(mut self) -> Result<W> {
self.write_frame()?;
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
self.out.write_all(&info)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let bps = check(buf.channels.len(), buf.sample_rate, depth)?;
let frames = buf.frames();
let mut out = Vec::new();
out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2)
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its
/// metadata block holds: symphonia allocates each field's length before reading it.
pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
let mut pos = at + 4;
while let Some(head) = be_u32(b, pos) {
let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize);
let end = (body + len).min(b.len());
let overruns = match (head >> 24) & 0x7f {
4 => comments_overrun(b, body, end),
// Picture: type, MIME type, description, four size fields, data.
6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]),
_ => false,
};
if overruns {
return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into()));
}
if head >> 31 == 1 {
break;
}
pos = body + len;
}
Ok(())
}
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
/// The vendor string is skipped unread.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body) else { return false };
let at = (body + 4).saturating_add(vendor as usize);
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
}
/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are
/// the bytes skipped before each length, which `read` reads.
fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option<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)]

View File

@ -10,17 +10,22 @@
//! encoder), with optional TPDF dither.
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file
//! of any length never has to fit in memory.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod aiff;
mod buffer;
mod caf;
mod flac;
mod mp4;
mod pcm;
pub mod peaks;
mod symph;
mod wav;
pub use buffer::AudioBuffer;
use std::io::{Seek, Write};
/// Errors from probing, decoding and encoding.
#[derive(Debug, thiserror::Error)]
@ -37,6 +42,9 @@ pub enum AudioError {
/// Encoding failed (e.g. an empty buffer).
#[error("audio encode failed: {0}")]
Encode(String),
/// Writing the output failed.
#[error("audio write failed: {0}")]
Io(#[from] std::io::Error),
}
/// Result alias for this crate.
@ -187,7 +195,7 @@ fn detect_by_ext(ext: &str) -> FileFormat {
"flac" => FileFormat::Flac,
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
"ogg" | "oga" => FileFormat::Ogg,
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
"caf" => FileFormat::Caf,
_ => FileFormat::Other,
}
@ -205,16 +213,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<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 +238,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
Ok((info, buf))
}
wav::WavParse::Foreign { bwf } => {
let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?;
let (mut info, buf) = symph::decode(bytes, format)?;
info.bwf = bwf;
Ok((info, buf))
}
@ -242,9 +250,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
info.frames = buf.frames() as u64;
Ok((info, buf))
}
aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::decode(bytes, format),
},
_ => symph::decode(bytes, ext_hint, format),
_ => symph::decode(bytes, format),
}
}
@ -286,6 +294,60 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
}
}
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
enum Writer<W> {
Wav(wav::Writer<W>),
Aiff(aiff::Writer<W>),
Flac(flac::Writer<W>),
}
impl<W: Write + Seek> FileEncoder<W> {
/// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask
/// derived from the channel count.
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
let (depth, dither) = (opts.bit_depth, opts.dither);
Ok(FileEncoder(match opts.format {
FileFormat::Wav => Writer::Wav(wav::Writer::new(out, channels, sample_rate, depth, dither, opts.bwf.as_ref(), mask, false)?),
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
}))
}
/// Fails, without writing anything, when `frames` more frames would not fit the format (an
/// AIFF holds 4 GiB, a FLAC 2^36 frames).
pub fn check_room(&self, frames: u64) -> Result<()> {
match &self.0 {
Writer::Wav(w) => w.check_room(frames),
Writer::Aiff(w) => w.check_room(frames),
Writer::Flac(w) => w.check_room(frames),
}
.map(drop)
}
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
/// silence), unless they don't fit (see [`FileEncoder::check_room`]).
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
match &mut self.0 {
Writer::Wav(w) => w.write(block, frames),
Writer::Aiff(w) => w.write(block, frames),
Writer::Flac(w) => w.write(block, frames),
}
}
/// Completes the headers and hands back `out`, positioned at the end of the file.
pub fn finish(self) -> Result<W> {
match self.0 {
Writer::Wav(w) => w.finish(),
Writer::Aiff(w) => w.finish(),
Writer::Flac(w) => w.finish(),
}
}
}
/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one.
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
wav::read_channel_mask(bytes)

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

@ -1,7 +1,8 @@
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
use crate::{AudioError, Result};
use crate::{AudioError, BitDepth, Result};
use std::io::Write;
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
@ -36,6 +37,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<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()
@ -156,6 +163,49 @@ impl Quantizer {
}
}
/// Bits per sample at `depth`, and whether they are float.
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
}
}
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
pub(crate) fn write_interleaved(
out: &mut impl Write,
scratch: &mut Vec<u8>,
block: &[Vec<f32>],
frames: usize,
quantizers: &mut [Quantizer],
(bits, is_float): (u16, bool),
big_endian: bool,
) -> std::io::Result<()> {
let size = usize::from(bits / 8);
let mut start = 0;
while start < frames {
let end = frames.min(start + 4096);
scratch.clear();
for f in start..end {
for (c, q) in quantizers.iter_mut().enumerate() {
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
match (is_float, big_endian) {
(true, false) => scratch.extend_from_slice(&clamp_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
}
}
}
out.write_all(scratch)?;
start = end;
}
Ok(())
}
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
pub(crate) fn clamp_float(x: f32) -> f32 {
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
@ -163,13 +213,18 @@ pub(crate) fn clamp_float(x: f32) -> f32 {
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
if buf.channels.is_empty() {
validate_layout(buf.channels.len(), buf.sample_rate)
}
/// Check that a stream is encodable: at least one channel and a non-zero sample rate.
pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
if channels == 0 {
return Err(AudioError::Encode("buffer has no channels".into()));
}
if buf.channels.len() > usize::from(u16::MAX) {
if channels > usize::from(u16::MAX) {
return Err(AudioError::Encode("too many channels".into()));
}
if buf.sample_rate == 0 {
if sample_rate == 0 {
return Err(AudioError::Encode("sample rate is 0".into()));
}
Ok(())

View File

@ -1,48 +1,62 @@
//! Multi-resolution min/max overview for waveform drawing.
//! Multi-resolution overview for waveform drawing.
//!
//! Level 0 holds the (min, max) of each block of [`BLOCK`] samples; each following level merges
//! [`FANOUT`] blocks of the level below, until a level has a single block. Range queries combine
//! the coarsest whole blocks that fit inside the range, so a query costs O(log n) regardless of
//! zoom.
//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following
//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range
//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks
//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom.
/// Samples per level-0 block.
pub const BLOCK: usize = 32;
/// Blocks of level `n` merged into one block of level `n + 1`.
pub const FANOUT: usize = 4;
/// Upper bound on the number of columns [`Peaks::columns`] will produce.
/// Upper bound on the number of spans [`Peaks::spans`] will produce.
pub const MAX_COLUMNS: usize = 1 << 20;
/// Multi-resolution min/max overview for waveform drawing. Level 0 = blocks of 32 samples; each next level merges 4.
/// Multi-resolution overview of one channel for waveform drawing.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Peaks {
len: usize,
levels: Vec<Vec<(f32, f32)>>,
levels: Vec<Vec<Block>>,
}
const EMPTY: (f32, f32) = (f32::INFINITY, f32::NEG_INFINITY);
fn merge(a: (f32, f32), b: (f32, f32)) -> (f32, f32) {
(a.0.min(b.0), a.1.max(b.1))
/// What a run of samples sums up to; NaN samples count for nothing.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
struct Block {
min: f32,
max: f32,
squares: f32,
}
fn finish(acc: (f32, f32)) -> (f32, f32) {
if acc.0 > acc.1 { (0.0, 0.0) } else { acc }
impl Block {
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
fn point(s: f32) -> Block {
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
}
fn of(samples: &[f32]) -> Block {
samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s)))
}
fn merge(self, o: Block) -> Block {
Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares }
}
/// (min, max), or (0, 0) when nothing was summed up.
fn range(self) -> (f32, f32) {
if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) }
}
}
impl Peaks {
/// Build the overview of `samples` (one channel). NaN samples are ignored.
pub fn build(samples: &[f32]) -> Peaks {
let mut levels = Vec::new();
let level0: Vec<(f32, f32)> = samples.chunks(BLOCK).map(|c| finish(c.iter().fold(EMPTY, |acc, &s| merge(acc, (s, s))))).collect();
if level0.is_empty() {
return Peaks { len: 0, levels };
if samples.is_empty() {
return Peaks::default();
}
levels.push(level0);
while let Some(last) = levels.last() {
if last.len() <= 1 {
break;
}
let next: Vec<(f32, f32)> = last.chunks(FANOUT).map(|c| c.iter().fold(EMPTY, |acc, &b| merge(acc, b))).collect();
let mut levels: Vec<Vec<Block>> = vec![samples.chunks(BLOCK).map(Block::of).collect()];
while let Some(last) = levels.last().filter(|l| l.len() > 1) {
let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect();
levels.push(next);
}
Peaks { len: samples.len(), levels }
@ -58,65 +72,84 @@ impl Peaks {
self.levels.len()
}
/// (min, max) over the sample range `[start, end)`, clamped to the data. Exact when both ends
/// are multiples of 32 (or `end` is the data length); otherwise the partial blocks at the edges
/// contribute their whole block's extremes, so the result always contains the true range.
/// Returns `(0.0, 0.0)` for an empty range.
pub fn minmax(&self, start: usize, end: usize) -> (f32, f32) {
let end = end.min(self.len);
if start >= end {
return (0.0, 0.0);
/// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the
/// data the overview was built from. Returns `(0.0, 0.0)` for an empty range.
pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) {
self.sum(samples, start, end).range()
}
fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block {
let end = end.min(self.len).min(samples.len());
let (first, last) = (start.div_ceil(BLOCK), end / BLOCK);
let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default());
if first >= last {
return raw(start, end);
}
let mut lo = start / BLOCK;
let mut hi = end.div_ceil(BLOCK);
let mut acc = EMPTY;
let mut lvl = 0usize;
while lo < hi {
let Some(level) = self.levels.get(lvl) else { break };
let has_parent = lvl + 1 < self.levels.len();
if !has_parent || hi - lo < 2 * FANOUT {
for b in level.get(lo..hi.min(level.len())).unwrap_or_default() {
acc = merge(acc, *b);
}
raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last))
}
/// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit.
fn blocks(&self, mut lo: usize, mut hi: usize) -> Block {
let mut acc = Block::EMPTY;
for (lvl, level) in self.levels.iter().enumerate() {
let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY));
if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT {
(lo..hi).for_each(add);
break;
}
while !lo.is_multiple_of(FANOUT) && lo < hi {
acc = merge(acc, level.get(lo).copied().unwrap_or(EMPTY));
while !lo.is_multiple_of(FANOUT) {
add(lo);
lo += 1;
}
while !hi.is_multiple_of(FANOUT) && hi > lo {
while !hi.is_multiple_of(FANOUT) {
hi -= 1;
acc = merge(acc, level.get(hi).copied().unwrap_or(EMPTY));
add(hi);
}
lo /= FANOUT;
hi /= FANOUT;
lvl += 1;
}
finish(acc)
acc
}
/// Per-column (min, max) for drawing `cols` pixel columns spanning the sample range
/// `[start, end)`. Columns that fall outside the data are `(0.0, 0.0)`. Each column covers at
/// least one sample, so zoomed-in views repeat the containing sample's block.
pub fn columns(&self, start: f64, end: f64, cols: usize) -> Vec<(f32, f32)> {
let cols = cols.min(MAX_COLUMNS);
let mut out = vec![(0.0, 0.0); cols];
if cols == 0 || !start.is_finite() || !end.is_finite() || end <= start || self.len == 0 {
return out;
}
let span = (end - start) / cols as f64;
let len = self.len as f64;
for (c, slot) in out.iter_mut().enumerate() {
let s = start + span * c as f64;
let e = start + span * (c + 1) as f64;
if e <= 0.0 || s >= len {
continue;
}
let si = s.max(0.0).floor();
let ei = e.min(len).ceil().max(si + 1.0);
*slot = self.minmax(si as usize, ei as usize);
}
out
/// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that
/// reach the data, clamped to it.
fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator<Item = Option<(f64, f64)>> {
let n = self.len.min(samples.len());
let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0;
let last = n.saturating_sub(1) as f64;
(0..cols.min(MAX_COLUMNS)).map(move |c| {
let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap);
(valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last)))
})
}
/// (min, max) of the line through `samples` over each of `cols` spans of sample positions
/// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate
/// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from
/// them stays connected at any zoom. `None` for spans outside the data.
pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<(f32, f32)>> {
let at = |t: f64| {
let i = t.floor();
let a = samples.get(i as usize).copied().unwrap_or(0.0);
let b = samples.get(i as usize + 1).copied().unwrap_or(a);
Block::point(a + (b - a) * (t - i) as f32)
};
self.windows(samples, start, step, overlap, cols)
.map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range()))
.collect()
}
/// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just
/// outside a span's ends.
pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<f32>> {
self.windows(samples, start, step, overlap, cols)
.map(|w| {
w.map(|(a, b)| {
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
})
})
.collect()
}
}
@ -134,29 +167,19 @@ mod tests {
}
#[test]
fn aligned_ranges_are_exact() {
fn every_range_is_exact() {
let x = signal(100_003);
let p = Peaks::build(&x);
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003)] {
assert_eq!(p.minmax(a, b), brute(&x, a, b), "range {a}..{b}");
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] {
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
}
}
#[test]
fn unaligned_ranges_contain_truth_and_match_block_cover() {
let x = signal(50_000);
let p = Peaks::build(&x);
let mut seed = 12345u64;
for _ in 0..2000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let a = (seed >> 33) as usize % 50_000;
let a = (seed >> 33) as usize % 100_003;
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let b = a + 1 + (seed >> 33) as usize % (50_000 - a);
let got = p.minmax(a, b);
let truth = brute(&x, a, b);
assert!(got.0 <= truth.0 && got.1 >= truth.1, "{a}..{b}: {got:?} vs {truth:?}");
let cover = brute(&x, a / BLOCK * BLOCK, (b.div_ceil(BLOCK) * BLOCK).min(x.len()));
assert_eq!(got, cover, "{a}..{b}");
let b = a + 1 + (seed >> 33) as usize % (100_003 - a);
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
}
}
@ -164,34 +187,87 @@ mod tests {
fn degenerate_inputs() {
let p = Peaks::build(&[]);
assert_eq!(p.len_samples(), 0);
assert_eq!(p.minmax(0, 10), (0.0, 0.0));
assert_eq!(p.columns(0.0, 10.0, 4), vec![(0.0, 0.0); 4]);
let p = Peaks::build(&[0.5, -0.25]);
assert_eq!(p.minmax(0, 2), (-0.25, 0.5));
assert_eq!(p.minmax(5, 3), (0.0, 0.0));
assert_eq!(p.minmax(0, usize::MAX), (-0.25, 0.5));
assert!(p.columns(f64::NAN, 1.0, 3).iter().all(|&c| c == (0.0, 0.0)));
assert_eq!(p.columns(0.0, 1.0, 0).len(), 0);
let p = Peaks::build(&[f32::NAN, 1.0]);
assert_eq!(p.minmax(0, 2), (1.0, 1.0));
assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0));
assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]);
let x = [0.5, -0.25];
let p = Peaks::build(&x);
assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5));
assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0));
assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5));
assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5));
for (start, step, overlap) in [
(f64::NAN, 1.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, -1.0, 0.0),
(0.0, f64::INFINITY, 0.0),
(f64::NEG_INFINITY, 1.0, 0.0),
(0.0, 1.0, -0.5),
(0.0, 1.0, f64::NAN),
] {
assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]);
}
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0);
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS);
let x = [f32::NAN, 1.0];
let p = Peaks::build(&x);
assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0));
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]);
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
}
#[test]
fn columns_cover_range() {
fn rms_spans_match_the_samples() {
let x = signal(20_000);
let p = Peaks::build(&x);
let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::<f64>() / (e - s) as f64).sqrt() as f32;
for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] {
let cols = ((19_999.0 - overlap) / step) as usize;
for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() {
let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0));
let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000)));
assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}");
}
}
let sine: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect();
let p = Peaks::build(&sine);
let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap();
assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}");
assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]);
assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]);
}
#[test]
fn spans_cover_their_samples_and_touch_their_neighbours() {
let x = signal(10_000);
let p = Peaks::build(&x);
let cols = p.columns(0.0, 10_000.0, 100);
assert_eq!(cols.len(), 100);
for (c, &(lo, hi)) in cols.iter().enumerate() {
let truth = brute(&x, c * 100, c * 100 + 100);
assert!(lo <= truth.0 && hi >= truth.1);
for step in [0.3, 1.0, 7.5, 100.0, 333.3] {
let cols = (9_999.0 / step) as usize;
let spans = p.spans(&x, 0.0, step, 0.0, cols);
for (c, s) in spans.iter().enumerate() {
let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}"));
let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize);
if a <= b {
let truth = brute(&x, a, b + 1);
assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}");
}
if let Some(Some((nlo, nhi))) = spans.get(c + 1) {
assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1);
}
}
let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1)));
assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}");
}
let all = cols.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |a, &c| (a.0.min(c.0), a.1.max(c.1)));
assert_eq!(all, brute(&x, 0, 10_000));
// Past the end of the data.
let cols = p.columns(9_000.0, 11_000.0, 2);
assert_eq!(cols[1], (0.0, 0.0));
// Zoomed in further than one sample per column.
assert_eq!(p.columns(0.0, 4.0, 8).len(), 8);
}
#[test]
fn spans_follow_the_line_between_samples() {
let x = [0.0, 1.0, -1.0, 0.5];
let p = Peaks::build(&x);
assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]);
assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]);
let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None];
assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans);
assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
}
}

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

@ -1,8 +1,9 @@
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
use crate::pcm::{PcmKind, Quantizer, clamp_float, deinterleave, le_u16, le_u32, le_u64, tag, validate_buffer};
use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
const WAVE_FORMAT_PCM: u16 = 1;
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
@ -83,6 +84,57 @@ fn parse_bext(c: &[u8]) -> Option<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")
@ -255,6 +307,74 @@ fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
}
}
/// A WAV written block by block: a placeholder header first, then the real one (the same length,
/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`].
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
format: (u16, bool),
bwf: Option<BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(
mut out: W,
channels: usize,
sample_rate: u32,
depth: BitDepth,
dither: bool,
bwf: Option<&BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
) -> Result<Self> {
validate_layout(channels, sample_rate)?;
let format = sample_format(depth);
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
Ok(Writer { out, start, channels, sample_rate, format, bwf: bwf.cloned(), mask, force_rf64, quantizers, frames: 0, bytes: Vec::new() })
}
fn header(&self, frames: u64) -> Result<Vec<u8>> {
header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64)
}
/// The frame count after `frames` more (RF64 has room for any bounce).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
self.header(total)?;
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
let total = self.check_room(frames as u64)?;
write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false)?;
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
}
let head = self.header(self.frames)?;
self.out.seek(SeekFrom::Start(self.start))?;
self.out.write_all(&head)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
/// it is used automatically when the file would exceed 4 GiB.
/// `mask` overrides the speaker mask derived from the channel count.
@ -269,36 +389,15 @@ pub(crate) fn encode(
validate_buffer(buf)?;
let channels = buf.channels.len();
let frames = buf.frames();
let (bits, is_float): (u16, bool) = match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let format = sample_format(depth);
let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?;
let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
out.extend_from_slice(&head);
let mut qs = Quantizer::per_channel(u32::from(bits), dither, channels);
for f in 0..frames {
for (ch, q) in buf.channels.iter().zip(qs.iter_mut()) {
let s = ch.get(f).copied().unwrap_or(0.0);
if is_float {
out.extend_from_slice(&clamp_float(s).to_le_bytes());
} else {
let v = q.quantize(s).to_le_bytes();
out.extend_from_slice(v.get(..bytes).unwrap_or_default());
}
}
}
if data_len % 2 == 1 {
out.push(0);
}
Ok(out)
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, bwf, mask, force_rf64)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// Everything before the sample data of a WAV holding `frames` frames.

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

@ -0,0 +1,64 @@
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
use std::io::Cursor;
fn tone(channels: usize, frames: usize) -> AudioBuffer {
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
AudioBuffer { sample_rate: 44_100, channels }
}
fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option<u32>) -> Vec<u8> {
let mut out = Cursor::new(b"pre".to_vec());
out.set_position(3);
let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap();
let mut start = 0;
for len in [1, 7, 4095, 4097, 333].into_iter().cycle() {
if start >= buf.frames() {
break;
}
let len = len.min(buf.frames() - start);
let block: Vec<Vec<f32>> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect();
enc.write(&block, len).unwrap();
start += len;
}
enc.finish().unwrap().into_inner()
}
#[test]
fn streamed_files_match_whole_buffer_encodes() {
let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() };
let cases = [
(FileFormat::Wav, BitDepth::Int24, true, None, None),
(FileFormat::Wav, BitDepth::Float32, false, None, None),
(FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)),
(FileFormat::Aiff, BitDepth::Int24, true, None, None),
(FileFormat::Aiff, BitDepth::Float32, false, None, None),
(FileFormat::Flac, BitDepth::Int16, true, None, None),
(FileFormat::Flac, BitDepth::Int24, false, None, None),
];
for (format, bit_depth, dither, bwf, mask) in cases {
let opts = EncodeOptions { format, bit_depth, dither, bwf };
for channels in [1, 6] {
let buf = tone(channels, 20_001);
let whole = match mask {
Some(m) => encode_with_channel_mask(&buf, &opts, m),
None => encode(&buf, &opts),
}
.unwrap();
assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels");
}
}
}
#[test]
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
let buf = tone(2, 100);
for (format, limit) in [(FileFormat::Aiff, 1usize << 30), (FileFormat::Flac, 1 << 36)] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
enc.write(&buf.channels, 100).unwrap();
assert!(enc.check_room(limit as u64 / 2).is_ok(), "{format:?}");
assert!(matches!(enc.check_room(limit as u64), Err(AudioError::TooLarge(_))), "{format:?}");
assert!(matches!(enc.write(&[], limit), Err(AudioError::TooLarge(_))), "{format:?}");
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
}
}

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

@ -14,8 +14,10 @@ use crate::{ClapError, scan};
use clap_sys::audio_buffer::clap_audio_buffer;
use clap_sys::entry::clap_plugin_entry;
use clap_sys::events::{
CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, clap_event_header, clap_event_midi,
clap_event_note, clap_event_param_value, clap_input_events, clap_output_events,
CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, CLAP_EVENT_TRANSPORT,
CLAP_TRANSPORT_HAS_BEATS_TIMELINE, CLAP_TRANSPORT_HAS_SECONDS_TIMELINE, CLAP_TRANSPORT_HAS_TEMPO, CLAP_TRANSPORT_HAS_TIME_SIGNATURE,
CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_RECORDING, clap_event_header, clap_event_midi, clap_event_note, clap_event_param_value,
clap_event_transport, clap_input_events, clap_output_events,
};
use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports};
use clap_sys::ext::gui::{CLAP_EXT_GUI, clap_host_gui, clap_plugin_gui};
@ -27,12 +29,14 @@ use clap_sys::ext::params::{
};
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
use clap_sys::host::clap_host;
use clap_sys::id::clap_id;
use clap_sys::plugin::clap_plugin;
use clap_sys::process::{CLAP_PROCESS_ERROR, clap_process};
use clap_sys::stream::{clap_istream, clap_ostream};
use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible};
use soundcraft_dsp::Transport;
use std::collections::HashMap;
use std::ffi::{CStr, CString, c_char, c_void};
use std::path::{Path, PathBuf};
@ -275,7 +279,7 @@ impl Bundle {
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
inst.gui = Arc::new(GuiLink {
inner: Mutex::new(GuiInner { plugin, gui, alive: !gui.is_null(), created: false, visible: false }),
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false }),
host: Arc::clone(&inst.state),
});
Ok(inst)
@ -579,6 +583,40 @@ impl RawEvent {
}
}
fn transport_event(t: &Transport) -> clap_event_transport {
let fixed = |x: f64, factor: i64| if x.is_finite() { (x * factor as f64).round() as i64 } else { 0 };
let mut flags =
CLAP_TRANSPORT_HAS_TEMPO | CLAP_TRANSPORT_HAS_BEATS_TIMELINE | CLAP_TRANSPORT_HAS_SECONDS_TIMELINE | CLAP_TRANSPORT_HAS_TIME_SIGNATURE;
if t.playing {
flags |= CLAP_TRANSPORT_IS_PLAYING;
}
if t.recording {
flags |= CLAP_TRANSPORT_IS_RECORDING;
}
clap_event_transport {
header: clap_event_header {
size: u32::try_from(size_of::<clap_event_transport>()).unwrap_or(u32::MAX),
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_TRANSPORT,
flags: 0,
},
flags,
song_pos_beats: fixed(t.quarters, CLAP_BEATTIME_FACTOR),
song_pos_seconds: fixed(t.seconds, CLAP_SECTIME_FACTOR),
tempo: t.tempo,
tempo_inc: 0.0,
loop_start_beats: 0,
loop_end_beats: 0,
loop_start_seconds: 0,
loop_end_seconds: 0,
bar_start: fixed(t.bar_start, CLAP_BEATTIME_FACTOR),
bar_number: i32::try_from(t.bar).unwrap_or(0),
tsig_num: u16::try_from(t.numerator).unwrap_or(4),
tsig_denom: u16::try_from(t.denominator).unwrap_or(4),
}
}
unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 {
if list.is_null() {
return 0;
@ -696,17 +734,19 @@ fn window_api() -> Option<&'static CStr> {
struct GuiInner {
plugin: *const clap_plugin,
gui: *const clap_plugin_gui,
state: *const clap_plugin_state,
/// The plugin instance exists (cleared by `Instance::drop` before it destroys the plugin).
alive: bool,
created: bool,
visible: bool,
}
/// The link between an instance and its editor handles. The instance may process on the audio
/// thread while the editor is driven from the main thread: CLAP allows `clap.gui` (main-thread)
/// calls concurrently with audio-thread calls. Every GUI call happens under this lock, and the
/// instance clears `alive` (destroying any GUI) under the same lock before it destroys the
/// plugin, so a handle can never call into a destroyed plugin.
/// The link between an instance and its editor and state handles. The instance may process on
/// the audio thread while the editor is driven and the state saved from the main thread: CLAP
/// allows `clap.gui` and `clap.state` (main-thread) calls concurrently with audio-thread calls.
/// Every such call happens under this lock, and the instance clears `alive` (destroying any GUI)
/// under the same lock before it destroys the plugin, so a handle can never call into a destroyed
/// plugin.
pub(crate) struct GuiLink {
inner: Mutex<GuiInner>,
host: Arc<HostState>,
@ -715,7 +755,14 @@ pub(crate) struct GuiLink {
impl Default for GuiLink {
fn default() -> Self {
GuiLink {
inner: Mutex::new(GuiInner { plugin: std::ptr::null(), gui: std::ptr::null(), alive: false, created: false, visible: false }),
inner: Mutex::new(GuiInner {
plugin: std::ptr::null(),
gui: std::ptr::null(),
state: std::ptr::null(),
alive: false,
created: false,
visible: false,
}),
host: Arc::new(HostState::default()),
}
}
@ -731,7 +778,8 @@ impl GuiLink {
}
pub fn available(&self) -> bool {
self.lock().alive
let g = self.lock();
g.alive && !g.gui.is_null()
}
pub fn host(&self) -> &HostState {
@ -805,6 +853,22 @@ impl GuiLink {
g.visible = false;
}
/// `clap_plugin_state.save` into memory (`None` without the extension, on failure, or once the
/// plugin is gone).
pub fn save_state(&self) -> Option<Vec<u8>> {
let g = self.lock();
if !g.alive {
return None;
}
// SAFETY: `state` is null or the plugin's static extension vtable, valid while `alive`.
let save = unsafe { g.state.as_ref() }?.save?;
let mut out: Vec<u8> = Vec::new();
let stream = clap_ostream { ctx: (&mut out as *mut Vec<u8>).cast::<c_void>(), write: Some(ostream_write) };
// SAFETY: live plugin (`alive`, under the lock); the stream and its context live on this
// frame for the call.
unsafe { save(g.plugin, &stream) }.then_some(out)
}
/// Closes (destroys) the editor.
pub fn close(&self) {
if !on_main_thread() {
@ -1012,19 +1076,14 @@ impl Instance {
self.gui.available().then(|| Arc::clone(&self.gui))
}
/// `clap_plugin_state.save` into memory (`None` without the extension or on failure).
pub fn save_state(&self) -> Option<Vec<u8>> {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let save = unsafe { self.state_ext.as_ref() }?.save?;
let mut out: Vec<u8> = Vec::new();
let stream = clap_ostream { ctx: (&mut out as *mut Vec<u8>).cast::<c_void>(), write: Some(ostream_write) };
// SAFETY: valid instance; the stream and its context live on this frame for the call.
unsafe { save(self.plugin, &stream) }.then_some(out)
/// The same link, for state handles (`None` when the plugin has no `clap.state`).
pub fn state_link(&self) -> Option<Arc<GuiLink>> {
(!self.state_ext.is_null()).then(|| Arc::clone(&self.gui))
}
/// `clap_plugin_state.load` from memory. `false` without the extension or when refused.
pub fn load_state(&self, data: &[u8]) -> bool {
// SAFETY: see `save_state`.
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(load) = (unsafe { self.state_ext.as_ref() }).and_then(|s| s.load) else { return false };
let mut cursor = ReadCursor { data, pos: 0 };
let stream = clap_istream { ctx: (&mut cursor as *mut ReadCursor<'_>).cast::<c_void>(), read: Some(istream_read) };
@ -1186,6 +1245,7 @@ impl Instance {
outputs: &mut PortBuffers,
events: &[Event],
offset: u32,
transport: Option<&Transport>,
) -> Result<i32, ClapError> {
if !self.active {
return Err(ClapError::Process("not active".into()));
@ -1219,10 +1279,11 @@ impl Instance {
self.out_params.clear();
let out_events =
clap_output_events { ctx: (&mut self.out_params as *mut Vec<(u32, f64)>).cast::<c_void>(), try_push: Some(out_events_try_push) };
let transport = transport.map(transport_event);
let p = clap_process {
steady_time,
frames_count: u32::try_from(frames).unwrap_or(0),
transport: std::ptr::null(),
transport: transport.as_ref().map_or(std::ptr::null(), std::ptr::from_ref),
audio_inputs: inputs.bufs.as_ptr(),
audio_outputs: outputs.bufs.as_mut_ptr(),
audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0),
@ -1232,8 +1293,8 @@ impl Instance {
};
// SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more)
// valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event
// lists and their contexts live on this stack frame / in `self.raw`, untouched until the
// call returns.
// lists and their contexts and the transport live on this stack frame / in `self.raw`,
// untouched until the call returns.
let status = unsafe { process(self.plugin, &p) };
if !self.out_params.is_empty()
&& let Ok(mut edits) = self.state.edits.try_lock()
@ -1264,3 +1325,33 @@ impl Drop for Instance {
self.created = false;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_transport_reaches_clap_in_fixed_point() {
let t = Transport {
playing: true,
tempo: 90.0,
numerator: 3,
denominator: 4,
samples: 96_000,
seconds: 2.0,
quarters: 3.5,
bar_start: 3.0,
bar: 1,
..Transport::default()
};
let e = transport_event(&t);
assert_eq!((e.header.type_, e.header.size as usize), (CLAP_EVENT_TRANSPORT, size_of::<clap_event_transport>()));
assert_eq!(e.flags & CLAP_TRANSPORT_IS_PLAYING, CLAP_TRANSPORT_IS_PLAYING);
assert_eq!(e.flags & CLAP_TRANSPORT_IS_RECORDING, 0);
assert_eq!((e.song_pos_beats, e.bar_start), (7 * CLAP_BEATTIME_FACTOR / 2, 3 * CLAP_BEATTIME_FACTOR));
assert_eq!(e.song_pos_seconds, 2 * CLAP_SECTIME_FACTOR);
assert_eq!((e.tempo, e.bar_number, e.tsig_num, e.tsig_denom), (90.0, 1, 3, 4));
let nan = transport_event(&Transport { quarters: f64::NAN, ..t });
assert_eq!(nan.song_pos_beats, 0);
}
}

View File

@ -88,6 +88,9 @@ pub enum ClapError {
mod api {
use super::*;
pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry::cached()
}
pub fn scan() -> Vec<ClapDescriptor> {
registry::scan()
}
@ -115,6 +118,9 @@ mod api {
mod api {
use super::*;
pub fn cached() -> Option<Vec<ClapDescriptor>> {
Some(Vec::new())
}
pub fn scan() -> Vec<ClapDescriptor> {
Vec::new()
}
@ -161,6 +167,11 @@ pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
api::scan_paths(dirs)
}
/// The plugins of the last scan, without scanning: `None` until something has scanned.
pub fn cached() -> Option<Vec<ClapDescriptor>> {
api::cached()
}
/// The plugins in one `.clap` file or bundle.
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
api::load_bundle(path)

View File

@ -2,7 +2,7 @@
use crate::ffi::{EVENT_CAP, Event, GuiLink, Instance, NotePort, PortBuffers};
use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI};
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo};
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport};
use std::sync::Arc;
use std::sync::atomic::Ordering;
@ -33,11 +33,14 @@ pub struct ClapPlugin {
in_main: Option<usize>,
out_main: Option<usize>,
notes: NoteMode,
/// The note port takes raw MIDI (controllers, pitch bend, program changes).
midi_in: bool,
held: [bool; 128],
channels: usize,
max_block: usize,
latency: usize,
steady: i64,
transport: Option<Transport>,
}
impl ClapPlugin {
@ -55,11 +58,13 @@ impl ClapPlugin {
in_main: None,
out_main: None,
notes: NoteMode::None,
midi_in: false,
held: [false; 128],
channels: 2,
max_block: 0,
latency: 0,
steady: 0,
transport: None,
}
}
@ -114,7 +119,9 @@ impl Plugin for ClapPlugin {
self.out_main = pick_main(&outs);
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
self.notes = note_mode(self.inst.note_port());
let port = self.inst.note_port();
self.notes = note_mode(port);
self.midi_in = port.is_some_and(|p| p.supported & CLAP_NOTE_DIALECT_MIDI != 0);
self.max_block = mb;
self.inst.state().restart.store(false, Ordering::Relaxed);
match self.inst.activate(sr, 1, u32::try_from(mb).unwrap_or(u32::MAX)) {
@ -187,7 +194,7 @@ impl Plugin for ClapPlugin {
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
let offset = u32::try_from(start).unwrap_or(u32::MAX);
let events = self.queue.get(..k).unwrap_or(&[]);
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset);
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset, self.transport.as_ref());
self.queue.drain(..k);
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
match (r, self.out_main) {
@ -217,6 +224,10 @@ impl Plugin for ClapPlugin {
self.latency
}
fn set_transport(&mut self, transport: &Transport) {
self.transport = Some(*transport);
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
if note > 127 {
return;
@ -248,6 +259,12 @@ impl Plugin for ClapPlugin {
}
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
if self.midi_in {
self.push(Event::Midi { time: u32::try_from(offset).unwrap_or(u32::MAX), data: msg });
}
}
fn all_notes_off(&mut self) {
for k in 0..128u8 {
if self.held.get(k as usize).copied().unwrap_or(false) {
@ -256,10 +273,6 @@ impl Plugin for ClapPlugin {
}
}
fn save_state(&mut self) -> Option<Vec<u8>> {
self.inst.save_state()
}
fn load_state(&mut self, data: &[u8]) -> bool {
let ok = self.inst.load_state(data);
if ok {
@ -283,6 +296,11 @@ impl Plugin for ClapPlugin {
let link = self.inst.gui()?;
Some(Box::new(ClapEditor { link, info: self.info, ids: self.ids.clone() }))
}
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
let link = self.inst.state_link()?;
Some(Box::new(move || link.save_state()))
}
}
/// A CLAP plugin's floating editor, driven from the main thread (see [`GuiLink`]).

View File

@ -83,6 +83,10 @@ pub fn scan() -> Vec<ClapDescriptor> {
ensure_scanned(&mut r).clone()
}
pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry().lock().unwrap_or_else(PoisonError::into_inner).scanned.clone()
}
pub fn rescan() -> Vec<ClapDescriptor> {
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
r.scanned = None;

View File

@ -3,7 +3,8 @@
//! - `org.soundcraft.test.gain`: stereo in/out, param 7 "Gain" (0..2, default 1), param 3 "Mode"
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
//! to the velocity of the held note (0 when none), switching at the event's sample offset.
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
//! MIDI CC 7 (volume) sets the constant to its value / 127.
//!
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
@ -150,6 +151,12 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
f.level = e.velocity;
}
CLAP_EVENT_NOTE_OFF => f.level = 0.0,
CLAP_EVENT_MIDI => {
let e = unsafe { &*(h as *const clap_event_header as *const clap_event_midi) };
if e.data[0] & 0xF0 == 0xB0 && e.data[1] == 7 {
f.level = f64::from(e.data[2]) / 127.0;
}
}
_ => {}
}
}

View File

@ -105,6 +105,9 @@ fn synth_plays_notes_at_their_offsets() {
p.all_notes_off();
p.process(&mut io, 64);
assert!(io[0].iter().all(|x| x.abs() < 1e-6));
p.midi(20, [0xB0, 7, 64]);
p.process(&mut io, 64);
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
}
#[test]
@ -146,11 +149,36 @@ fn state_saves_and_restores_into_a_new_instance() {
// Plugins without clap.state.
let mut s = soundcraft_clap_host::create(SYNTH).unwrap();
assert!(s.save_state().is_none());
assert!(s.state_reader().is_none());
assert!(!s.load_state(&blob));
assert!(s.editor().is_none(), "the synth has no GUI");
assert!(s.open_editor().is_err());
}
#[test]
fn state_reader_reads_while_another_thread_processes() {
fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
p.prepare(48_000.0, 64, 2);
assert!(p.set_param("7", 1.5));
p.process(&mut vec![vec![0.5f32; 64]; 2], 64);
let mut reader = p.state_reader().expect("the gain fixture has clap.state");
let blob = p.save_state().unwrap();
let audio = std::thread::spawn(move || {
for _ in 0..2_000 {
p.process(&mut vec![vec![0.5f32; 64]; 2], 64);
}
p
});
while !audio.is_finished() {
assert_eq!(reader.save_state().as_ref(), Some(&blob));
}
let p = audio.join().unwrap();
assert_eq!(reader.save_state(), Some(blob));
drop(p);
assert_eq!(reader.save_state(), None);
}
#[test]
fn editors_refuse_to_open_off_the_main_thread() {
fixture_dir();

View File

@ -169,6 +169,24 @@ impl PluginInfo {
}
}
/// The host's transport at the start of a block, for tempo-synced plugins.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Transport {
pub playing: bool,
pub recording: bool,
/// Quarter notes per minute.
pub tempo: f64,
pub numerator: u32,
pub denominator: u32,
/// Timeline position.
pub samples: i64,
pub seconds: f64,
pub quarters: f64,
/// Start of the current bar, in quarter notes, and its number (0 for the first bar).
pub bar_start: f64,
pub bar: i64,
}
/// A realtime audio processor or instrument.
///
/// Contract: `process` never allocates or panics. It processes `min(frames, io[c].len())` frames
@ -195,9 +213,14 @@ pub trait Plugin: Send {
fn gain_reduction_db(&self) -> f32 {
0.0
}
/// The transport for the next block, given before `process`.
fn set_transport(&mut self, _transport: &Transport) {}
/// Instruments: note events at sample offsets within the next block.
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
fn note_off(&mut self, _offset: usize, _note: u8) {}
/// Instruments: any other MIDI 1.0 channel message (control change, pitch bend, pressure,
/// program change) at a sample offset within the next block.
fn midi(&mut self, _offset: usize, _msg: [u8; 3]) {}
fn all_notes_off(&mut self) {}
/// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it.
/// `false` when the plugin does not play samples. Does not allocate; sounding voices stop.
@ -206,11 +229,12 @@ pub trait Plugin: Send {
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
false
}
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
/// the plugin refused. May allocate: never call it in steady-state audio processing.
/// The plugin's complete internal state as an opaque blob (third-party plugins), as its
/// [`Plugin::state_reader`] reads it. `None` when the plugin has no state beyond its
/// parameters (built-ins) or the plugin refused. May allocate: never call it in steady-state
/// audio processing.
fn save_state(&mut self) -> Option<Vec<u8>> {
None
self.state_reader()?.save_state()
}
/// Restores a blob from [`Plugin::save_state`]. `false` if unsupported or the plugin rejected
/// it (the plugin keeps working with its previous state). The data is hostile input.
@ -229,6 +253,10 @@ pub trait Plugin: Send {
fn editor(&mut self) -> Option<Box<dyn PluginEditor>> {
None
}
/// Reads this instance's state from the UI thread (`None` when it has no state of its own).
fn state_reader(&mut self) -> Option<Box<dyn PluginStateReader>> {
None
}
}
/// A plugin's editor (GUI), driven from the main (UI) thread. Obtained from
@ -249,6 +277,19 @@ pub trait PluginEditor: Send {
fn set_param(&mut self, _id: &str, _value: f32) {}
}
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
/// thread, as plugin formats allow. Obtained from [`Plugin::state_reader`]; closures are readers.
pub trait PluginStateReader: Send {
/// What [`Plugin::save_state`] would return; `None` once the instance is gone.
fn save_state(&mut self) -> Option<Vec<u8>>;
}
impl<F: FnMut() -> Option<Vec<u8>> + Send> PluginStateReader for F {
fn save_state(&mut self) -> Option<Vec<u8>> {
self()
}
}
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
/// thread: no locks, no I/O.
@ -301,6 +342,11 @@ pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
plugins::REGISTRY.iter().copied().find(|i| i.id == id)
}
/// Whether a plugin makes sound instead of processing its input (it ignores the input).
pub fn is_generator(id: &str) -> bool {
id == "signal_generator"
}
/// Silence floor used by the dB helpers.
pub const MIN_DB: f32 = -144.0;

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

@ -52,7 +52,8 @@ fn max_len(ch: &[Vec<f32>]) -> usize {
ch.iter().map(Vec::len).max().unwrap_or(0)
}
fn peak(ch: &[Vec<f32>]) -> f32 {
/// The largest finite sample magnitude in any channel.
pub fn peak(ch: &[Vec<f32>]) -> f32 {
ch.iter().flatten().filter(|v| v.is_finite()).fold(0.0f32, |m, v| m.max(v.abs()))
}
@ -341,7 +342,14 @@ pub fn pitch_shift(ch: &[Vec<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 +451,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

@ -387,7 +387,7 @@ impl DrumSynth {
fn apply(&mut self, kind: EventKind) {
match kind {
EventKind::On(n, v) => self.trigger(n, v),
EventKind::Off(_) => {}
EventKind::Off(_) | EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
EventKind::AllOff => {
let fast = decay60(30.0, self.sr);
for v in &mut self.voices {

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

@ -299,6 +299,7 @@ impl Sampler {
v.env.gate_off();
}
}
EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
}
}

View File

@ -13,8 +13,15 @@ pub(crate) enum EventKind {
On(u8, u8),
Off(u8),
AllOff,
Sustain(bool),
/// Pitch bend as a frequency ratio.
Bend(f32),
ResetControllers,
}
/// Pitch bend range, semitones each way.
const BEND_RANGE: f32 = 2.0;
#[derive(Debug, Clone, Copy)]
pub(crate) struct Event {
pub(crate) offset: usize,
@ -212,6 +219,8 @@ struct Voice {
svf: Svf,
age: u64,
held: bool,
/// Released while the sustain pedal was down.
sustained: bool,
}
impl Voice {
@ -228,6 +237,7 @@ impl Voice {
svf: Svf::default(),
age: 0,
held: false,
sustained: false,
}
}
@ -250,6 +260,8 @@ pub struct SubtractiveSynth {
counter: u64,
level: Smoother,
cutoff: Smoother,
sustain: bool,
bend: f32,
}
impl Default for SubtractiveSynth {
@ -277,6 +289,8 @@ impl SubtractiveSynth {
counter: 0,
level: Smoother::new(0.5),
cutoff: Smoother::new(2000.0),
sustain: false,
bend: 1.0,
};
s.update();
s
@ -337,15 +351,32 @@ impl SubtractiveSynth {
v.filt.gate_on();
v.age = counter;
v.held = true;
v.sustained = false;
}
self.last_freq = Some(freq);
}
fn stop_note(&mut self, note: u8) {
let sustain = self.sustain;
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
v.held = false;
v.amp.gate_off();
v.filt.gate_off();
if sustain {
v.sustained = true;
} else {
v.amp.gate_off();
v.filt.gate_off();
}
}
}
fn set_sustain(&mut self, on: bool) {
self.sustain = on;
if !on {
for v in self.voices.iter_mut().filter(|v| v.sustained) {
v.sustained = false;
v.amp.gate_off();
v.filt.gate_off();
}
}
}
@ -356,10 +387,17 @@ impl SubtractiveSynth {
EventKind::AllOff => {
for v in &mut self.voices {
v.held = false;
v.sustained = false;
v.amp.gate_off();
v.filt.gate_off();
}
}
EventKind::Sustain(on) => self.set_sustain(on),
EventKind::Bend(ratio) => self.bend = ratio,
EventKind::ResetControllers => {
self.set_sustain(false);
self.bend = 1.0;
}
}
}
@ -374,6 +412,7 @@ impl SubtractiveSynth {
let env_oct = self.p.v(7) / 100.0 * 6.0;
let cutoff = self.cutoff.next();
let nyq = sr * 0.45;
let bend = self.bend;
let mut out = 0.0;
for v in &mut self.voices {
if !v.active() {
@ -386,8 +425,8 @@ impl SubtractiveSynth {
}
let a = v.amp.next(&self.amp_rates);
let fe = v.filt.next(&self.filt_rates);
let s1 = v.osc1.next(w1, v.freq / sr);
let s2 = v.osc2.next(w2, v.freq * ratio2 / sr);
let s1 = v.osc1.next(w1, v.freq * bend / sr);
let s2 = v.osc2.next(w2, v.freq * bend * ratio2 / sr);
let raw = s1 * (1.0 - mix) + s2 * mix;
let fc = (cutoff * 2f32.powf(env_oct * fe)).clamp(20.0, nyq);
let g = (std::f32::consts::PI * fc / sr).tan();
@ -418,6 +457,8 @@ impl Plugin for SubtractiveSynth {
self.last_freq = None;
self.level.snap();
self.cutoff.snap();
self.sustain = false;
self.bend = 1.0;
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
@ -467,6 +508,21 @@ impl Plugin for SubtractiveSynth {
self.events.push(offset, EventKind::Off(note));
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
let [status, d1, d2] = msg;
let kind = match (status & 0xF0, d1) {
(0xB0, 64) => EventKind::Sustain(d2 >= 64),
(0xB0, 120 | 123) => EventKind::AllOff,
(0xB0, 121) => EventKind::ResetControllers,
(0xE0, _) => {
let v = (i32::from(d2 & 0x7F) << 7 | i32::from(d1 & 0x7F)) - 8192;
EventKind::Bend(2f32.powf(v as f32 / 8192.0 * BEND_RANGE / 12.0))
}
_ => return,
};
self.events.push(offset, kind);
}
fn all_notes_off(&mut self) {
self.events.push(0, EventKind::AllOff);
}

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

@ -1,7 +1,7 @@
use std::collections::HashSet;
use std::f32::consts::TAU;
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, plugin_info, plugins};
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, is_generator, plugin_info, plugins};
const SR: f32 = 48_000.0;
const BLOCK: usize = 512;
@ -45,7 +45,7 @@ fn prepared(id: &str) -> Box<dyn Plugin> {
}
fn is_source(id: &str) -> bool {
plugin_info(id).unwrap().is_instrument || id == "signal_generator"
plugin_info(id).unwrap().is_instrument || is_generator(id)
}
#[test]
@ -199,6 +199,44 @@ fn eq_magnitude_at_center_matches_gain() {
assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}");
}
#[test]
fn eq_switches_and_choices_apply_to_a_running_eq() {
// (plugin, settings, the one switch or choice changed while running, test tone)
let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0),
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0),
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0),
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0),
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0),
("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0),
("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0),
];
let mut stale = Vec::new();
for (id, setup, (key, value), tone) in cases {
let x = [sine(tone, 0.5, 24_000)];
let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..]));
let build = |settings: &[(&str, f32)]| {
let mut p = create(id).unwrap();
p.prepare(SR, BLOCK, 1);
for &(k, v) in settings {
assert!(p.set_param(k, v), "{id} {k}");
}
p.reset();
p
};
let mut live = build(setup);
let before = level(live.as_mut());
assert!(live.set_param(key, value));
let after = level(live.as_mut());
let want = level(build(&[setup, &[(key, value)]].concat()).as_mut());
assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart");
if (after - want).abs() >= 0.1 {
stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB"));
}
}
assert!(stale.is_empty(), "{stale:#?}");
}
#[test]
fn compressor_steady_state_gain_reduction() {
let mut p = prepared("compressor");
@ -329,6 +367,23 @@ fn delay_repeats_at_time() {
assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}");
}
#[test]
fn modulation_tail_covers_feedback_echoes() {
for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] {
let mut p = prepared(id);
for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] {
assert!(p.set_param(param, v));
}
p.reset();
let mut x = vec![0.0f32; 48_000 * 5];
x[0] = 0.5;
let out = run(p.as_mut(), &[x]);
let floor = peak(&out[0]) * 1e-3;
let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap();
assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples());
}
}
#[test]
fn trim_and_invert() {
let mut p = prepared("invert");
@ -394,6 +449,34 @@ fn synth_note_on_sounds_and_note_off_decays() {
assert!(peak(&out[0][72_000..]) < 1e-6);
}
#[test]
fn synth_follows_the_sustain_pedal_and_pitch_bend() {
let mut p = prepared("subtractive_synth");
p.midi(0, [0xB0, 64, 127]);
p.note_on(0, 60, 100);
run(p.as_mut(), &vec![vec![0.0; 4800]; 2]);
p.note_off(0, 60);
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
assert!(rms(&out[0][72_000..]) > 0.01, "the pedal holds the note");
p.midi(0, [0xB0, 64, 0]);
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
assert!(peak(&out[0][72_000..]) < 1e-6, "lifting the pedal releases it");
let rises = |x: &[f32]| x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() as f64;
let mut p = prepared("subtractive_synth");
p.set_param("osc1_wave", 2.0);
p.set_param("osc_mix", 0.0);
p.note_on(0, 69, 100);
let plain = run(p.as_mut(), &[vec![0.0; 48_000]]);
p.midi(0, [0xE0, 0x7F, 0x7F]);
let bent = run(p.as_mut(), &[vec![0.0; 48_000]]);
let ratio = rises(&bent[0][4800..]) / rises(&plain[0][4800..]);
assert!((ratio - 2f64.powf(2.0 / 12.0)).abs() < 0.01, "{ratio}");
p.midi(0, [0xB0, 121, 0]);
let reset = run(p.as_mut(), &[vec![0.0; 48_000]]);
assert!((rises(&reset[0][4800..]) / rises(&plain[0][4800..]) - 1.0).abs() < 0.01);
}
#[test]
fn synth_event_offsets_are_sample_accurate() {
let mut p = prepared("subtractive_synth");

View File

@ -0,0 +1,615 @@
//! Crash-recovery copies of open sessions, one folder each, written in the background, and the
//! recovery of the ones left behind with unsaved work.
use crate::io::{Recording, open_session, sanitize_name};
use crate::{Engine, EngineError, Result};
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
use soundcraft_model::{Session, SourceAudio, SourceId};
use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
use std::sync::{Arc, Mutex, PoisonError, Weak};
use std::time::{Duration, Instant, SystemTime};
const SESSION: &str = "session.scraft";
const ORIGIN: &str = "origin";
const LOCK: &str = "lock";
/// The plan of a recording still running when the folder was written.
const RECORDING: &str = "recording.json";
/// While changes keep coming, the newest is written at most this often.
const INTERVAL: Duration = Duration::from_secs(1);
const STOPPED: &str = "the autosave thread stopped";
/// Writes autosaves on a thread of its own, so the UI never waits on the disk.
pub struct Autosaver {
jobs: Option<Sender<Job>>,
thread: Option<std::thread::JoinHandle<()>>,
failure: Arc<Mutex<Option<String>>>,
}
enum Job {
Write(Arc<Session>, Option<String>, Option<Recording>),
/// Done with the session's folder: keep it for the next launch, or delete it.
Close(bool),
Flush(Sender<std::result::Result<(), String>>),
}
impl Autosaver {
/// A writer for autosave folders under `root`.
pub fn new(root: PathBuf) -> Autosaver {
let (tx, rx) = channel();
let failure = Arc::new(Mutex::new(None));
let writer = Writer { root, failure: Arc::clone(&failure), folder: None, pending: None, last: None, written: Ok(()) };
let thread = std::thread::Builder::new().name("soundcraft-autosave".into()).spawn(move || writer.run(&rx));
if let Err(e) = &thread {
report(&failure, format!("{STOPPED}: {e}"));
}
Autosaver { jobs: Some(tx), thread: thread.ok(), failure }
}
/// Autosave `session`, opened from the session file `origin`, soon, with the plan of the
/// recording running in it.
pub fn write(&self, session: Arc<Session>, origin: Option<String>, recording: Option<Recording>) {
self.send(Job::Write(session, origin, recording));
}
/// Done with the open session's folder: keep it for the next launch, or delete it.
pub fn close(&self, keep: bool) {
self.send(Job::Close(keep));
}
/// Wait until everything handed over so far is done. A failure returned here isn't reported
/// again by `failure`.
pub fn flush(&self) -> Result<()> {
let (tx, rx) = channel();
self.send(Job::Flush(tx));
let done = rx.recv().unwrap_or_else(|_| Err(STOPPED.into()));
if done.is_err() {
self.failure();
}
done.map_err(EngineError::Io)
}
/// The newest failure since the last call.
pub fn failure(&self) -> Option<String> {
self.failure.lock().unwrap_or_else(PoisonError::into_inner).take()
}
fn send(&self, job: Job) {
if self.jobs.as_ref().is_none_or(|tx| tx.send(job).is_err()) {
report(&self.failure, STOPPED.into());
}
}
}
impl Drop for Autosaver {
/// What was handed over is written, and an open folder stays for the next launch.
fn drop(&mut self) {
drop(self.jobs.take());
if let Some(t) = self.thread.take() {
let _ = t.join();
}
}
}
/// The writer thread.
struct Writer {
root: PathBuf,
failure: Arc<Mutex<Option<String>>>,
/// The open session's folder.
folder: Option<Autosave>,
/// The newest session not written yet.
pending: Option<(Arc<Session>, Option<String>, Option<Recording>)>,
last: Option<Instant>,
/// How the newest write into the open folder went, for `Flush`.
written: std::result::Result<(), String>,
}
impl Writer {
fn run(mut self, jobs: &Receiver<Job>) {
loop {
let wait = match (&self.pending, self.last) {
(None, _) => Duration::MAX,
(Some(_), last) => last.map_or(Duration::ZERO, |t| INTERVAL.saturating_sub(t.elapsed())),
};
match jobs.recv_timeout(wait) {
Ok(Job::Write(session, origin, recording)) => self.pending = Some((session, origin, recording)),
Ok(Job::Flush(reply)) => {
self.write_pending();
let _ = reply.send(self.written.clone());
}
Ok(Job::Close(keep)) => {
if keep {
self.write_pending();
}
self.pending = None;
self.written = Ok(());
if let Some(a) = self.folder.take()
&& !keep
{
a.discard();
}
}
Err(RecvTimeoutError::Timeout) => self.write_pending(),
Err(RecvTimeoutError::Disconnected) => {
self.write_pending();
return;
}
}
}
}
fn write_pending(&mut self) {
let Some((session, origin, recording)) = self.pending.take() else { return };
self.last = Some(Instant::now());
let written = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| -> Result<()> {
if self.folder.is_none() {
self.folder = Some(Autosave::create(&self.root, &session.name)?);
}
self.folder.as_mut().map_or(Ok(()), |a| a.write(&session, origin.as_deref(), recording.as_ref()))
}));
self.written = match written {
Ok(written) => written.map_err(|e| e.to_string()),
Err(_) => Err("internal error (see the log)".into()),
};
if let Err(e) = &self.written {
report(&self.failure, e.clone());
}
}
}
fn report(failure: &Mutex<Option<String>>, e: String) {
log::warn!("autosave failed: {e}");
*failure.lock().unwrap_or_else(PoisonError::into_inner) = Some(e);
}
/// An open session's autosave folder, locked while the session is open so a later launch can tell
/// it from one a crash left behind.
struct Autosave {
dir: PathBuf,
lock: Option<std::fs::File>,
copies: BTreeMap<SourceId, (Weak<SourceAudio>, String)>,
next: u64,
/// A recording's plan is in the folder.
recording: bool,
}
impl Autosave {
/// A new folder under `root` for the session called `name`.
fn create(root: &Path, name: &str) -> Result<Autosave> {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id()));
let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?;
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
}
/// Write `session`, opened from the session file `origin`, and the plan of the `recording`
/// running in it. Audio saved with it is referenced where it is; audio that exists only in
/// memory is copied, once per recording or render.
fn write(&mut self, session: &Session, origin: Option<&str>, recording: Option<&Recording>) -> Result<()> {
let base = origin.and_then(|p| Path::new(p).parent()).and_then(|p| std::path::absolute(p).ok());
let audio_dir = self.dir.join("Audio Files");
let mut s = session.clone();
let mut copies = BTreeMap::new();
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
if let Some(f) = &file
&& (!src.unsaved && f.is_file() || audio.is_none())
{
src.path = f.to_string_lossy().into_owned();
continue;
}
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::create_dir_all(&audio_dir)
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
.map_err(|err| io_error(&audio_dir, err))?;
name
}
};
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
src.unsaved = true;
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
let _ = std::fs::remove_file(audio_dir.join(name));
}
if let Some(r) = recording {
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
write_atomically(&self.dir.join(RECORDING), plan.as_bytes())?;
} else if self.recording {
let _ = std::fs::remove_file(self.dir.join(RECORDING));
}
self.recording = recording.is_some();
Ok(())
}
/// Delete the folder: the session closed with nothing left to recover.
fn discard(mut self) {
drop(self.lock.take());
let _ = std::fs::remove_dir_all(&self.dir);
}
}
/// Unsaved work left behind in an autosave folder.
#[derive(Debug, Clone, PartialEq)]
pub struct Leftover {
pub dir: PathBuf,
/// The session's name.
pub name: String,
/// The session file it was autosaved from, if it had one.
pub origin: Option<String>,
pub saved: SystemTime,
/// A recording was still running in it.
pub recording: bool,
}
/// Leftover autosaves under `root`, newest first: folders no open session holds, written after the
/// session file they came from or holding a recording. Empty and outdated ones are deleted.
pub fn leftovers(root: &Path) -> Vec<Leftover> {
let mut out = Vec::new();
for dir in std::fs::read_dir(root).into_iter().flatten().flatten().map(|d| d.path()) {
let Ok(lock) = std::fs::OpenOptions::new().write(true).open(dir.join(LOCK)) else { continue };
if lock.try_lock().is_err() {
continue;
}
let origin = std::fs::read_to_string(dir.join(ORIGIN)).ok().filter(|o| !o.is_empty());
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
let recording = dir.join(RECORDING).is_file();
drop(lock);
match saved {
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
out.push(Leftover { dir, name, origin, saved, recording });
}
_ => {
let _ = std::fs::remove_dir_all(&dir);
}
}
}
out.sort_by_key(|l| std::cmp::Reverse(l.saved));
out
}
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
/// with the takes of a recording that never finished placed as Stop would have. Returns the
/// media that couldn't be loaded.
pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
let mut r = Engine::default();
let mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?;
let mut s = r.session().clone();
let unfinished = unfinished_takes(left, &s, &mut missing);
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
for src in s.sources.iter_mut().filter(|src| !src.unsaved) {
if let Ok(rel) = Path::new(&src.path).strip_prefix(home) {
src.path = rel.components().map(|c| c.as_os_str().to_string_lossy()).collect::<Vec<_>>().join("/");
}
}
}
e.replace_session(Session::default());
*e.session_mut() = s;
e.path = left.origin.clone();
if let Some((plan, takes)) = unfinished {
crate::io::place_recording(e, &plan, takes);
}
Ok(missing)
}
/// The takes of the recording `left` holds the plan of, repaired, minus those already in `s`.
/// A take the plan names that isn't a take file SoundCraft recorded, in a folder it records into,
/// is left untouched and added to `skipped`.
fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec<String>) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> {
let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?;
let known: Vec<PathBuf> = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
let folders = take_folders(left);
let mut takes = Vec::new();
for t in &plan.takes {
if std::fs::canonicalize(&t.path).is_ok_and(|p| known.contains(&p)) {
continue;
}
match repair_take(&t.path, &folders, t.channels, plan.rate) {
Ok(frames) if frames > 0 => takes.push((t.track, t.path.clone())),
Ok(_) => {}
Err(err) => {
log::warn!("recovery skipped the take {}: {err}", t.path.display());
skipped.push(format!("{}: {err}", t.path.display()));
}
}
}
Some((plan, takes))
}
/// The folders a recording in `left`'s session writes its takes into: the `Audio Files` next to
/// the session file, or, never saved, the one in its folder under the autosave root.
fn take_folders(left: &Leftover) -> Vec<PathBuf> {
let saved = left.origin.as_deref().and_then(|o| Path::new(o).parent()).map(|d| d.join("Audio Files"));
let unsaved = left.dir.parent().map(|root| root.join(sanitize_name(&left.name)).join("Audio Files"));
saved.into_iter().chain(unsaved).filter_map(|d| std::fs::canonicalize(d).ok()).collect()
}
/// Make a take file's header cover all the audio in it; returns its frames. The recorder
/// rewrites the header only every second, so a crash leaves up to a second uncovered. Only a
/// regular file in one of `folders` that starts with the header the recorder writes is touched.
fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) -> std::io::Result<u64> {
let refuse = |why: &str| std::io::Error::new(std::io::ErrorKind::InvalidData, why.to_string());
if !std::fs::symlink_metadata(path)?.file_type().is_file() {
return Err(refuse("not a regular file"));
}
let folder = path.parent().and_then(|d| std::fs::canonicalize(d).ok());
if !folder.is_some_and(|d| folders.contains(&d)) {
return Err(refuse("not in a folder SoundCraft records into"));
}
let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other);
let expected = header(0)?;
let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?;
let mut head = vec![0u8; expected.len()];
if std::io::Read::read_exact(&mut f, &mut head).is_err() || !is_take_header(&head, &expected) {
return Err(refuse("not a take SoundCraft recorded"));
}
let audio = f.metadata()?.len().saturating_sub(expected.len() as u64);
let frames = audio / (channels.max(1) as u64 * 4);
std::io::Seek::rewind(&mut f)?;
f.write_all(&header(frames)?)?;
f.sync_all()?;
Ok(frames)
}
/// `head` is a take header like `expected` (the recorder's header for no frames yet): RIFF or
/// RF64 WAVE, the same format chunk at the same place, and the data chunk right after it all.
/// Only the sizes and frame counts may differ.
fn is_take_header(head: &[u8], expected: &[u8]) -> bool {
let magic = matches!(head.get(..4), Some(b"RIFF" | b"RF64")) && head.get(8..12) == Some(b"WAVE");
let fmt = expected.windows(4).position(|w| w == b"fmt ");
let fmt_len = fmt.and_then(|at| expected.get(at + 4..at + 8)).and_then(|b| b.try_into().ok()).map(u32::from_le_bytes);
let same_fmt = match (fmt, fmt_len) {
(Some(at), Some(n)) => {
let end = at.saturating_add(8).saturating_add(n as usize);
expected.get(at..end).is_some_and(|chunk| head.get(at..end) == Some(chunk))
}
_ => false,
};
let data = expected.len().checked_sub(8).and_then(|at| head.get(at..at + 4)) == Some(b"data");
magic && same_fmt && data
}
/// Delete a leftover autosave.
pub fn discard(left: &Leftover) {
let _ = std::fs::remove_dir_all(&left.dir);
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()
}
fn write_atomically(path: &Path, bytes: &[u8]) -> Result<()> {
let tmp = path.with_extension("tmp");
write_synced(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, path)).map_err(|err| {
let _ = std::fs::remove_file(&tmp);
io_error(path, err)
})
}
fn io_error(path: &Path, err: std::io::Error) -> EngineError {
EngineError::Io(format!("{}: {err}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn record(e: &mut Engine, level: f32) {
let before = e.session_arc();
let s = e.session_mut();
let t = match s.tracks.first() {
Some(t) => t.id,
None => s.add_track(TrackKind::Audio, ChannelFormat::Mono, None),
};
let src =
crate::io::add_source(s, "Audio 1_01", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_01", src, 0, 0, 4_800));
e.push_undo("Record", before);
}
fn levels(e: &Engine) -> Vec<f32> {
let s = e.session();
s.sources.iter().map(|src| s.pool.get(src.id).map_or(f32::NAN, |a| a.buffer.channels[0][0])).collect()
}
#[test]
fn saved_audio_is_referenced_and_takes_are_copied_once() {
let dir = folder("autosave-copies");
let path = dir.join("Song/Song.scraft");
let mut e = Engine::default();
record(&mut e, 0.5);
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
record(&mut e, -0.25);
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1);
drop(a);
let left = leftovers(&dir.join("Autosave"));
let mut r = Engine::default();
assert_eq!(recover(&mut r, &left[0]).unwrap(), Vec::<String>::new());
let paths: Vec<_> = r.session().sources.iter().map(|s| (s.path.starts_with("Audio Files/"), s.unsaved)).collect();
assert_eq!(paths, [(true, false), (false, true)]);
assert_eq!(levels(&r), [0.5, -0.25]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_recorded_after_undo_gets_its_own_copy() {
let dir = folder("autosave-undo");
let mut e = Engine::default();
record(&mut e, 0.5);
let mut a = Autosave::create(&dir, "Untitled").unwrap();
a.write(e.session(), None, None).unwrap();
e.execute("edit.undo", &json!({})).unwrap();
record(&mut e, -0.25);
a.write(e.session(), None, None).unwrap();
drop(a);
let mut r = Engine::default();
recover(&mut r, &leftovers(&dir)[0]).unwrap();
assert_eq!(levels(&r), [-0.25]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_writer_saves_the_newest_change_and_drops_the_folder_when_done() {
let root = folder("autosave-writer");
let w = Autosaver::new(root.clone());
let mut e = Engine::default();
for _ in 0..5 {
record(&mut e, 0.5);
w.write(e.session_arc(), None, None);
}
w.flush().unwrap();
let file = std::fs::read_dir(&root).unwrap().flatten().map(|d| d.path().join(SESSION)).collect::<Vec<_>>();
let mut r = Engine::default();
open_session(&mut r, &file[0].to_string_lossy()).unwrap();
assert_eq!((file.len(), r.session().sources.len()), (1, 5));
w.close(false);
w.flush().unwrap();
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
record(&mut e, 0.5);
w.write(e.session_arc(), None, None);
drop(w);
let mut r = Engine::default();
recover(&mut r, &leftovers(&root)[0]).unwrap();
assert_eq!(r.session().sources.len(), 6);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
use crate::io::Take;
let dir = folder("autosave-take");
let root = dir.join("Autosave");
let mut e = Engine::default();
let track =
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav");
std::fs::create_dir_all(take.parent().unwrap()).unwrap();
let audio: Vec<u8> = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect();
std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap();
let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] };
let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::<Vec<_>>();
let w = Autosaver::new(root.clone());
w.write(e.session_arc(), None, Some(plan.clone()));
drop(w);
let left = leftovers(&root);
assert!(left[0].recording);
let mut r = Engine::default();
recover(&mut r, &left[0]).unwrap();
assert_eq!(clips(&r), [(48_000, 480)], "all of the take, its last second included");
discard(&left[0]);
let w = Autosaver::new(root.clone());
w.write(r.session_arc(), None, Some(plan));
drop(w);
let mut again = Engine::default();
recover(&mut again, &leftovers(&root)[0]).unwrap();
assert_eq!((clips(&again), again.session().sources.len()), (vec![(48_000, 480)], 1), "placed once");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn recovery_never_writes_a_file_that_isnt_a_take_in_a_take_folder() {
use crate::io::Take;
let dir = folder("autosave-hostile-take");
let root = dir.join("Autosave");
let mut e = Engine::default();
let track =
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files");
std::fs::create_dir_all(&takes).unwrap();
let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat();
let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4);
let files =
[(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())];
for (path, bytes) in &files {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
let mut paths: Vec<PathBuf> = files.iter().map(|(p, _)| p.clone()).collect();
#[cfg(unix)]
{
let link = takes.join("Vox_03.wav");
std::os::unix::fs::symlink(&files[1].0, &link).unwrap();
paths.push(link);
}
let plan = Recording {
start: 0,
pass: None,
punch: None,
rate: 48_000,
takes: paths.iter().map(|p| Take { track, path: p.clone(), channels: 1 }).collect(),
};
let w = Autosaver::new(root.clone());
w.write(e.session_arc(), None, Some(plan));
drop(w);
let mut r = Engine::default();
let skipped = recover(&mut r, &leftovers(&root)[0]).unwrap();
assert_eq!(skipped.len(), paths.len(), "{skipped:?}");
assert!(r.session().sources.is_empty());
for (path, bytes) in &files {
assert_eq!(&std::fs::read(path).unwrap(), bytes, "{} untouched", path.display());
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn unsaved_work_is_offered_once_its_session_closes() {
let dir = folder("autosave-leftovers");
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let root = dir.join("Autosave");
std::fs::create_dir_all(&dir).unwrap();
let mut e = Engine::default();
crate::io::save_session(&mut e, &path).unwrap();
record(&mut e, 0.5);
let mut a = Autosave::create(&root, "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap();
assert!(leftovers(&root).is_empty());
drop(a);
let left = leftovers(&root);
assert_eq!((left.len(), left[0].name.as_str(), left[0].origin.as_deref()), (1, "Song", Some(path.as_str())));
let mut r = Engine::default();
recover(&mut r, &left[0]).unwrap();
assert!(r.is_dirty());
r.execute("session.save", &json!({})).unwrap();
assert!(leftovers(&root).is_empty());
assert!(!left[0].dir.exists());
let mut reopened = Engine::default();
open_session(&mut reopened, &path).unwrap();
assert_eq!(levels(&reopened), [0.5]);
Autosave::create(&root, "Song").unwrap().discard();
assert_eq!(std::fs::read_dir(&root).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
pub const EXTENSION: &str = "scgrp";
const FORMAT: &str = "soundcraft.clipgroup";
/// Refuse files that would place absurd numbers of clips.
/// Refuse files that would place absurd numbers of clips, tracks or audio files.
const MAX_CLIPS: usize = 10_000;
#[derive(Debug, Serialize, Deserialize)]
@ -31,6 +31,9 @@ struct GroupFile {
/// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written.
pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<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,35 +84,50 @@ 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()));
}
if file.lanes.iter().map(Vec::len).sum::<usize>() > MAX_CLIPS {
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing.
let mut decoded = BTreeMap::new();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
let rate = e.session().sample_rate.hz();
for (old, rel) in &file.audio {
let full = dir.join(rel);
if !full.starts_with(&dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
}
let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
decoded.insert(*old, (rel.clone(), buf));
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
Some(n) => *n,
None => {
let bytes = crate::io::read_file(&full)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
decoded.push((rel.clone(), buf));
index.insert(key, decoded.len() - 1);
decoded.len() - 1
}
};
file_of.insert(*old, n);
}
let src_rate = f64::from(file.sample_rate.max(1));
let s = e.session_mut();
let ratio = f64::from(s.sample_rate.hz()) / src_rate;
let scale = |v: Samples| -> Samples { (v as f64 * ratio).round() as Samples };
let mut map: BTreeMap<u64, SourceId> = BTreeMap::new();
for (old, (rel, buf)) in decoded {
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string();
map.insert(old, crate::io::add_source(s, &name, buf, None, FileFormat::Wav));
}
let sources: Vec<SourceId> = decoded
.into_iter()
.map(|(rel, buf)| {
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string();
crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
})
.collect();
let map: BTreeMap<u64, SourceId> = file_of.into_iter().filter_map(|(old, n)| Some((old, *sources.get(n)?))).collect();
let start_idx = first.and_then(|t| s.tracks.iter().position(|x| x.id == t));
let mut placed = Vec::new();
let group_id = s.alloc();
@ -142,9 +159,9 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
}
c.id = s.new_clip_id();
c.start = at.saturating_add(scale(c.start.max(0)));
c.length = scale(c.length).max(1);
c.length = scale(c.length);
c.group = Some(group_id);
c.clamp_fades();
c.sanitize();
placed.push(c.id);
crate::edit::place_clip(s, target, c);
}
@ -190,6 +207,46 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn positions_in_a_group_file_are_kept_within_reach() {
let dir = std::env::temp_dir().join(format!("sc-grp-far-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let p = dir.join("far.scgrp");
let note = json!({"pitch": 60, "velocity": 100, "release_velocity": 64, "channel": 0, "start": i64::MAX, "length": i64::MAX});
let clip =
json!({"id": 1, "name": "far", "start": i64::MAX, "length": i64::MAX, "content": {"Midi": {"sequence": {"notes": [note], "ctrls": []}}}});
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 1, "lanes": [[clip]], "audio": {}});
std::fs::write(&p, file.to_string()).unwrap();
let mut e = crate::demo::demo_engine();
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
let id = soundcraft_model::ClipId(r["clips"][0].as_u64().unwrap());
let c = e.session().find_clip(id).unwrap().1;
assert!(c.start <= crate::cmd::MAX_POSITION && c.length <= crate::cmd::MAX_POSITION);
e.execute("edit.select_all", &json!({})).unwrap();
for cmd in ["edit.duplicate", "event.extract_chords"] {
assert!(!matches!(e.execute(cmd, &json!({})), Err(crate::EngineError::Internal(..))), "{cmd}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_file_named_by_many_ids_becomes_one_source() {
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let audio = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let clip = |id: u64| json!({"id": id, "name": "k", "start": id * 1000, "length": 480, "content": {"Audio": {"source": id, "offset": 0}}});
let audio_paths = json!({"1": "k.wav", "2": "./k.wav", "3": "k.wav"});
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 4000, "lanes": [[clip(1), clip(2), clip(3)]], "audio": audio_paths});
let p = dir.join("k.scgrp");
std::fs::write(&p, file.to_string()).unwrap();
let mut e = crate::demo::demo_engine();
let before = e.session().sources.len();
let r = e.execute("file.import_clip_groups", &json!({"path": p.to_string_lossy()})).unwrap();
assert_eq!((r["clips"].as_array().unwrap().len(), e.session().sources.len()), (3, before + 1));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn rejects_foreign_files_and_escaping_paths() {
let dir = std::env::temp_dir().join(format!("sc-grp-bad-{}", std::process::id()));

View File

@ -40,7 +40,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result<Value> {
seq.sort();
let count = seq.notes.len();
let clip = Clip::midi(ClipId(0), "Audio as MIDI", 0, r.len(), seq);
e.clipboard = Clipboard { length: r.len(), tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::default() };
e.clipboard = Clipboard { tracks: vec![vec![clip]], automation: vec![Vec::new()], ..Clipboard::of_range(e.session(), r) };
Ok(json!({"notes": count, "length": r.len()}))
}

View File

@ -4,7 +4,7 @@ use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent};
use soundcraft_model::{Clip, ClipContent, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
@ -12,7 +12,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AudioSuite Process",
[],
None,
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?}",
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks)",
has_selection,
process
)]
@ -35,17 +35,36 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
}
if soundcraft_dsp::is_generator(&name) && p.get("clips").is_none() && p.get("clip").is_none() {
let r = range_param(e, "audiosuite.process", p)?;
if !r.is_empty() {
return generate(e, p, &name, &params, r);
}
}
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("audiosuite.process", "select audio clips first"));
}
let sr = e.session().sample_rate;
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
let most = match name.as_str() {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr.as_f64()),
_ => usize::MAX,
};
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels());
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
let mut tmp = e.session().clone();
@ -78,20 +97,11 @@ fn process(e: &mut Engine, p: &Value) -> Result<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));
for (k, v) in &params {
pl.set_param(k, *v);
}
soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf);
apply(plugin, &params, &mut audio, srf)?;
audio
}
};
@ -122,6 +132,46 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"processed": done, "process": name}))
}
/// Fill `r` on each selected audio track with a new clip from the generator `name`, over
/// whatever was there, like Pro Tools' Signal Generator.
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
let sr = e.session().sample_rate;
let tracks = tracks_param(e, "audiosuite.process", p)?;
// Refuse before generating anything, rather than cut the clip short.
for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) {
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?;
}
let mut clips = Vec::new();
for track in tracks {
let Some(t) = e.session().track(track).filter(|t| t.kind == TrackKind::Audio) else { continue };
let channels = t.channels().max(1);
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
let clip_name = format!("{}-{}", t.name, short(name));
let mut audio = vec![vec![0.0; frames]; channels];
apply(name, params, &mut audio, sr.as_f64() as f32)?;
let s = e.session_mut();
let src = crate::io::add_source(s, &clip_name, AudioBuffer { sample_rate: sr.hz(), channels: audio }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, track, Clip::audio(id, &clip_name, src, 0, r.start, i64::try_from(frames).unwrap_or(0)));
clips.push(id.0);
}
if clips.is_empty() {
return Err(bad("audiosuite.process", "select an audio track to generate on"));
}
Ok(json!({"processed": clips.len(), "process": name, "clips": clips}))
}
/// Run plugin `id` with `params` over `audio`, in place.
fn apply(id: &str, params: &[(String, f32)], audio: &mut [Vec<f32>], srf: f32) -> Result<()> {
let Some(mut pl) = soundcraft_dsp::create(id) else { return Err(bad("audiosuite.process", format!("cannot create `{id}`"))) };
pl.prepare(srf, 1024, audio.len().max(1));
for (k, v) in params {
pl.set_param(k, *v);
}
soundcraft_dsp::offline::apply_plugin(audio, pl.as_mut(), srf);
Ok(())
}
fn short(n: &str) -> String {
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
s.chars().take(8).collect()
@ -165,4 +215,28 @@ mod tests {
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
#[test]
fn signal_generator_fills_the_selection_with_a_clip() {
let (mut e, _) = gained_clip();
let tone = e.session().tracks[0].id;
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Empty"));
e.execute("edit.select", &json!({"tracks": [t.0], "start": 48_000, "end": 96_000})).unwrap();
assert!(matches!(e.execute("audiosuite.process", &json!({"process": "normalize"})), Err(crate::EngineError::BadParams(..))));
let generator = json!({"process": "signal_generator", "params": {"freq": 440.0, "level": -6.0}});
assert_eq!(e.execute("audiosuite.process", &generator).unwrap()["processed"], 1);
let c = e.session().tracks[1].clips()[0].clone();
assert_eq!((c.start, c.length), (48_000, 48_000));
let buf = &e.session().pool.get(c.source().unwrap()).unwrap().buffer;
assert_eq!(buf.channels.len(), 2);
let peak = buf.channels[1].iter().fold(0.0f32, |m, x| m.max(x.abs()));
assert!((peak - 0.5).abs() < 0.01, "-6 dB sine peaks near 0.5, got {peak}");
// Over existing audio it replaces just the selection.
e.execute("edit.select", &json!({"tracks": [tone.0], "start": 32, "end": 96})).unwrap();
e.execute("audiosuite.process", &generator).unwrap();
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
}
}

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
}
}
@ -153,8 +153,13 @@ pub fn specs() -> Vec<CommandSpec> {
Some(_) => return Err(bad("clip.place_source", "audio files go on audio tracks")),
None => s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::for_channels(chans.min(2)), Some(&name)),
};
let (sh, len) = (s.edit.edit_mode == soundcraft_model::EditMode::Shuffle, frames.max(1));
let at = if sh { crate::edit::shuffle_slot(s, t, at, &[]) } else { at };
if sh {
crate::edit::make_room(s, t, at, len).map_err(|why| bad("clip.place_source", why))?;
}
let id = s.new_clip_id();
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, frames.max(1)));
crate::edit::place_clip(s, t, soundcraft_model::Clip::audio(id, name, src, 0, at, len));
Ok(json!({"clip": id, "track": t}))
}
),
@ -199,9 +204,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<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 +291,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();
@ -297,7 +307,7 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
copies.push((c.start + c.length * k, nc));
}
for (at, nc) in copies {
crate::edit::paste(s, tid, at, &[nc], c.length, false);
let _ = crate::edit::paste(s, tid, at, &[nc], c.length, false);
made += 1;
}
}
@ -341,14 +351,11 @@ fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
let moves: Vec<(soundcraft_model::ClipId, i64)> = ids
.iter()
.filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, c.start + c.sync_point)))
.map(|(id, anchor)| (id, s.edit.grid.snap(anchor, s.sample_rate, &s.tempo, s.frame_rate) - anchor))
.collect();
let moves: Vec<(soundcraft_model::ClipId, i64)> =
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
let s = e.session_mut();
for (id, d) in &moves {
crate::edit::move_clips(s, &[*id], *d, None);
crate::edit::move_clips(s, &[*id], *d, &[]);
}
Ok(json!({"clips": moves.len()}))
}
@ -378,6 +385,45 @@ mod tests {
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
}
/// A clip on an Elastic track playing `clip_len` samples of a source that is silent before `tone_at`.
fn elastic_clip(source_len: usize, tone_at: usize, clip_len: i64) -> (crate::Engine, TrackId, ClipId) {
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let cid = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
let mut e = crate::Engine::new(s);
e.execute("track.elastic", &json!({"track": t.0, "algorithm": "polyphonic"})).unwrap();
(e, t, cid)
}
fn rendered_peak(e: &crate::Engine, t: TrackId, cid: ClipId) -> f32 {
let range = e.session().find_clip(cid).unwrap().1.range();
soundcraft_mix::render_clips(e.session(), t, range).iter().flatten().fold(0.0, |m, x| m.max(x.abs()))
}
/// Elastic Properties stretches the material the clip plays, by the requested ratio (#227).
#[test]
fn elastic_properties_stretches_the_clip_material() {
let (mut e, t, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
assert_eq!(e.session().find_clip(cid).unwrap().1.length, 40_000);
assert!(rendered_peak(&e, t, cid) > 0.3, "the tone in the second half was lost");
let (mut e, t, cid) = elastic_clip(60_000, 20_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
assert_eq!(rendered_peak(&e, t, cid), 0.0, "audio from past the clip end leaked in");
for (ratio, len) in [(4.0, 80_000), (0.2, 4_000)] {
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": ratio})).unwrap();
assert_eq!(e.session().find_clip(cid).unwrap().1.length, len, "ratio {ratio}");
}
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();

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

@ -1,25 +1,25 @@
//! Edit menu commands.
use super::more_util::toggle_flag;
use super::*;
use crate::edit as ops;
use crate::{Clipboard, cmd};
use serde_json::json;
use soundcraft_model::{EditMode, FadeShape, Session};
use soundcraft_model::{EditMode, Fade, FadeShape, Session};
pub fn specs() -> Vec<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,
@ -72,8 +72,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "edit.move_selection_right", "Move Edit Right", ["Edit", "Selection"], Some("Cmd+Alt+'"), "{}", always, |e, _| shift_selection(e, 1)),
cmd!(noundo "edit.halve_selection", "Halve Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 0.5)),
cmd!(noundo "edit.double_selection", "Double Edit", ["Edit", "Selection"], None, "{}", always, |e, _| scale_selection(e, 2.0)),
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, -1)),
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| extend_tracks(e, 1)),
cmd!(noundo "edit.extend_selection_up", "Extend Edit Up", ["Edit", "Selection"], Some("Ctrl+Shift+P"), "{}", has_tracks, |e, _| extend_tracks(e, -1)),
cmd!(noundo "edit.extend_selection_down", "Extend Edit Down", ["Edit", "Selection"], Some("Ctrl+Shift+;"), "{}", has_tracks, |e, _| extend_tracks(e, 1)),
cmd!(noundo "edit.remove_selection_top", "Remove Edit from Top", ["Edit", "Selection"], None, "{}", has_tracks, |e, _| {
let s = e.session_mut();
if !s.edit.selected_tracks.is_empty() {
@ -103,7 +103,15 @@ pub fn specs() -> Vec<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",
@ -118,19 +126,41 @@ pub fn specs() -> Vec<CommandSpec> {
"Create...",
["Edit", "Fades"],
Some("Cmd+F"),
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}",
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?, curvature?: -1..1}",
has_range,
fades_create
),
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge),
cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| {
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
e.session_mut().edit.edit_mode = m;
Ok(json!({"mode": m}))
}),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge),
cmd!(
noundo "edit.mode",
"Edit Mode",
[],
Some("F1-F4, Alt+1-4, `"),
"{mode: shuffle|slip|spot|grid|relative_grid, snap_to_grid?: bool (Shuffle, Slip and Spot)}",
always,
set_mode
),
cmd!(
noundo "edit.snap_to_grid",
"Snap to Grid",
[],
Some("Shift+F4"),
"{value?: bool} (toggles when omitted) — in Shuffle, Slip and Spot the Edit cursor and selections snap to the grid",
always,
snap_to_grid
),
cmd!(
noundo "edit.shuffle_lock",
"Shuffle Lock",
[],
None,
"{value?: bool} (toggles when omitted) — keeps Shuffle mode from being chosen",
always,
shuffle_lock
),
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
e.session_mut().edit.tool = t;
@ -141,10 +171,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Move Clips",
[],
None,
"{clips?: [id], by?: samples, to?: position (first clip start), track?: target}",
"{clips?: [id], by?: samples, to?: position for the first clip that can move (Grid modes snap its sync point), track?: where the first clip's track goes; the others keep their distance}",
has_selection,
move_clips
),
cmd!(
"edit.trim_clip",
"Trim Clip",
[],
None,
"{clip: id, start?: position, end?: position} — moves the clip's edges like the Trim tool, in the current Edit mode",
always,
trim_clip
),
cmd!("edit.consolidate", "Consolidate", ["Edit"], Some("Alt+Shift+3"), "{tracks?, start?, end?}", has_range, consolidate),
]
}
@ -153,8 +192,57 @@ fn shuffle(s: &Session) -> bool {
s.edit.edit_mode == EditMode::Shuffle
}
fn set_mode(e: &mut Engine, p: &Value) -> Result<Value> {
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
if m == EditMode::Shuffle && e.session().edit.flag(ops::SHUFFLE_LOCK) {
return Err(bad("edit.mode", "Shuffle is locked"));
}
let snap = bool_or(p, "snap_to_grid", false) && !m.is_grid();
let ed = &mut e.session_mut().edit;
ed.edit_mode = m;
ed.set_flag(ops::SNAP_TO_GRID, snap);
if m.is_grid() {
ed.set_flag(ops::RELATIVE_GRID, m == EditMode::GridRelative);
}
Ok(json!({"mode": m, "snap_to_grid": snap}))
}
fn snap_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
if e.session().edit.edit_mode.is_grid() {
return Err(bad("edit.snap_to_grid", "Grid mode always snaps"));
}
toggle_flag(e, p, ops::SNAP_TO_GRID)
}
fn shuffle_lock(e: &mut Engine, p: &Value) -> Result<Value> {
if shuffle(e.session()) && bool_or(p, "value", true) {
return Err(bad("edit.shuffle_lock", "leave Shuffle mode before locking it"));
}
toggle_flag(e, p, ops::SHUFFLE_LOCK)
}
fn has_last_selection(e: &Engine) -> std::result::Result<(), String> {
if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) }
}
/// Remember the selection `before` had when a command changed it, for Restore Last Selection.
pub(crate) fn remember_selection(e: &mut Engine, before: &Session) {
let (old, new) = (&before.edit, &e.session().edit);
if old.selection == new.selection && old.selected_tracks == new.selected_tracks {
return;
}
e.last_selection = Some((old.selection, old.selected_tracks.clone()));
}
/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so
/// invoking it again goes back.
fn restore_selection(e: &mut Engine, _: &Value) -> Result<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> {
@ -163,7 +251,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
if range.is_empty() {
return Err(bad(id, "selection is empty"));
}
let mut cb = Clipboard { length: range.len(), ..Clipboard::default() };
let mut cb = Clipboard::of_range(e.session(), range);
let follow = e.session().edit.automation_follows_edit;
for t in &tracks {
cb.tracks.push(ops::copy_range(e.session(), *t, range));
@ -205,15 +293,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
let sh = shuffle(e.session());
let s = e.session_mut();
for t in &tracks {
ops::clear_range(s, *t, range, sh);
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
l.clear_range(range.start, range.end);
if sh {
l.shift_from(range.end, -range.len());
}
}
}
cut_track(s, *t, range, sh, follow);
}
s.edit.selection = soundcraft_time::Range::point(range.start);
}
@ -222,17 +302,39 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
Ok(json!({"clips": n, "length": range.len()}))
}
/// Remove `r` from a track, closing the gap in Shuffle mode; automation follows when `follow`.
fn cut_track(s: &mut Session, t: TrackId, r: soundcraft_time::Range, sh: bool, follow: bool) {
if follow && let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
l.clear_range(r.start, r.end);
}
}
ops::clear_range(s, t, r, sh);
}
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.paste", p)?;
let at = position_param(e, "edit.paste", p, "at")?.unwrap_or(e.session().edit.selection.start);
let given = position_param(e, "edit.paste", p, "at")?;
let cb = e.clipboard.clone();
let sh = shuffle(e.session());
let follow = e.session().edit.automation_follows_edit;
let s = e.session();
let at = match given {
Some(at) => at,
None if s.edit.edit_mode == EditMode::GridRelative => {
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
}
None => s.edit.selection.start,
};
let sh = shuffle(s) && !cb.automation_only;
let follow = s.edit.automation_follows_edit;
let replace = Some(s.edit.selection).filter(|r| sh && given.is_none() && !r.is_empty());
let s = e.session_mut();
let mut pasted = 0;
for (i, t) in tracks.iter().enumerate() {
let Some(clips) = cb.tracks.get(i % cb.tracks.len().max(1)) else { continue };
pasted += ops::paste(s, *t, at, clips, cb.length, sh).len();
if let Some(r) = replace {
cut_track(s, *t, r, true, follow);
}
pasted += ops::paste(s, *t, at, clips, cb.length, sh).map_err(|why| bad("edit.paste", why))?.len();
if follow && let (Some(lanes), Some(tr)) = (cb.automation.get(i % cb.automation.len().max(1)), s.track_mut(*t)) {
for l in lanes {
let dst = tr.lane_mut(&l.param);
@ -329,7 +431,7 @@ fn repeat_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
c
})
.collect();
ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
let _ = ops::paste(s, *t, at, &trimmed, (range.end - at).min(len), false);
}
}
at += len;
@ -429,20 +531,25 @@ fn scale_selection(e: &mut Engine, f: f64) -> Result<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}))
}
@ -453,12 +560,14 @@ fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
if range.is_empty() {
return Err(bad("edit.duplicate", "selection is empty"));
}
let sh = shuffle(e.session());
let follow = e.session().edit.automation_follows_edit;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let clips = ops::copy_range(s, *t, range);
n += ops::paste(s, *t, range.end, &clips, range.len(), false).len();
if let Some(tr) = s.track_mut(*t) {
n += ops::paste(s, *t, range.end, &clips, range.len(), sh).map_err(|why| bad("edit.duplicate", why))?.len();
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
let pts: Vec<_> = l.points.iter().filter(|p| range.contains(p.at)).copied().collect();
for pt in pts {
@ -478,11 +587,13 @@ fn repeat(e: &mut Engine, p: &Value) -> Result<Value> {
if range.is_empty() {
return Err(bad("edit.repeat", "selection is empty"));
}
let sh = shuffle(e.session());
let s = e.session_mut();
for t in &tracks {
let clips = ops::copy_range(s, *t, range);
for k in 1..=count {
ops::paste(s, *t, range.start + range.len() * k, &clips, range.len(), false);
let at = range.start.saturating_add(range.len().saturating_mul(k));
ops::paste(s, *t, at, &clips, range.len(), sh).map_err(|why| bad("edit.repeat", why))?;
}
}
Ok(json!({"repeats": count}))
@ -493,19 +604,25 @@ fn shift(e: &mut Engine, p: &Value) -> Result<Value> {
let later = bool_or(p, "later", true);
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let n = ops::move_clips(s, &ids, if later { by } else { -by }, None);
let n = ops::move_clips(s, &ids, if later { by } else { -by }, &[]);
Ok(json!({"moved": n}))
}
fn insert_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.insert_silence", p)?;
let range = range_param(e, "edit.insert_silence", p)?;
let sh = shuffle(e.session());
let follow = e.session().edit.automation_follows_edit;
let s = e.session_mut();
for t in &tracks {
ops::insert_silence(s, *t, range);
if let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
l.shift_from(range.start, range.len());
if sh {
ops::make_room(s, *t, range.start, range.len()).map_err(|why| bad("edit.insert_silence", why))?;
} else {
ops::clear_range(s, *t, range, false);
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
l.clear_range(range.start, range.end);
}
}
}
}
@ -591,13 +708,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result<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 +727,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,17 +795,59 @@ 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}))
}
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.fades_create", p)?;
let r = range_param(e, "edit.fades_create", p)?;
let shape = str_param(p, "shape")
.map_or(Some(FadeShape::EqualPower), FadeShape::from_id)
.ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?;
if p.get("clip").is_some() || p.get("side").is_some() {
let curvature = match p.get("curvature") {
None => 0.0,
Some(value) => value
.as_f64()
.filter(|v| v.is_finite() && (-1.0..=1.0).contains(v))
.ok_or_else(|| bad("edit.fades_create", "curvature must be a finite number from -1 to 1"))? as f32,
};
if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) {
return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range"));
}
let id = p.get("clip").and_then(Value::as_u64).map(soundcraft_model::ClipId).ok_or_else(|| bad("edit.fades_create", "clip must be an id"))?;
let fade_in = match str_param(p, "side") {
Some("in") => true,
Some("out") => false,
_ => return Err(bad("edit.fades_create", "side must be in or out")),
};
let length = p
.get("length")
.and_then(Value::as_i64)
.filter(|len| *len > 0)
.ok_or_else(|| bad("edit.fades_create", "length must be a positive sample count"))?;
let (_, clip) = e.session().find_clip(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
let available = clip.length.min(clip.end().saturating_sub(clip.start));
if available <= 0 {
return Err(bad("edit.fades_create", "clip has no length"));
}
if p.get("shape").is_some_and(|v| !v.is_string()) {
return Err(bad("edit.fades_create", "shape must be a fade shape name"));
}
let fade = Fade { len: length.min(available), shape, curvature };
let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
if fade_in {
clip.fade_in = fade;
} else {
clip.fade_out = fade;
}
return Ok(json!({"fades": 1}));
}
if p.get("curvature").is_some() {
return Err(bad("edit.fades_create", "curvature requires a clip and side"));
}
let tracks = tracks_required(e, "edit.fades_create", p)?;
let r = range_param(e, "edit.fades_create", p)?;
let s = e.session_mut();
let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum();
if n == 0 {
@ -715,10 +887,11 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<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) };
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, &[]) };
s.edit.selection = s.edit.selection.shifted(step);
if s.edit.selection.start < 0 {
s.edit.selection = s.edit.selection.shifted(-s.edit.selection.start);
@ -731,44 +904,183 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
if ids.is_empty() {
return Err(bad("edit.move_clips", "no clips"));
}
let to_track = track_param(e, "edit.move_clips", p, "track")?;
let first = ids.iter().filter_map(|id| e.session().find_clip(*id).map(|(_, c)| c.start)).min().unwrap_or(0);
let s = e.session();
let dest = match track_param(e, "edit.move_clips", p, "track")? {
Some(to) => ids
.iter()
.find_map(|c| s.find_clip(*c))
.and_then(|(from, _)| ops::destinations(s, &ids, from, to))
.ok_or_else(|| bad("edit.move_clips", "the clips can't land there: past the last track, or on a track that can't hold them"))?,
None => Vec::new(),
};
let first = ops::first_movable(s, &ids);
let delta = match (position_param(e, "edit.move_clips", p, "by")?, position_param(e, "edit.move_clips", p, "to")?) {
(Some(by), _) => by,
(None, Some(to)) => to - first,
(None, Some(to)) => to.saturating_sub(first.map_or(0, |c| c.start)),
_ => 0,
};
// Grid mode snaps the destination.
let s = e.session();
let delta = if s.edit.edit_mode.is_grid() && p.get("to").is_some() {
s.edit.grid.snap(first + delta, s.sample_rate, &s.tempo, s.frame_rate) - first
} else {
delta
let mode = s.edit.edit_mode;
let delta = match first {
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
_ => delta,
};
let s = e.session_mut();
let n = ops::move_clips(s, &ids, delta, to_track);
let n = if mode == EditMode::Shuffle {
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
} else {
ops::move_clips(s, &ids, delta, &dest)
};
s.edit.selected_clips = ids;
Ok(json!({"moved": n, "by": delta}))
}
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
let id = clip_id(p, "edit.trim_clip")?;
let start = position_param(e, "edit.trim_clip", p, "start")?;
let end = position_param(e, "edit.trim_clip", p, "end")?;
if start.is_none() && end.is_none() {
return Err(bad("edit.trim_clip", "pass `start` and/or `end`"));
}
let sh = shuffle(e.session());
let c = ops::trim_clip(e.session_mut(), id, start, end, sh).ok_or_else(|| bad("edit.trim_clip", "no such clip, or it is edit-locked"))?;
Ok(json!({"start": c.start, "end": c.end()}))
}
fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.consolidate", p)?;
let r = range_param(e, "edit.consolidate", p)?;
if r.is_empty() {
return Err(bad("edit.consolidate", "selection is empty"));
}
let midi = tracks.iter().filter(|&&t| crate::edit::consolidate_midi(e.session_mut(), t, r)).count();
let mut n = 0;
for t in tracks {
if crate::io::consolidate(e, t, r)? {
if crate::io::consolidate(e, "edit.consolidate", t, r)? {
n += 1;
}
}
Ok(json!({"consolidated": n}))
Ok(json!({"consolidated": n + midi}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind};
#[test]
fn targeted_fade_curvature_saves_reopens_and_undoes_without_changing_lengths() {
for side in ["in", "out"] {
let mut e = crate::demo::demo_engine();
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve"})).unwrap();
let before = e.session().clone();
let undo_len = e.undo_history().len();
e.execute(
"edit.fades_create",
&json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve", "curvature": -0.65}),
)
.unwrap();
let mut expected = before.clone();
let c = expected.find_clip_mut(clip.id).unwrap();
if side == "in" {
c.fade_in.curvature = -0.65;
} else {
c.fade_out.curvature = -0.65;
}
assert_eq!(e.session(), &expected);
let reopened = Session::from_json(&e.session().to_json().unwrap()).unwrap();
let reopened = reopened.find_clip(clip.id).unwrap().1;
let stored = expected.find_clip(clip.id).unwrap().1;
assert_eq!(reopened.fade_in, stored.fade_in);
assert_eq!(reopened.fade_out, stored.fade_out);
assert_eq!(e.undo_history().len(), undo_len + 1);
assert!(e.undo());
assert_eq!(e.session(), &before);
assert!(e.redo());
assert_eq!(e.session(), &expected);
}
}
#[test]
fn targeted_fades_fill_only_the_requested_side_and_undo_once() {
for side in ["in", "out"] {
let mut e = crate::demo::demo_engine();
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select_none", &json!({})).unwrap();
let before = e.session().clone();
let undo_len = e.undo_history().len();
assert_eq!(
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": i64::MAX, "shape": "s-curve"})).unwrap(),
json!({"fades": 1})
);
let mut expected = before.clone();
let c = expected.find_clip_mut(clip.id).unwrap();
let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 };
if side == "in" {
c.fade_in = fade;
} else {
c.fade_out = fade;
}
assert_eq!(e.session(), &expected);
assert_eq!(e.undo_history().len(), undo_len + 1);
assert!(e.undo());
assert_eq!(e.session(), &before);
assert!(e.redo());
assert_eq!(e.session(), &expected);
}
}
#[test]
fn targeted_fades_reject_invalid_parameters_without_an_edit() {
let mut e = crate::demo::demo_engine();
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
for params in [
json!({"clip": id, "side": "in"}),
json!({"clip": id, "length": 10}),
json!({"side": "out", "length": 10}),
json!({"clip": "bad", "side": "in", "length": 10}),
json!({"clip": u64::MAX, "side": "in", "length": 10}),
json!({"clip": id, "side": "bad", "length": 10}),
json!({"clip": id, "side": "in", "length": 0}),
json!({"clip": id, "side": "in", "length": -1}),
json!({"clip": id, "side": "in", "length": 1.5}),
json!({"clip": id, "side": "in", "length": "10"}),
json!({"clip": id, "side": "in", "length": u64::MAX}),
json!({"clip": id, "side": "in", "length": 10, "shape": "bad"}),
json!({"clip": id, "side": "in", "length": 10, "shape": null}),
json!({"clip": id, "side": "in", "length": 10, "start": 0}),
json!({"clip": id, "side": "in", "length": 10, "end": 100}),
json!({"clip": id, "side": "in", "length": 10, "tracks": []}),
json!({"clip": id, "side": "in", "length": 10, "curvature": 2}),
json!({"clip": id, "side": "in", "length": 10, "curvature": -2}),
json!({"clip": id, "side": "in", "length": 10, "curvature": 1e100}),
json!({"clip": id, "side": "in", "length": 10, "curvature": null}),
json!({"clip": id, "side": "in", "length": 10, "curvature": "0.5"}),
json!({"start": 0, "end": 100, "curvature": 0.5}),
] {
let before = e.session().clone();
let history = e.undo_history();
assert!(e.execute("edit.fades_create", &params).is_err(), "{params}");
assert_eq!(e.session(), &before, "{params}");
assert_eq!(e.undo_history(), history, "{params}");
}
e.session_mut().find_clip_mut(soundcraft_model::ClipId(id)).unwrap().length = 0;
let before = e.session().clone();
assert!(e.execute("edit.fades_create", &json!({"clip": id, "side": "in", "length": 1})).is_err());
assert_eq!(e.session(), &before);
}
#[test]
fn targeted_fades_support_one_sample_clips() {
let mut e = crate::demo::demo_engine();
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id;
e.session_mut().find_clip_mut(id).unwrap().length = 1;
for side in ["in", "out"] {
e.execute("edit.fades_create", &json!({"clip": id.0, "side": side, "length": 1})).unwrap();
let clip = e.session().find_clip(id).unwrap().1;
assert_eq!(if side == "in" { clip.fade_in.len } else { clip.fade_out.len }, 1);
}
}
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
@ -782,4 +1094,119 @@ mod tests {
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
assert_ne!(e.revision, rev);
}
fn keys() -> (Engine, TrackId, impl Fn(i64) -> i64) {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let (tempo, sr) = (e.session().tempo.clone(), e.session().sample_rate);
(e, t, move |q| tempo.tick_to_samples(q * soundcraft_time::TICKS_PER_QUARTER, sr))
}
fn note(start: i64, length: i64, pitch: u8) -> soundcraft_midi::Note {
let q = soundcraft_time::TICKS_PER_QUARTER;
soundcraft_midi::Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start: start * q, length: length * q }
}
#[test]
fn consolidating_an_empty_midi_selection_makes_an_empty_clip() {
let (mut e, t, at) = keys();
e.execute("edit.select", &json!({"tracks": [t.0], "start": at(4), "end": at(12)})).unwrap();
assert_eq!(e.execute("edit.consolidate", &json!({})).unwrap()["consolidated"], 1);
let clips = e.session().track(t).unwrap().clips().to_vec();
assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::<Vec<_>>(), vec![(at(4), at(12))]);
assert!(matches!(&clips[0].content, ClipContent::Midi { sequence } if sequence.notes.is_empty()));
}
#[test]
fn consolidating_midi_merges_the_clips_as_they_sound() {
let (mut e, t, at) = keys();
let s = e.session_mut();
let cc = soundcraft_midi::CtrlEvent { tick: 0, channel: 0, kind: soundcraft_midi::CtrlKind::Cc(1), value: 64 };
let a = soundcraft_midi::Sequence { notes: vec![note(0, 1, 60), note(3, 2, 62), note(5, 1, 63)], ctrls: vec![] };
let b = soundcraft_midi::Sequence { notes: vec![note(0, 1, 64)], ctrls: vec![] };
let c = soundcraft_midi::Sequence { notes: vec![note(1, 1, 65)], ctrls: vec![cc] };
for (start, end, seq, muted) in [(0, 4, a, false), (6, 8, b, true), (8, 12, c, false)] {
let id = s.new_clip_id();
let mut clip = Clip::midi(id, "Keys", at(start), at(end) - at(start), seq);
clip.muted = muted;
ops::place_clip(s, t, clip);
}
e.execute("edit.select", &json!({"tracks": [t.0], "start": at(2), "end": at(10)})).unwrap();
e.execute("edit.consolidate", &json!({})).unwrap();
let clips = e.session().track(t).unwrap().clips().to_vec();
assert_eq!(clips.iter().map(|c| (c.start, c.end())).collect::<Vec<_>>(), vec![(0, at(2)), (at(2), at(10)), (at(10), at(12))]);
let ClipContent::Midi { sequence } = &clips[1].content else { panic!("not MIDI") };
assert_eq!(sequence.notes, vec![note(1, 1, 62), note(7, 1, 65)]);
assert_eq!(sequence.ctrls, vec![soundcraft_midi::CtrlEvent { tick: 6 * soundcraft_time::TICKS_PER_QUARTER, ..cc }]);
}
/// A 60,000-frame source with `clips` placed on one track as (start, length, source offset).
fn trim_session(clips: &[(Samples, Samples, Samples)]) -> (Engine, TrackId, Vec<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",
@ -284,7 +290,7 @@ pub(super) fn cut_all_automation(e: &mut Engine, p: &Value) -> Result<Value> {
fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let mut cb = Clipboard { length: r.len(), ..Clipboard::default() };
let mut cb = Clipboard { automation_only: true, ..Clipboard::of_range(e.session(), r) };
let mut lanes_n = 0;
for t in &tracks {
cb.tracks.push(Vec::new());
@ -326,7 +332,7 @@ fn auto_copy(e: &mut Engine, p: &Value, which: Lanes, cut: bool, id: &str) -> Re
Ok(json!({"lanes": lanes_n, "length": r.len()}))
}
fn copy_clip_gain(e: &mut Engine, p: &Value) -> Result<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)?;
@ -545,12 +551,13 @@ fn duplicate_extend(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "edit.duplicate_extend";
let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?;
let (sh, follow) = (shuffle(e.session()), e.session().edit.automation_follows_edit);
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let clips = ops::copy_range(s, *t, r);
n += ops::paste(s, *t, r.end, &clips, r.len(), false).len();
if let Some(tr) = s.track_mut(*t) {
n += ops::paste(s, *t, r.end, &clips, r.len(), sh).map_err(|why| bad(id, why))?.len();
if follow && let Some(tr) = s.track_mut(*t) {
for l in &mut tr.automation {
let pts: Vec<_> = l.points.iter().filter(|pt| r.contains(pt.at)).copied().collect();
for pt in pts {
@ -677,7 +684,7 @@ fn snap(e: &mut Engine, p: &Value, next: bool) -> Result<Value> {
others.iter().filter(|x| x.end() <= c.start).map(Clip::end).max().map(|en| en - c.start)
};
if let Some(d) = delta.filter(|d| *d != 0) {
moved += ops::move_clips(s, &[id], d, None);
moved += ops::move_clips(s, &[id], d, &[]);
}
}
Ok(json!({"moved": moved}))
@ -1044,6 +1051,22 @@ mod tests {
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn cut_clip_gain_can_be_pasted() {
let (mut e, t, ids) = session_with_clips(&[(0, 1000), (2000, 1000), (4000, 1000)]);
e.execute("clip.gain", &json!({"clip": ids[0].0, "db": -6.0})).unwrap();
e.execute("clip.gain", &json!({"clip": ids[2].0, "db": -12.0})).unwrap();
// An older copy sits in the clipboard: the paste must get the cut gain, not this one.
e.execute("edit.copy_clip_gain", &json!({"start": 4000, "end": 5000})).unwrap();
e.execute("edit.select", &json!({"tracks": [t.0], "clips": [ids[0].0], "start": 0, "end": 1000})).unwrap();
e.execute("edit.cut_clip_gain", &json!({})).unwrap();
let src = e.session().track(t).unwrap().clips()[0].clone();
assert!(clip_db(&src, 500).abs() < 1e-3, "cut resets the source: {src:?}");
e.execute("edit.paste_clip_gain", &json!({"at": 2000})).unwrap();
let c = e.session().track(t).unwrap().clips()[1].clone();
assert!((clip_db(&c, 500) + 6.0).abs() < 1e-3, "{c:?}");
}
#[test]
fn move_to_new_playlist_clears_main() {
let (mut e, t, _) = session_with_clips(&[(0, 1000)]);

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;
@ -152,7 +176,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::clear_range(s, t, r, true);
crate::edit::clear_range(s, t, r, false);
crate::edit::shift_from(s, t, r.end, -r.len());
if let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
l.clear_range(r.start, r.end);
@ -314,7 +339,8 @@ fn edit_marker(e: &mut Engine, p: &Value) -> Result<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 +402,63 @@ mod tests {
let tick = s.tempo.samples_to_ticks(at, s.sample_rate);
assert!((s.tempo.tempo_at_tick(tick) - 133.5).abs() < 1e-9);
}
/// Changing the initial meter keeps a later meter change at its bar (issue #250).
#[test]
fn earlier_meter_change_keeps_later_meter_on_its_bar() {
use soundcraft_time::BarBeat;
let mut e = crate::Engine::default();
assert_eq!(e.session().sample_rate.hz(), 48_000);
e.execute("event.meter", &json!({"numerator": 3, "denominator": 4, "at": 192_000})).unwrap();
e.execute("event.meter", &json!({"numerator": 6, "denominator": 4, "at": 0})).unwrap();
let map = &e.session().tempo;
let later = map.meters().get(1).copied().unwrap();
assert_eq!((later.numerator, later.denominator), (3, 4));
assert_eq!(map.bar_beat_at_tick(later.tick), BarBeat { bar: 3, beat: 1, tick: 0 });
let bar2_beat3 = BarBeat { bar: 2, beat: 3, tick: 0 };
assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(bar2_beat3)), bar2_beat3);
}
fn marker_name(e: &crate::Engine, id: u64) -> String {
e.session().markers.iter().find(|m| m.id.0 == id).map(|m| m.name.clone()).unwrap_or_default()
}
/// `markers.edit` documents `name`: with `id` or `number` selecting the location, it is the new name.
#[test]
fn edit_marker_renames_with_documented_name() {
let mut e = crate::Engine::default();
let verse = e.execute("markers.add", &json!({"name": "Verse", "at": 1000})).unwrap();
let outro = e.execute("markers.add", &json!({"name": "Outro", "at": 3000})).unwrap();
let (verse_id, outro_id) = (verse["id"].as_u64().unwrap(), outro["id"].as_u64().unwrap());
e.execute("markers.edit", &json!({"id": verse_id, "name": "Chorus"})).unwrap();
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Outro".into()));
e.execute("markers.edit", &json!({"number": outro["number"], "name": "Bridge"})).unwrap();
assert_eq!((marker_name(&e, verse_id), marker_name(&e, outro_id)), ("Chorus".into(), "Bridge".into()));
e.execute("markers.edit", &json!({"name": "Bridge", "comments": "last"})).unwrap();
let s = e.session();
let bridge = s.markers.iter().find(|m| m.id.0 == outro_id).unwrap();
assert_eq!((bridge.name.as_str(), bridge.comments.as_str()), ("Bridge", "last"));
}
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
#[test]
fn tempo_constant_is_bounded_by_its_range() {
let mut e = crate::Engine::default();
e.execute("event.tempo", &json!({"bpm": 100.0, "at": 0})).unwrap();
e.execute("event.tempo", &json!({"bpm": 140.0, "at": 48_000})).unwrap();
let s = e.session();
let (t0, t1) = (s.tempo.samples_to_ticks(24_000, s.sample_rate), s.tempo.samples_to_ticks(96_000, s.sample_rate));
let mid = s.tempo.samples_to_ticks(72_000, s.sample_rate);
let after = s.tempo.tempo_at_tick(t1);
e.execute("event.tempo_constant", &json!({"bpm": 90.0, "start": 24_000, "end": 96_000})).unwrap();
let tempo = &e.session().tempo;
assert_eq!(tempo.tempo_at_tick(t0 - 1), 100.0, "before the range");
assert_eq!(tempo.tempo_at_tick(t0), 90.0);
assert_eq!(tempo.tempo_at_tick(mid), 90.0, "the interior change is replaced");
assert_eq!(tempo.tempo_at_tick(t1), after, "the old tempo resumes at the end");
assert_eq!(after, 140.0);
}
}

View File

@ -555,6 +555,10 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option<String> {
Some(name)
}
/// The most bars Extract Chords looks at (55 hours of 4/4 at 120 BPM), and notes sounding in them.
const MAX_CHORD_BARS: usize = 100_000;
const MAX_CHORD_NOTES: usize = 10_000_000;
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.extract_chords";
let (_, t0, t1) = selection_ticks(e, p, id)?;
@ -580,20 +584,36 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
let first_bar = s.tempo.bar_beat_at_tick(t0).bar;
let mut bar = first_bar;
notes.sort_unstable_by_key(|n| n.0);
let mut bar = s.tempo.bar_beat_at_tick(t0).bar;
let mut sounding: Vec<(i64, i64, u8)> = Vec::new();
let (mut next, mut bars, mut heard) = (0, 0, 0);
let mut chords: Vec<Value> = Vec::new();
let mut marks: Vec<(Samples, String)> = Vec::new();
let mut last = String::new();
while chords.len() < 2000 {
while chords.len() < 2000 && bars < MAX_CHORD_BARS && heard < MAX_CHORD_NOTES {
let a = s.tempo.bar_start_tick(bar).max(t0);
let b = s.tempo.bar_start_tick(bar + 1).min(t1);
if a >= t1 {
break;
}
while let Some(&n) = notes.get(next).filter(|n| n.0 < b) {
sounding.push(n);
next += 1;
}
sounding.retain(|n| n.1 > a);
if sounding.is_empty() {
match notes.get(next) {
Some(n) => bar = (bar + 1).max(s.tempo.bar_beat_at_tick(n.0).bar),
None => break,
}
continue;
}
bars += 1;
heard += sounding.len();
let mut w = [0.0f64; 12];
let mut bass: Option<u8> = None;
for (na, nb, pitch) in &notes {
for (na, nb, pitch) in &sounding {
let ov = (*nb).min(b) - (*na).max(a);
if ov > 0 {
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
@ -901,6 +921,18 @@ mod tests {
assert!(e.session().markers.iter().any(|m| m.ruler == 2));
}
#[test]
fn chords_far_down_the_timeline_are_found_without_walking_every_bar() {
let mut e = crate::demo::demo_engine();
let keys = e.session().track_by_name("Keys").unwrap().id;
let far = 1i64 << 39;
if let Some(p) = e.session_mut().track_mut(keys).and_then(soundcraft_model::Track::playlist_mut) {
p.clips.iter_mut().for_each(|c| c.start += far);
}
let r = e.execute("event.extract_chords", &json!({"tracks": [keys.0], "start": 0, "end": far + 48_000 * 600})).unwrap();
assert!(!r["chords"].as_array().unwrap().is_empty(), "{r}");
}
#[test]
fn beat_detective_finds_demo_tempo() {
let mut e = crate::demo::demo_engine();

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);
@ -275,6 +281,21 @@ fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
}
fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let job = bounce_job(e, p)?;
let (path, seconds) = (job.path().to_string_lossy().into_owned(), e.session().sample_rate.seconds(job.range().len()));
let done = job.run()?;
Ok(json!({
"path": path,
"bytes": done.bytes,
"seconds": seconds,
"peak_db": done.peak_db,
"lufs": done.lufs,
"true_peak_db": done.true_peak_db,
}))
}
/// The bounce `file.bounce_mix` runs for `p`, not rendered yet.
pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
@ -292,11 +313,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let normalize = bool_or(p, "normalize", false);
let fold = fold_down_param(p, "file.bounce_mix")?;
let bus = bounce_bus(e, p)?;
let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
crate::io::write_file(&path, &bytes)?;
Ok(
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
)
crate::io::Bounce::new(e.session_arc(), std::path::Path::new(&path), r, &opts, bus, normalize, fold)
}
/// Write the score of the chosen MIDI tracks as MusicXML (or SVG when `svg`).

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,
@ -26,10 +26,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
}
fn seq_mut<'a>(e: &'a mut Engine, id: ClipId, cmd: &str) -> Result<&'a mut Sequence> {
match e.session_mut().find_clip_mut(id).map(|c| &mut c.content) {
Some(ClipContent::Midi { sequence }) => Ok(sequence),

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

@ -35,6 +35,11 @@ mod video;
mod view;
mod view_more;
mod vst3;
mod zoom_toggle;
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
@ -128,6 +133,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(instrument::specs());
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
v
})
}
@ -238,9 +244,7 @@ pub fn tracks_required(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>>
if t.is_empty() { Err(bad(cmd, "no tracks: pass `track`/`tracks` or select tracks")) } else { Ok(t) }
}
/// Largest position accepted from parameters: 2^40 samples (about 265 days at 48 kHz). Clamping
/// here keeps every later addition and subtraction on positions far away from overflow.
pub const MAX_POSITION: Samples = 1 << 40;
pub use soundcraft_time::MAX_POSITION;
/// Position from `key`: a number of samples, or `{"seconds": x}`, or a string in the main counter format.
/// Values are clamped to ±[`MAX_POSITION`].
@ -275,7 +279,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<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));
}
}
@ -297,6 +301,10 @@ pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
}
}
pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| bad(cmd, "`clip` id required"))
}
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();

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

@ -1,6 +1,6 @@
//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
//! state from the audio engine right before saving and stores it on its insert (base64), from
//! where the mixer restores it into new instances.
//! state right before saving and stores it on its insert (base64), from where the mixer restores
//! it into new instances.
use super::*;
use serde_json::json;

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,16 +55,15 @@ 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}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
}
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);

View File

@ -5,10 +5,9 @@ use super::more_util::{set_text, store_value, text, toggle_flag, values_with_pre
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode;
use std::path::{Path, PathBuf};
use std::sync::Arc;
macro_rules! option {
($id:literal, $label:literal, $flag:literal) => {
@ -147,7 +146,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<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 +319,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}))
}
@ -401,22 +402,10 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"enabled": enabled}))
}
/// Decode a source file of another session (paths relative to that session's folder).
fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<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 (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?;
if buf.sample_rate != sr && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
buf.sample_rate = sr;
}
Some(buf)
}
fn import_session_data(e: &mut Engine, p: &Value) -> Result<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>> =
@ -441,31 +430,29 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
needed.sort_by_key(|x| x.0);
needed.dedup();
let sr = e.session().sample_rate.hz();
let decoded: Vec<(Source, Option<soundcraft_audio_io::AudioBuffer>)> =
needed.iter().filter_map(|sid| other.source(*sid).cloned()).map(|src| (src.clone(), load_source(&dir, &src, sr))).collect();
let s = e.session_mut();
let mut files = crate::io::DecodedFiles::new();
let mut map: std::collections::BTreeMap<u64, SourceId> = Default::default();
let mut missing = Vec::new();
for (src, buf) in decoded {
let nid = SourceId(s.alloc());
for src in needed.iter().filter_map(|sid| other.source(*sid)) {
let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
let mut ns = src.clone();
ns.id = nid;
ns.id = SourceId(s.alloc());
ns.path = abs.to_string_lossy().into_owned();
match buf {
Some(b) => {
ns.frames = b.frames() as u64;
match crate::io::decode_file(&mut files, &abs, sr) {
Ok(audio) => {
ns.frames = audio.buffer.frames() as u64;
ns.sample_rate = sr;
ns.unsaved = true;
s.pool.insert(nid, Arc::new(SourceAudio::new(b)));
s.pool.insert(ns.id, audio);
}
None => {
Err(_) => {
ns.unsaved = false;
missing.push(ns.path.clone());
}
}
map.insert(src.id.0, ns.id);
s.sources.push(ns);
map.insert(src.id.0, nid);
}
let mut out = Vec::new();
for mut tr in picked {
@ -556,8 +543,7 @@ mod tests {
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap();
let s = e.session();
let shown =
soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode);
assert_eq!(shown, "01:00:00:00");
}

View File

@ -60,7 +60,21 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.rename", "Rename Track", [], None, "{track, name}", has_tracks, rename),
cmd!("track.comments", "Track Comments", [], None, "{track, comments}", has_tracks, comments),
cmd!("track.color", "Track Color", [], None, "{tracks?, color: [r,g,b] | palette index}", has_selection, color),
cmd!("track.height", "Track Height", [], None, "{tracks?, height: micro|mini|small|medium|large|jumbo|extreme}", has_selection, height),
cmd!(
"track.height",
"Track Height",
[],
None,
"{tracks?, height: micro|mini|small|medium|large|jumbo|extreme | points}",
has_selection,
height
),
cmd!("track.height_up", "Increase Track Height", [], Some("Ctrl+Up"), "{tracks?} — one preset taller", has_selection, |e, p| height_step(
e, p, true
)),
cmd!("track.height_down", "Decrease Track Height", [], Some("Ctrl+Down"), "{tracks?} — one preset shorter", has_selection, |e, p| {
height_step(e, p, false)
}),
cmd!("track.view", "Track View", [], None, "{tracks?, view: waveform|blocks|notes|volume|pan|mute|<automation id>}", has_selection, view),
cmd!("track.hide", "Hide Track", [], None, "{tracks?, hidden?: bool}", has_selection, |e, p| flag(
e,
@ -100,7 +114,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)),
@ -130,7 +144,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)
{
@ -176,7 +193,7 @@ pub fn specs() -> Vec<CommandSpec> {
tr.active_playlist = active;
}
let _ = src;
let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false);
let ids = crate::edit::paste(s, t, r.start, &clips, r.len(), false).unwrap_or_default();
Ok(json!({"clips": ids}))
}
),
@ -647,7 +664,10 @@ fn color(e: &mut Engine, p: &Value) -> Result<Value> {
fn height(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.height", p)?;
let h = str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?;
let h = match p.get("height").and_then(Value::as_f64) {
Some(points) => TrackHeight::from_points(points as f32),
None => str_param(p, "height").and_then(TrackHeight::from_id).ok_or_else(|| bad("track.height", "unknown height"))?,
};
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
@ -657,6 +677,26 @@ fn height(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"height": h}))
}
fn height_step(e: &mut Engine, p: &Value, taller: bool) -> Result<Value> {
let tracks = tracks_required(e, if taller { "track.height_up" } else { "track.height_down" }, p)?;
let mut steps = Vec::new();
for tr in tracks.iter().filter_map(|t| e.session().track(*t)) {
let h = tr.height.step(taller);
if h != tr.height {
steps.push((tr.id, h));
}
}
if !steps.is_empty() {
let s = e.session_mut();
for (t, h) in &steps {
if let Some(tr) = s.track_mut(*t) {
tr.height = *h;
}
}
}
Ok(json!({"changed": steps.len()}))
}
fn view(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.view", p)?;
let v = str_param(p, "view").ok_or_else(|| bad("track.view", "`view` required"))?.to_string();
@ -907,4 +947,40 @@ mod tests {
assert_eq!(kick.folder, None);
assert_eq!(kick.mixer.output, Route::Main);
}
#[test]
fn heights_take_points_and_step_through_the_presets() {
let mut e = crate::demo::demo_engine();
let height = |e: &Engine, name: &str| e.session().track_by_name(name).map(|t| t.height);
e.execute("track.height", &json!({"tracks": ["Kick"], "height": 57})).unwrap();
assert_eq!(height(&e, "Kick"), Some(TrackHeight::Custom(57)));
e.execute("track.height_up", &json!({"tracks": ["Kick", "Snare"]})).unwrap();
assert_eq!((height(&e, "Kick"), height(&e, "Snare")), (Some(TrackHeight::Medium), Some(TrackHeight::Large)));
e.execute("track.height", &json!({"tracks": ["Kick"], "height": "extreme"})).unwrap();
let steps = e.undo_history().len();
assert_eq!(e.execute("track.height_up", &json!({"tracks": ["Kick"]})).unwrap()["changed"], 0);
assert_eq!(e.undo_history().len(), steps);
e.execute("track.height_down", &json!({"tracks": ["Kick"]})).unwrap();
assert_eq!(height(&e, "Kick"), Some(TrackHeight::Jumbo));
}
#[test]
fn vca_null_clears_the_assignment() {
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Member"})).unwrap();
e.execute("track.new", &json!({"kind": "vca", "name": "Master"})).unwrap();
let (member, master) = (e.session().tracks[0].id, e.session().tracks[1].id);
let vca = |e: &Engine| e.session().track(member).unwrap().mixer.vca;
e.execute("track.vca", &json!({"tracks": [member.0], "vca": master.0})).unwrap();
assert_eq!(vca(&e), Some(master.0));
// #237: null is documented as "no VCA"; anything else that is not a VCA is still an error.
for invalid in [json!(true), json!(999), json!("Missing"), json!(member.0)] {
assert!(e.execute("track.vca", &json!({"tracks": [member.0], "vca": invalid})).is_err());
assert_eq!(vca(&e), Some(master.0));
}
e.execute("track.vca", &json!({"tracks": [member.0], "vca": null})).unwrap();
assert_eq!(vca(&e), None);
assert!(e.undo());
assert_eq!(vca(&e), Some(master.0));
}
}

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

@ -0,0 +1,88 @@
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{TrackHeight, ZoomToggle};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
}
fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
if let Some(back) = s.edit.zoom_toggle.take() {
s.edit.zoom.samples_per_px = back.samples_per_px.clamp(0.25, 1_000_000.0);
s.edit.zoom.scroll = back.scroll.clamp(0, MAX_POSITION);
for t in &mut s.tracks {
t.height = back.heights.iter().find(|(id, _)| *id == t.id).map_or(t.height, |(_, h)| *h);
}
return Ok(json!({"on": false}));
}
let heights: Vec<_> = s.tracks.iter().filter(|t| !t.hidden && s.edit.selected_tracks.contains(&t.id)).map(|t| (t.id, t.height)).collect();
// Rows sit 1 point apart.
let each = f64_or(p, "height_px", 600.0) / heights.len().max(1) as f64 - 1.0;
let fit = TrackHeight::Fit(each.clamp(16.0, f64::from(u16::MAX)) as u16);
for t in s.tracks.iter_mut().filter(|t| heights.iter().any(|(id, _)| *id == t.id)) {
t.height = fit;
}
let (r, z) = (s.edit.selection, &mut s.edit.zoom);
s.edit.zoom_toggle = Some(ZoomToggle { samples_per_px: z.samples_per_px, scroll: z.scroll, heights });
if !r.is_empty() {
z.samples_per_px = (r.len() as f64 / f64_or(p, "width_px", 1200.0).max(100.0)).clamp(0.25, 1_000_000.0);
z.scroll = r.start;
}
Ok(json!({"on": true}))
}
#[cfg(test)]
mod tests {
use super::*;
use TrackHeight::{Fit, Large, Medium};
fn engine(select: Value) -> Engine {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 3})).unwrap();
e.execute("view.zoom_set", &json!({"samples_per_px": 500.0})).unwrap();
e.execute("view.scroll", &json!({"to": 1000})).unwrap();
e.execute("edit.select", &select).unwrap();
e
}
fn view(e: &Engine) -> (f64, Samples, Vec<TrackHeight>) {
let s = e.session();
(s.edit.zoom.samples_per_px, s.edit.zoom.scroll, s.tracks.iter().map(|t| t.height).collect())
}
#[test]
fn the_selection_fills_the_window_and_toggling_again_goes_back() {
let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000}));
let steps = e.undo_history().len();
assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true}));
assert_eq!(view(&e), (48.0, 48_000, vec![Fit(249), Medium, Fit(249)]));
e.execute("view.zoom_in", &json!({})).unwrap();
e.execute("track.height", &json!({"tracks": ["Audio 2"], "height": "large"})).unwrap();
assert_eq!(e.execute("view.zoom_toggle", &json!({})).unwrap(), json!({"on": false}));
assert_eq!(view(&e), (500.0, 1000, vec![Medium, Large, Medium]));
assert_eq!(e.undo_history().len(), steps + 1);
e.execute("edit.select", &json!({"tracks": ["Audio 2"], "start": 5000})).unwrap();
e.execute("view.zoom_toggle", &json!({"height_px": 300.0})).unwrap();
assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium]));
}
#[test]
fn hostile_sizes_and_stored_views_stay_in_range() {
let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));
e.execute("view.zoom_toggle", &json!({"width_px": -5.0, "height_px": 1e300})).unwrap();
assert_eq!((view(&e).0, view(&e).2[0]), (1_000_000.0, Fit(u16::MAX)));
e.execute("view.zoom_toggle", &json!({})).unwrap();
e.execute("view.zoom_toggle", &json!({"width_px": "wide", "height_px": -1.0})).unwrap();
assert_eq!(view(&e).2[0], Fit(16));
let audio_1 = e.session().tracks[0].id;
let heights = vec![(TrackId(9999), TrackHeight::Jumbo), (audio_1, Fit(0))];
e.session_mut().edit.zoom_toggle = Some(ZoomToggle { samples_per_px: -1.0, scroll: Samples::MIN, heights });
e.execute("view.zoom_toggle", &json!({})).unwrap();
assert_eq!(view(&e), (0.25, 0, vec![Fit(0), Medium, Medium]));
assert_eq!(Fit(0).points(), 16.0);
}
}

View File

@ -228,7 +228,7 @@ pub fn demo_session() -> Session {
let len = total as i64 - start;
let cid = s.new_clip_id();
let mut clip = Clip::audio(cid, name, src, start, start, len - (srf * 1.5) as i64);
clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve };
clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve, curvature: 0.0 };
crate::edit::place_clip(s, t, clip);
t
};

View File

@ -5,8 +5,16 @@
//! Placing a clip overwrites whatever is under it (like the incumbent's Slip mode); Shuffle
//! mode also ripples later clips.
use soundcraft_model::{Clip, ClipContent, ClipId, Fade, FadeShape, Session, TrackId};
use soundcraft_model::{AutoParam, AutomationPoint, Clip, ClipContent, ClipId, Fade, FadeShape, Session, Track, TrackId};
use soundcraft_time::{Range, SampleRate, Samples, TempoMap};
use std::collections::{HashMap, HashSet};
/// Edit-state flag: the Edit cursor and selections snap to the grid in Shuffle, Slip and Spot.
pub const SNAP_TO_GRID: &str = "edit.snap_to_grid";
/// Edit-state flag: Shuffle mode can't be chosen.
pub const SHUFFLE_LOCK: &str = "edit.shuffle_lock";
/// Edit-state flag: the Grid mode last used was Relative, so choosing Grid again picks it.
pub const RELATIVE_GRID: &str = "edit.relative_grid";
/// Context needed to split MIDI clips in musical time.
#[derive(Clone, Copy)]
@ -101,8 +109,9 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
count
}
/// Remove material inside `range` on a track. In shuffle mode later clips move left.
/// Returns the removed material as clips positioned relative to `range.start`.
/// Remove material inside `range` on a track. In Shuffle mode later clips move left, up to the
/// first locked clip (see [`ripple`]). Returns the removed material as clips positioned relative
/// to `range.start`.
pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool) -> Vec<Clip> {
if range.is_empty() {
return Vec::new();
@ -122,14 +131,9 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool)
}
});
if shuffle {
let len = range.len();
for c in &mut pl.clips {
if c.start >= range.end {
c.start -= len;
}
}
let moved = ripple(s, track, range.end, -range.len());
follow_automation(s, track, range.end, moved);
}
pl.sort();
removed
}
@ -155,20 +159,42 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
out
}
/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless
/// `shuffle`, which first pushes later material right by `length`.
pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Vec<ClipId> {
let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0));
if shuffle {
separate_at(s, track, at);
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
for c in &mut pl.clips {
if c.start >= at {
c.start += length;
}
/// Merge the MIDI inside `range` into one clip covering the whole range, gaps included, as it
/// sounds (muted clips stay silent). Returns false for tracks that aren't MIDI or instrument tracks.
pub fn consolidate_midi(s: &mut Session, track: TrackId, range: Range) -> bool {
let Some(name) = s.track(track).filter(|t| t.kind.is_midi()).map(|t| t.name.clone()) else { return false };
let mut merged = soundcraft_midi::Sequence::default();
{
let t = TimeCtx::of(s);
let base = t.ticks(range.start);
for part in copy_range(s, track, range).into_iter().filter(|c| !c.muted) {
let at = t.ticks(range.start.saturating_add(part.start)).saturating_sub(base);
let len = t.ticks(range.start.saturating_add(part.end())).saturating_sub(base).saturating_sub(at);
let ClipContent::Midi { sequence } = part.content else { continue };
for mut n in sequence.notes.into_iter().filter(|n| (0..len).contains(&n.start)) {
n.length = n.length.min(len - n.start);
n.start += at;
merged.notes.push(n);
}
for mut c in sequence.ctrls.into_iter().filter(|c| (0..len).contains(&c.tick)) {
c.tick += at;
merged.ctrls.push(c);
}
}
}
merged.sort();
let id = s.new_clip_id();
place_clip(s, track, Clip::midi(id, name, range.start, range.len(), merged));
true
}
/// Insert `clips` (positions relative to 0) at `at`. Overwrites what's underneath unless
/// `shuffle`, which first makes room for `length` (see [`make_room`], whose refusal it returns).
pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], length: Samples, shuffle: bool) -> Result<Vec<ClipId>, &'static str> {
let length = length.max(clips.iter().map(Clip::end).max().unwrap_or(0));
if shuffle {
make_room(s, track, at, length)?;
}
let mut ids = Vec::new();
for c in clips {
let mut nc = c.clone();
@ -177,7 +203,7 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt
ids.push(nc.id);
place_clip(s, track, nc);
}
ids
Ok(ids)
}
/// Place one clip, overwriting whatever it covers.
@ -189,21 +215,146 @@ pub fn place_clip(s: &mut Session, track: TrackId, clip: Clip) {
}
}
/// Insert silence: shift material at/after `range.start` right by the range length.
/// Insert silence: shift material at/after `range.start` right by the range length (Insert Time;
/// Shuffle mode edits use [`make_room`]).
pub fn insert_silence(s: &mut Session, track: TrackId, range: Range) {
if range.is_empty() {
return;
}
separate_at(s, track, range.start);
shift_from(s, track, range.start, range.len());
}
/// Move every clip that starts at or after `from` by `by`, locked or not.
pub fn shift_from(s: &mut Session, track: TrackId, from: Samples, by: Samples) {
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
for c in &mut pl.clips {
if c.start >= range.start {
c.start += range.len();
}
for c in pl.clips.iter_mut().filter(|c| c.start >= from) {
c.start = c.start.saturating_add(by);
}
}
}
/// Time- or edit-locked: the clip doesn't move, and Shuffle stops at it.
pub fn locked(c: &Clip) -> bool {
c.time_locked || c.edit_locked
}
/// A clip's place on its track, for working out Shuffle layouts without touching the clips.
#[derive(Clone, Copy)]
struct Span {
id: ClipId,
start: Samples,
end: Samples,
locked: bool,
}
fn spans(clips: &[Clip]) -> Vec<Span> {
clips.iter().map(|c| Span { id: c.id, start: c.start, end: c.end(), locked: locked(c) }).collect()
}
/// Shuffle mode's ripple: the spans from `from` on move by `by`, up to the first locked one, which
/// stays put with everything after it. A move left stops at the session start, keeping their
/// spacing. Returns how far they moved and where the lock is (`Samples::MAX` when none).
fn ripple_spans(spans: &mut [Span], from: Samples, by: Samples) -> (Samples, Samples) {
let lock = spans.iter().filter(|c| c.locked && c.start >= from).map(|c| c.start).min().unwrap_or(Samples::MAX);
let moves = |c: &Span| c.start >= from && c.start < lock;
let floor = spans.iter().filter(|c| moves(c)).map(|c| c.start).min().unwrap_or(0).max(0);
let by = by.max(-floor);
for c in spans.iter_mut().filter(|c| moves(c)) {
c.start = c.start.saturating_add(by);
c.end = c.end.saturating_add(by);
}
(by, lock)
}
/// [`ripple_spans`] on a track's clips.
fn ripple(s: &mut Session, track: TrackId, from: Samples, by: Samples) -> (Samples, Samples) {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return (0, Samples::MAX) };
let mut sp = spans(&pl.clips);
let moved = ripple_spans(&mut sp, from, by);
for (c, n) in pl.clips.iter_mut().zip(&sp) {
c.start = n.start;
}
pl.sort();
moved
}
/// Automation Follows Edit for a Shuffle ripple (`moved` from [`ripple`]): the points from `from`
/// up to the lock move with the clips, and when material closes up the points in the span that
/// goes away go too.
fn follow_automation(s: &mut Session, track: TrackId, from: Samples, (by, lock): (Samples, Samples)) {
if !s.edit.automation_follows_edit || by == 0 {
return;
}
let Some(t) = s.track_mut(track) else { return };
for l in &mut t.automation {
if by < 0 {
l.clear_range(from.saturating_add(by), from);
}
for p in l.points.iter_mut().filter(|p| p.at >= from && p.at < lock) {
p.at = p.at.saturating_add(by);
}
l.points.sort_by_key(|p| p.at);
l.points.dedup_by_key(|p| p.at);
}
}
/// With Automation Follows Edit, take the points in `[start, end)` off a track, timed from `origin`.
fn take_automation(s: &mut Session, track: TrackId, start: Samples, end: Samples, origin: Samples) -> Vec<(AutoParam, AutomationPoint)> {
let mut out = Vec::new();
if !s.edit.automation_follows_edit || end <= start {
return out;
}
let Some(t) = s.track_mut(track) else { return out };
for l in &mut t.automation {
let taken = l.points.iter().filter(|p| p.at >= start && p.at < end);
out.extend(taken.map(|p| (l.param.clone(), AutomationPoint { at: p.at.saturating_sub(origin), value: p.value })));
l.clear_range(start, end);
}
out
}
fn put_automation(s: &mut Session, track: TrackId, origin: Samples, points: &[(AutoParam, AutomationPoint)]) {
let Some(t) = s.track_mut(track) else { return };
for (param, p) in points {
t.lane_mut(param).set_point(origin.saturating_add(p.at), p.value);
}
}
/// How far Shuffle mode can push the material from `at` on before it runs into a locked clip
/// (`Samples::MAX` when none follows).
pub fn shuffle_room(s: &Session, track: TrackId, at: Samples) -> Samples {
let clips = s.track(track).map(|t| t.clips()).unwrap_or_default();
let Some(lock) = clips.iter().filter(|c| locked(c) && c.end() > at).map(|c| c.start).min() else { return Samples::MAX };
let end = clips.iter().filter(|c| c.end() > at && c.start < lock).map(Clip::end).fold(at, Samples::max);
lock.saturating_sub(end)
}
/// Why Shuffle can't make room: a locked clip follows too closely.
pub const NO_ROOM: &str = "not enough room before a locked clip";
/// Why Shuffle can't make room: the point is inside a crossfade, which can't be pulled apart.
pub const IN_CROSSFADE: &str = "can't make room inside a crossfade";
/// Shuffle mode's room for `len` at `at`: later clips (and with Automation Follows Edit their
/// automation) move right. Refused, with nothing changed, when a locked clip is in the way or
/// `at` is inside a crossfade.
pub fn make_room(s: &mut Session, track: TrackId, at: Samples, len: Samples) -> Result<(), &'static str> {
if len <= 0 {
return Ok(());
}
let clips = s.track(track).map(|t| t.clips()).unwrap_or_default();
if clips.iter().filter(|c| c.start <= at && at < c.end()).count() > 1 {
return Err(IN_CROSSFADE);
}
if shuffle_room(s, track, at) < len {
return Err(NO_ROOM);
}
separate_at(s, track, at);
let moved = ripple(s, track, at, len);
follow_automation(s, track, at, moved);
Ok(())
}
/// Trim clips to keep only material inside `range`.
pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
let tempo = s.tempo.clone();
@ -221,6 +372,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
n += 1;
}
}
pl.sort();
n
}
@ -279,14 +431,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
healed
}
/// Move clips by `delta` (and optionally to another track), overwriting what they land on.
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Option<TrackId>) -> usize {
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
/// elsewhere (see [`destinations`]).
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
for t in &mut s.tracks {
let tid = t.id;
if let Some(pl) = t.playlist_mut() {
pl.clips.retain(|c| {
if ids.contains(&c.id) && !c.time_locked && !c.edit_locked {
if ids.contains(&c.id) && !locked(c) {
moving.push((tid, c.clone()));
false
} else {
@ -296,13 +450,311 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, to_track: Opt
}
}
let n = moving.len();
for (tid, mut c) in moving {
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta).max(0);
place_clip(s, to_track.unwrap_or(tid), c);
}
(lands(dest, tid), c)
});
put_clips(s, placed.collect());
n
}
/// Place moved clips, overwriting what they cover but not each other, so a crossfaded pair
/// moved together stays a crossfade.
fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
for (t, c) in &placed {
clear_range(s, *t, c.range(), false);
}
for (t, c) in placed {
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.push(c);
pl.sort();
}
}
}
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
pub fn shown(s: &Session, t: &Track) -> bool {
!t.hidden && !folder_collapsed(s, t)
}
/// Hidden because a folder at any level above this track is closed.
fn folder_collapsed(s: &Session, track: &Track) -> bool {
let mut current = track.folder;
for _ in 0..64 {
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
if !folder.folder_open {
return true;
}
current = folder.folder;
}
false
}
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
/// from it among the tracks the Edit window shows, or among all tracks when a hidden one is
/// involved. None when one would land past either end, or on a track that can't hold its clips.
pub fn destinations(s: &Session, ids: &[ClipId], from: TrackId, to: TrackId) -> Option<Vec<(TrackId, TrackId)>> {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let holds = |t: &Track| t.clips().iter().any(|c| ids.contains(&c.id));
let visible = s.tracks.iter().filter(|t| t.id == from || t.id == to || holds(t)).all(|t| shown(s, t));
let rows: Vec<&Track> = s.tracks.iter().filter(|t| !visible || shown(s, t)).collect();
let row = |id: TrackId| rows.iter().position(|t| t.id == id).and_then(|i| isize::try_from(i).ok());
let shift = row(to)? - row(from)?;
s.tracks
.iter()
.filter(|t| holds(t))
.map(|t| {
let d = rows.get(usize::try_from(row(t.id)? + shift).ok()?)?;
(d.kind.has_playlist() && (d.kind == t.kind || d.kind.is_midi() && t.kind.is_midi())).then_some((t.id, d.id))
})
.collect()
}
/// The track that clips on `t` land on under a [`destinations`] map.
pub fn lands(dest: &[(TrackId, TrackId)], t: TrackId) -> TrackId {
dest.iter().find(|(from, _)| *from == t).map_or(t, |(_, d)| *d)
}
/// The earliest of clips `ids` that can move (neither time- nor edit-locked).
pub fn first_movable<'a>(s: &'a Session, ids: &[ClipId]) -> Option<&'a Clip> {
ids.iter().filter_map(|id| s.find_clip(*id)).map(|(_, c)| c).filter(|c| !locked(c)).min_by_key(|c| c.start)
}
/// One Shuffle lift: where the hole the clip leaves starts and how long it is, how far the clips
/// after it actually close up (none past a lock, none before the session start) and where the
/// first locked clip after it is.
#[derive(Clone, Copy)]
struct Lift {
id: ClipId,
start: Samples,
from: Samples,
shift: Samples,
by: Samples,
lock: Samples,
}
/// Lifting the movable `moving` clips off a track's `spans` the Shuffle way, right to left, in
/// one pass: the lifts in that order, and the spans that stay at the starts they close up to.
/// A hole starts where the clips before it end (a crossfade partner keeps its material) and
/// closes up to the next clip, so silence after the lifted clip stays. Clips are ordered by
/// start, then id, so of two stacked clips the later one counts as after the other.
fn lift_plan(mut sp: Vec<Span>, moving: &HashSet<ClipId>) -> (Vec<Lift>, Vec<Span>) {
sp.sort_by_key(|c| (c.start, c.id));
let lifted = |c: &Span| !c.locked && moving.contains(&c.id);
let reach: Vec<Samples> = std::iter::once(Samples::MIN)
.chain(sp.iter().scan(Samples::MIN, |far, c| {
*far = (*far).max(c.end);
Some(*far)
}))
.collect();
let mut lifts = Vec::new();
let (mut next, mut next_moves, mut lock) = (None, false, Samples::MAX);
for (i, c) in sp.iter().enumerate().rev() {
if !lifted(c) {
(next, next_moves) = (Some(c.start), !c.locked);
if c.locked {
lock = c.start;
}
continue;
}
let from = reach.get(i).map_or(c.start, |far| c.start.max(*far));
let shift = next.unwrap_or(c.end).min(c.end).saturating_sub(from).max(0);
let by = if next_moves { -shift.min(next.unwrap_or(0).max(0)) } else { 0 };
next = next.map(|n| n.saturating_add(by));
lifts.push(Lift { id: c.id, start: c.start, from, shift, by, lock });
}
let (mut by, mut pending) = (0, lifts.iter().rev());
let mut staying = Vec::with_capacity(sp.len() - lifts.len());
for c in sp {
if lifted(&c) {
by += pending.next().map_or(0, |l| l.by);
} else if c.locked {
by = 0;
staying.push(c);
} else {
staying.push(Span { start: c.start.saturating_add(by), end: c.end.saturating_add(by), ..c });
}
}
(lifts, staying)
}
/// Where Shuffle can land material on `track` once the movable `moving` clips have left it (later
/// clips close each gap): the clip boundaries, never inside a clip or a crossfade, sorted. Empty
/// for an empty track.
pub fn shuffle_slots(s: &Session, track: TrackId, moving: &[ClipId]) -> Vec<Samples> {
let clips = s.track(track).map(|t| t.clips()).unwrap_or_default();
let (_, mut sp) = lift_plan(spans(clips), &moving.iter().copied().collect());
sp.sort_by_key(|c| c.start);
let reach: Vec<Samples> = sp
.iter()
.scan(Samples::MIN, |far, c| {
*far = (*far).max(c.end);
Some(*far)
})
.collect();
let inside = |b: Samples| reach.get(sp.partition_point(|c| c.start < b).wrapping_sub(1)).is_some_and(|far| *far > b);
let mut slots: Vec<Samples> = sp.iter().flat_map(|c| [c.start, c.end]).filter(|b| !inside(*b)).collect();
slots.sort_unstable();
slots.dedup();
slots
}
/// The slot nearest `at` (the earlier one on a tie), or the session start when there are none.
pub fn nearest_slot(slots: &[Samples], at: Samples) -> Samples {
let i = slots.partition_point(|b| *b < at);
[i.checked_sub(1), Some(i)].into_iter().flatten().filter_map(|i| slots.get(i)).copied().min_by_key(|b| b.abs_diff(at)).unwrap_or(0)
}
/// Where Shuffle mode lands material dropped at `at` on `track` (see [`shuffle_slots`]).
pub fn shuffle_slot(s: &Session, track: TrackId, at: Samples, moving: &[ClipId]) -> Samples {
nearest_slot(&shuffle_slots(s, track, moving), at)
}
/// Lift the movable `moving` clips off a track the Shuffle way (see [`lift_plan`]); with
/// Automation Follows Edit their automation leaves with them. Returns the clips, each with that
/// automation timed from its start.
fn lift_all(s: &mut Session, track: TrackId, moving: &HashSet<ClipId>) -> Vec<(Clip, Vec<(AutoParam, AutomationPoint)>)> {
let Some(clips) = s.track(track).map(|t| spans(t.clips())) else { return Vec::new() };
let (lifts, staying) = lift_plan(clips, moving);
let mut points: HashMap<ClipId, Vec<(AutoParam, AutomationPoint)>> = HashMap::new();
for l in &lifts {
points.insert(l.id, take_automation(s, track, l.from, l.from.saturating_add(l.shift), l.start));
follow_automation(s, track, l.from.saturating_add(l.shift), (l.by, l.lock));
}
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return Vec::new() };
let starts: HashMap<ClipId, Samples> = staying.iter().map(|c| (c.id, c.start)).collect();
let (out, kept): (Vec<Clip>, Vec<Clip>) = std::mem::take(&mut pl.clips).into_iter().partition(|c| points.contains_key(&c.id));
pl.clips = kept;
for c in &mut pl.clips {
if let Some(start) = starts.get(&c.id) {
c.start = *start;
}
}
pl.sort();
out.into_iter()
.map(|c| {
let p = points.remove(&c.id).unwrap_or_default();
(c, p)
})
.collect()
}
/// The movable clips `ids` grouped by the track they land on (per `dest`): each destination and
/// the start of its earliest clip, which Shuffle lands on a slot; the others keep their distance.
pub fn shuffle_groups(s: &Session, ids: &[ClipId], dest: &[(TrackId, TrackId)]) -> Vec<(TrackId, Samples)> {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let mut groups: Vec<(TrackId, Samples)> = Vec::new();
for t in &s.tracks {
for c in t.clips().iter().filter(|c| ids.contains(&c.id) && !locked(c)) {
let d = lands(dest, t.id);
match groups.iter_mut().find(|g| g.0 == d) {
Some(g) => g.1 = g.1.min(c.start),
None => groups.push((d, c.start)),
}
}
}
groups
}
/// Move clips in Shuffle mode: each leaves its track (later clips close the gap, see
/// [`lift_plan`]), and the clips bound for each track land together, the earliest on the
/// [`shuffle_slot`] nearest `delta` away, pushing later clips right. With Automation Follows Edit
/// their automation travels with them. Refused, with the reason, when a locked clip or a
/// crossfade leaves no room.
pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> Result<usize, &'static str> {
let groups: Vec<(TrackId, Samples, Samples)> =
shuffle_groups(s, ids, dest).into_iter().map(|(d, first)| (d, first, shuffle_slot(s, d, first.saturating_add(delta), ids))).collect();
let moving: HashSet<ClipId> = ids.iter().copied().collect();
let sources: Vec<TrackId> = s.tracks.iter().filter(|t| t.clips().iter().any(|c| moving.contains(&c.id) && !locked(c))).map(|t| t.id).collect();
let mut lifted = Vec::new();
for t in sources {
lifted.extend(lift_all(s, t, &moving).into_iter().map(|(c, points)| (lands(dest, t), c, points)));
}
for (d, first, at) in groups {
let block: Vec<&(TrackId, Clip, Vec<(AutoParam, AutomationPoint)>)> = lifted.iter().filter(|l| l.0 == d).collect();
let len = block.iter().map(|l| l.1.end()).max().unwrap_or(first).saturating_sub(first);
make_room(s, d, at, len)?;
let mut placed = Vec::new();
for (_, c, points) in block {
let mut c = c.clone();
c.start = at.saturating_add(c.start.saturating_sub(first));
put_automation(s, d, c.start, points);
placed.push(c);
}
if let Some(pl) = s.track_mut(d).and_then(|t| t.playlist_mut()) {
pl.clips.extend(placed);
pl.sort();
}
}
Ok(lifted.len())
}
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
/// grid steps, keeping its offset from the grid.
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
let anchor = clip.start.saturating_add(sync);
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
}
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
/// Shuffle mode the start stays put (the material slides under it) and later clips follow the
/// change, up to a locked clip; otherwise a trim stops at the neighbouring clips.
pub fn trim_clip(s: &mut Session, id: ClipId, start: Option<Samples>, end: Option<Samples>, shuffle: bool) -> Option<Clip> {
let (track, old) = s.find_clip(id).filter(|(_, c)| !c.edit_locked).map(|(t, c)| (t, c.clone()))?;
let (mut lo, mut hi) = (Samples::MIN, Samples::MAX);
if shuffle {
let room = shuffle_room(s, track, old.end()).max(0);
lo = old.start.saturating_sub(room);
hi = old.end().saturating_add(room);
} else {
let others = s.track(track).map(|t| t.clips()).unwrap_or_default().iter().filter(|c| c.id != id);
if let Some(prev) = others.clone().filter(|c| c.start < old.start).map(Clip::end).max() {
lo = lo.max(prev.min(old.start));
}
if let Some(next) = others.filter(|c| c.start > old.start).map(|c| c.start).min() {
hi = hi.min(next.max(old.end()));
}
lo = lo.max(0);
}
let file = |c: &Clip| {
let start = c.start.saturating_sub(c.source_offset());
material_len(s, c).map(|n| (start.min(c.start), start.saturating_add(n).max(c.end())))
};
let t = TimeCtx::of(s);
let mut c = old.clone();
if let Some(at) = start {
c.trim_start_to(at.max(file(&c).map_or(lo, |(f, _)| lo.max(f))));
rebase_midi_start(&mut c, old.start, t);
if shuffle {
c.start = old.start;
}
}
if let Some(at) = end {
c.trim_end_to(at.min(file(&c).map_or(hi, |(_, f)| hi.min(f))));
}
let pl = s.track_mut(track).and_then(|t| t.playlist_mut())?;
*pl.clip_mut(id)? = c.clone();
pl.sort();
if shuffle {
let moved = ripple(s, track, old.start.saturating_add(1), c.end().saturating_sub(old.end()));
follow_automation(s, track, old.end(), moved);
}
Some(c)
}
/// Length of the file or movie under a clip; None for MIDI, which has no end.
fn material_len(s: &Session, c: &Clip) -> Option<Samples> {
match c.content {
ClipContent::Audio { source, .. } => s.source(source).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX)),
ClipContent::Video { source, .. } => s.video(source).map(|v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7))),
ClipContent::Midi { .. } => None,
}
}
/// Create fades from a selection (the incumbent's Edit › Fades › Create semantics):
/// a range across a clip boundary makes a crossfade; at a clip start a fade-in; at its end a fade-out.
pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeShape) -> usize {
@ -311,23 +763,7 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
}
let handles: Vec<(ClipId, Samples)> = {
let Some(tr) = s.track(track) else { return 0 };
tr.clips()
.iter()
.map(|c| {
let avail = match c.content {
ClipContent::Audio { source, offset } => {
let total = s.source(source).map_or(0, |src| i64::try_from(src.frames).unwrap_or(i64::MAX));
total.saturating_sub(offset).saturating_sub(c.length)
}
ClipContent::Video { source, offset } => {
let total = s.video(source).map_or(0, |v| s.sample_rate.samples(v.duration.clamp(0.0, 1e7)));
total.saturating_sub(offset).saturating_sub(c.length)
}
ClipContent::Midi { .. } => 0,
};
(c.id, avail)
})
.collect()
tr.clips().iter().map(|c| (c.id, material_len(s, c).map_or(0, |n| n.saturating_sub(c.source_offset()).saturating_sub(c.length)))).collect()
};
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
pl.sort();
@ -348,12 +784,12 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
let ext_b = before.min(b_handle).max(0);
if let Some(ca) = pl.clips.get_mut(i) {
ca.length += ext_a;
ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape };
ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape, curvature: 0.0 };
}
if let Some(cb) = pl.clips.get_mut(i + 1) {
let old = cb.start;
cb.trim_start_to(old - ext_b);
cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape };
cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape, curvature: 0.0 };
}
made += 1;
xfaded.push(a.id);
@ -362,10 +798,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
}
for c in pl.clips.iter_mut().filter(|c| !xfaded.contains(&c.id)) {
if range.start <= c.start && range.end > c.start && range.end < c.end() {
c.fade_in = Fade { len: range.end - c.start, shape };
c.fade_in = Fade { len: range.end - c.start, shape, curvature: 0.0 };
made += 1;
} else if range.end >= c.end() && range.start > c.start && range.start < c.end() {
c.fade_out = Fade { len: c.end() - range.start, shape };
c.fade_out = Fade { len: c.end() - range.start, shape, curvature: 0.0 };
made += 1;
}
}
@ -460,7 +896,7 @@ mod tests {
let copied = copy_range(&s, t, Range::new(1500, 2500));
assert_eq!(copied.len(), 1);
assert_eq!((copied[0].start, copied[0].length, copied[0].source_offset()), (0, 1000, 1500));
paste(&mut s, t, 4000, &copied, 1000, false);
paste(&mut s, t, 4000, &copied, 1000, false).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 3000, 1000), (4000, 1000, 1500)]);
}
@ -468,7 +904,7 @@ mod tests {
fn paste_shuffle_ripples() {
let (mut s, t) = session_with_clip();
let copied = copy_range(&s, t, Range::new(1000, 2000));
paste(&mut s, t, 1000, &copied, 1000, true);
paste(&mut s, t, 1000, &copied, 1000, true).unwrap();
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 4000, 1000)]);
}
@ -500,13 +936,109 @@ mod tests {
assert_eq!((c[0].end(), c[1].start), (3000, 3000));
}
fn three_clips() -> (Session, TrackId, [ClipId; 3]) {
let (mut s, t) = session_with_clip();
let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id()];
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips = vec![
Clip::audio(ids[0], "a", SourceId(500), 0, 0, 1000),
Clip::audio(ids[1], "b", SourceId(500), 1000, 1000, 2000),
Clip::audio(ids[2], "c", SourceId(500), 3000, 5000, 1000),
];
}
(s, t, ids)
}
#[test]
fn shuffle_moves_reorder_clips_and_keep_the_silence() {
let (mut s, t, [_, b, _]) = three_clips();
assert_eq!(shuffle_clips(&mut s, &[b], -900, &[]), Ok(1));
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a, ..]) = three_clips();
assert_eq!(shuffle_slot(&s, t, 900, &[a]), 0);
assert_eq!(shuffle_slot(&s, t, 1100, &[a]), 2000);
shuffle_clips(&mut s, &[a], 2500, &[]).unwrap();
assert_eq!(clips(&s, t), vec![(0, 2000, 1000), (2000, 1000, 0), (5000, 1000, 3000)]);
}
#[test]
fn shuffling_onto_an_empty_track_starts_it_at_zero() {
let (mut s, t, [_, _, c]) = three_clips();
let other = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
shuffle_clips(&mut s, &[c], 40_000, &[(t, other)]).unwrap();
assert_eq!(clips(&s, other), vec![(0, 1000, 3000)]);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000)]);
}
#[test]
fn shuffle_trims_move_later_clips_and_slip_trims_stop_at_neighbours() {
let (mut s, t, [a, b, c]) = three_clips();
trim_clip(&mut s, b, None, Some(2500), true);
trim_clip(&mut s, b, Some(1500), None, true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 1000, 1500), (4000, 1000, 3000)]);
let (mut s, t, _) = three_clips();
trim_clip(&mut s, a, None, Some(2500), false);
trim_clip(&mut s, c, Some(2000), None, false);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 2000, 1000), (3000, 3000, 1000)]);
trim_clip(&mut s, c, None, Some(99_999), false);
assert_eq!(clips(&s, t)[2], (3000, 9000, 1000));
}
#[test]
fn shuffle_leaves_locked_clips_and_what_follows_them_in_place() {
let (mut s, t, [a, b, c]) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
clear_range(&mut s, t, Range::new(1000, 3000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (5000, 1000, 3000)]);
let (mut s, t, [a2, ..]) = three_clips();
s.find_clip_mut(c).unwrap().edit_locked = true;
assert_eq!(shuffle_room(&s, t, 0), 2000);
assert_eq!(make_room(&mut s, t, 0, 2500), Err(NO_ROOM));
assert_eq!(make_room(&mut s, t, 0, 2000), Ok(()));
assert_eq!(clips(&s, t), vec![(2000, 1000, 0), (3000, 2000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_clips(&mut s, &[a2], 2500, &[]), Ok(1));
assert_eq!(clips(&s, t)[2], (5000, 1000, 3000));
let (mut s, t, _) = three_clips();
s.find_clip_mut(c).unwrap().time_locked = true;
trim_clip(&mut s, b, None, Some(9000), true);
assert_eq!(clips(&s, t), vec![(0, 1000, 0), (1000, 4000, 1000), (5000, 1000, 3000)]);
assert_eq!(shuffle_slot(&s, t, 4000, &[a]), 4000);
}
#[test]
fn multi_track_moves_keep_the_tracks_apart() {
let (mut s, t1, [a, ..]) = three_clips();
let t2 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let t3 = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let m = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let d = s.new_clip_id();
s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(500), 0, 0, 500));
let dest = destinations(&s, &[a, d], t1, t2);
assert_eq!(dest, Some(vec![(t1, t2), (t2, t3)]));
assert_eq!(destinations(&s, &[a, d], t1, m), None);
assert_eq!(destinations(&s, &[a], t1, m), None);
assert_eq!(move_clips(&mut s, &[a, d], 0, &dest.unwrap()), 2);
assert_eq!(clips(&s, t2), vec![(0, 1000, 0)]);
assert_eq!(clips(&s, t3), vec![(0, 500, 0)]);
}
#[test]
fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
}
#[test]
fn moves_overwrite_and_respect_locks() {
let (mut s, t) = session_with_clip();
let id = s.track(t).unwrap().clips()[0].id;
assert_eq!(move_clips(&mut s, &[id], 500, None), 1);
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 1);
assert_eq!(clips(&s, t), vec![(1500, 4000, 1000)]);
s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, None), 0);
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
}
}

View File

@ -0,0 +1,489 @@
//! 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. Plug-in scans live in the host crates. The one engine module that reads
/// and writes a fixed folder is `autosave.rs`: crash-recovery copies in the app's own data
/// folder, which no command names, so it is skipped too.
#[test]
fn commands_do_not_touch_the_file_system_themselves() {
fn walk(dir: &Path, files: &mut Vec<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; `autosave` writes the app's own
// recovery folder; `tests*.rs` are test-only modules.
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "autosave.rs") || name.starts_with("tests") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
// Test modules (which build their own fixtures) come last in each file.
let code = text.split("#[cfg(test)]").next().unwrap_or("");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
let touches =
["std::fs", "fs::", "File::open", "File::create", "OpenOptions", ".is_file()", ".exists()", ".is_dir()", "canonicalize"]
.iter()
.any(|w| line.contains(w));
if !line.starts_with("//") && touches {
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
}
}
}
assert!(offenders.is_empty(), "use the checked helpers in crate::io (read_file, write_file, ...): {offenders:#?}");
}
}

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(),

File diff suppressed because it is too large Load Diff

View File

@ -5,12 +5,14 @@
//! The engine owns the [`Session`] behind an `Arc` (copy-on-write), so undo snapshots are cheap.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod autosave;
pub mod catalog;
pub mod catalog_more;
pub mod clip_group_file;
pub mod cmd;
pub mod demo;
pub mod edit;
pub mod file_access;
pub mod inspect;
pub mod io;
pub mod score;
@ -88,6 +90,18 @@ pub struct Clipboard {
pub clip_effects: Vec<(String, f64)>,
/// Markers inside the copied range (starts relative to it), for Paste Special › Merge Markers.
pub markers: Vec<soundcraft_model::MemoryLocation>,
/// How far the copied range started after a grid line; Relative Grid pastes keep it.
pub grid_offset: Samples,
/// Copy Special › Automation: pasting it leaves the clips alone, in Shuffle mode too.
pub automation_only: bool,
}
impl Clipboard {
/// An empty clipboard for material copied from `range`.
pub fn of_range(s: &Session, range: soundcraft_time::Range) -> Clipboard {
let line = s.edit.grid.line_at_or_before(range.start, s.sample_rate, &s.tempo, s.frame_rate);
Clipboard { length: range.len(), grid_offset: range.start.saturating_sub(line), ..Clipboard::default() }
}
}
#[derive(Debug, Clone)]
@ -118,8 +132,13 @@ pub struct Engine {
dirty_at: Cell<(u64, bool)>,
/// Gesture key for undo coalescing (see `execute_merged`).
merge_key: Option<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 +166,9 @@ impl Engine {
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
merge_hold: None,
messages: Vec::new(),
last_selection: None,
}
}
@ -181,6 +202,7 @@ impl Engine {
self.saved = Arc::clone(&self.doc);
self.undo.clear();
self.redo.clear();
self.last_selection = None;
self.revision = self.revision.wrapping_add(1);
self.dirty = false;
}
@ -223,6 +245,7 @@ impl Engine {
pub fn undo(&mut self) -> bool {
let Some(h) = self.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.redo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
@ -232,12 +255,27 @@ impl Engine {
pub fn redo(&mut self) -> bool {
let Some(h) = self.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.undo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
true
}
/// Undo and Redo never change the Edit mode or its settings (Shuffle Lock above all).
fn keep_edit_mode(&mut self, from: &Session) {
use edit::{RELATIVE_GRID, SHUFFLE_LOCK, SNAP_TO_GRID};
let flags = [SNAP_TO_GRID, SHUFFLE_LOCK, RELATIVE_GRID];
let ed = &self.doc.edit;
if ed.edit_mode != from.edit.edit_mode || flags.iter().any(|f| ed.flag(f) != from.edit.flag(f)) {
let ed = &mut Arc::make_mut(&mut self.doc).edit;
ed.edit_mode = from.edit.edit_mode;
for f in flags {
ed.set_flag(f, from.edit.flag(f));
}
}
}
/// Record an undo point manually (for interactive gestures committed by the UI).
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
if Arc::ptr_eq(&before, &self.doc) {
@ -251,10 +289,19 @@ impl Engine {
}
}
/// Whether command `id` must wait for the take being recorded: nothing may replace the
/// session or quit under it.
pub fn blocked_while_recording(&self, id: &str) -> bool {
self.transport.recording && matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
}
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
/// document is restored.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if self.blocked_while_recording(id) {
return Err(EngineError::Disabled(id.to_string(), "stop recording first".into()));
}
if let Err(reason) = (spec.enabled)(self) {
// A call that names its targets (tracks, clips, a range) does not need a selection;
// the command itself validates them.
@ -266,7 +313,6 @@ impl Engine {
if spec.view {
return self.execute_view(spec, params);
}
self.merge_key = None;
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
@ -280,8 +326,13 @@ impl Engine {
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
}
};
if !Arc::ptr_eq(&before, &self.doc) {
// Only a change to the document ends a gesture, so Stop doesn't split the pass it ends.
self.merge_key = None;
}
match result {
Ok(v) => {
cmd::remember_selection(self, &before);
if spec.undoable && !Arc::ptr_eq(&before, &self.doc) {
self.push_undo(spec.label, before);
}
@ -315,17 +366,26 @@ impl Engine {
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
/// with the same `key` collapse into a single undo step.
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<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;
@ -374,7 +434,7 @@ fn install_panic_hook() {
ONCE.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
log::error!("panic: {info}");
log::error!("panic: {info}\n{}", std::backtrace::Backtrace::force_capture());
prev(info);
}));
});
@ -400,6 +460,34 @@ mod merge_tests {
e.undo();
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
}
#[test]
fn a_held_merge_joins_other_gestures_into_its_step() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.hold_merge(Some("pass"));
for db in [-1.0, -2.0, -3.0] {
e.execute_merged("mix.volume", &json!({"track": t.0, "db": db}), "fader").unwrap();
e.execute_merged("mix.pan", &json!({"track": t.0, "pan": db / 4.0}), "pan").unwrap();
}
assert_eq!(e.undo_history().len(), 1);
e.hold_merge(None);
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -6.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 2);
}
#[test]
fn only_a_change_to_the_document_ends_a_gesture() {
let mut e = Engine::default();
let t = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -1.0}), "fader").unwrap();
e.execute("transport.stop", &json!({})).unwrap();
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -2.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 1);
e.execute("mix.mute", &json!({"track": t.0})).unwrap();
e.execute_merged("mix.volume", &json!({"track": t.0, "db": -3.0}), "fader").unwrap();
assert_eq!(e.undo_history().len(), 3);
}
}
#[cfg(test)]

View File

@ -0,0 +1,87 @@
//! Bounces stream the main mix or a bus to their file, exactly as rendered in memory.
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
use soundcraft_engine::io::{Bounce, bounce_stems};
use soundcraft_time::Range;
use std::path::PathBuf;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn bounces_write_exactly_the_mix_and_report_its_levels() {
let e = soundcraft_engine::demo::demo_engine();
let s = e.session();
let dir = scratch("bounce-exact");
let path = dir.join("Mix.wav");
let r = Range::new(s.sample_rate.samples(10.0) + 7, s.sample_rate.samples(13.0));
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let verb = s.bus_by_name("Verb").unwrap().id;
for bus in [None, Some(verb)] {
let mut mix = match bus {
Some(b) => soundcraft_mix::render_bus(s, b, r, 1024),
None => soundcraft_mix::render_range(s, r, 1024),
};
assert!(mix.iter().flatten().any(|v| v.abs() > 1e-3), "{bus:?}");
for normalize in [false, true] {
let mut bounce = Bounce::new(e.session_arc(), &path, r, &opts, bus, normalize, false).unwrap();
let mut progress = vec![bounce.progress()];
let done = loop {
if let Some(done) = bounce.step(|| false).unwrap() {
break done;
}
progress.push(bounce.progress());
};
let last = progress[progress.len() - 1];
assert!(progress.len() > 100 && progress.is_sorted() && progress[0] < 0.01 && last > 0.99 && last <= 1.0, "{progress:?}");
if normalize {
soundcraft_dsp::offline::normalize(&mut mix, -0.1, false);
}
let bytes = std::fs::read(&path).unwrap();
let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap();
assert_eq!(file.channels, mix, "{bus:?} normalize: {normalize}");
let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, mix.len());
lm.process(&mix, mix[0].len());
let levels = (soundcraft_dsp::offline::peak_db(&mix), lm.integrated_lufs(), lm.true_peak_db());
assert_eq!((done.bytes, (done.peak_db, done.lufs, done.true_peak_db)), (bytes.len() as u64, levels));
}
}
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_bounce_too_big_for_its_format_fails_and_keeps_the_old_file() {
let e = soundcraft_engine::demo::demo_engine();
let s = e.session();
let dir = scratch("bounce-too-big");
let path = dir.join("Mix.aif");
std::fs::write(&path, b"old").unwrap();
let opts = EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let err = Bounce::new(e.session_arc(), &path, Range::new(0, s.sample_rate.samples(3.9 * 3600.0)), &opts, None, false, false).err().unwrap();
assert!(err.to_string().contains("4 GiB"), "{err}");
assert_eq!(std::fs::read(&path).unwrap(), b"old");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let err = bounce_stems(&e, &dir.to_string_lossy(), Range::new(0, s.sample_rate.samples(4.1 * 3600.0)), &opts).unwrap_err();
assert!(err.to_string().contains("4 hours"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn dropping_an_unfinished_bounce_cancels_it_and_keeps_the_old_file() {
let e = soundcraft_engine::demo::demo_engine();
let dir = scratch("bounce-cancel");
let path = dir.join("Mix.wav");
std::fs::write(&path, b"old").unwrap();
let mut bounce = Bounce::new(e.session_arc(), &path, Range::new(0, 48_000), &EncodeOptions::default(), None, false, false).unwrap();
assert_eq!(bounce.step(|| false).unwrap(), None);
assert!(dir.join("Mix.wav.part").exists());
drop(bounce);
assert_eq!(std::fs::read(&path).unwrap(), b"old");
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}

View File

@ -69,6 +69,17 @@ fn clap_insert_renders_through_the_mix() {
assert!(e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "Gain", "value": 1.0})).is_err());
}
#[test]
fn a_refused_state_is_reported() {
install_fixture();
let gain = "clap:org.soundcraft.test.gain";
let make = |state: Option<&[u8]>| soundcraft_mix::create_instance(gain, state, 48_000.0, 512, 2).unwrap();
let saved = make(None).plugin.save_state().unwrap();
assert!(!make(None).refused_state);
assert!(!make(Some(&saved)).refused_state);
assert!(make(Some(&[0xff; 16])).refused_state, "NaN gain");
}
#[test]
fn clap_state_is_captured_stored_and_restored_before_the_first_block() {
install_fixture();
@ -88,17 +99,15 @@ fn clap_state_is_captured_stored_and_restored_before_the_first_block() {
eng.set_external_instances(true);
let specs = soundcraft_mix::instance_specs(e.session());
assert_eq!(specs.len(), 1);
let mut p = soundcraft_mix::create_instance(&specs[0].id, None, 48_000.0, 512, specs[0].ch).unwrap();
let mut p = soundcraft_mix::create_instance(&specs[0].id, None, 48_000.0, 512, specs[0].ch).unwrap().plugin;
p.set_param("7", 1.5);
let mut io = vec![vec![0.0f32; 64]];
p.process(&mut io, 64);
let mut reader = p.state_reader().unwrap();
eng.adopt(vec![soundcraft_mix::PreparedInstance { track: t, slot: 0, id: gain.into(), ch: 1, plugin: p }]);
eng.ensure_synced(e.session());
let states = eng.capture_states();
assert_eq!(states.len(), 1);
let (tt, slot, blob) = &states[0];
assert_eq!((*tt, *slot), (t, 0));
e.execute("mix.insert_state", &json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(blob)})).unwrap();
let blob = reader.save_state().unwrap();
e.execute("mix.insert_state", &json!({"track": t.0, "slot": 0, "state": soundcraft_model::b64::encode(&blob)})).unwrap();
assert_eq!(eng.take_retired().len(), 0);
// Offline render (synchronous creation): the state is restored before the first block.

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,415 @@
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_engine::edit::SHUFFLE_LOCK;
use soundcraft_model::{AutoParam, Clip, ClipId, EditMode, Source, SourceId, TrackId};
fn source(id: u64, frames: u64) -> Source {
Source {
id: SourceId(id),
name: "src".into(),
path: "src.wav".into(),
channels: 1,
frames,
sample_rate: 48_000,
format: soundcraft_audio_io::FileFormat::Wav,
time_reference: 0,
unsaved: false,
}
}
fn new_track(e: &mut Engine) -> TrackId {
TrackId(e.execute("track.new", &json!({"count": 1})).unwrap()["tracks"][0].as_u64().unwrap())
}
fn engine(mode: &str) -> (Engine, TrackId, [ClipId; 2]) {
let mut e = Engine::default();
let t = new_track(&mut e);
let s = e.session_mut();
s.sources.push(source(7, 10_000));
let ids = [s.new_clip_id(), s.new_clip_id()];
s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips =
vec![Clip::audio(ids[0], "a", SourceId(7), 0, 0, 1000), Clip::audio(ids[1], "b", SourceId(7), 1000, 2000, 1000)];
e.execute("edit.mode", &json!({"mode": mode})).unwrap();
(e, t, ids)
}
/// A track of clips (start, length, offset) on a long file, source 8.
fn long(mode: &str, spans: &[(i64, i64, i64)]) -> (Engine, TrackId, Vec<ClipId>) {
let mut e = Engine::default();
let t = new_track(&mut e);
let s = e.session_mut();
s.sources.push(source(8, 1_000_000));
let ids: Vec<ClipId> = spans.iter().map(|_| s.new_clip_id()).collect();
s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap().clips =
spans.iter().zip(&ids).map(|(&(start, len, offset), id)| Clip::audio(*id, "c", SourceId(8), offset, start, len)).collect();
e.execute("edit.mode", &json!({"mode": mode})).unwrap();
(e, t, ids)
}
fn clips(e: &Engine, t: TrackId) -> Vec<(i64, i64)> {
e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length)).collect()
}
/// Each clip's id, length and file offset, in id order: what a move must never change.
fn material(e: &Engine, t: TrackId) -> Vec<(u64, i64, i64)> {
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.id.0, c.length, c.source_offset())).collect();
v.sort_unstable();
v
}
fn start(e: &Engine, id: ClipId) -> i64 {
e.session().find_clip(id).unwrap().1.start
}
fn points(e: &Engine, t: TrackId) -> Vec<i64> {
e.session().track(t).unwrap().lane(&AutoParam::Volume).map(|l| l.points.iter().map(|p| p.at).collect()).unwrap_or_default()
}
fn set_points(e: &mut Engine, t: TrackId, at: &[i64]) {
for a in at {
run(e, "automation.set_point", json!({"track": t.0, "param": "volume", "at": a, "value": -6.0}));
}
}
fn run(e: &mut Engine, id: &str, p: Value) {
e.execute(id, &p).unwrap();
}
#[test]
fn insert_silence_pushes_in_shuffle_and_clears_otherwise() {
for (mode, want) in [("shuffle", vec![(0, 500), (700, 500), (2200, 1000)]), ("slip", vec![(0, 500), (700, 300), (2000, 1000)])] {
let (mut e, t, _) = engine(mode);
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 500, "end": 700}));
assert_eq!(clips(&e, t), want, "{mode}");
}
}
#[test]
fn duplicate_and_repeat_make_room_in_shuffle() {
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (3000, 1000)]);
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.repeat", json!({"tracks": [t.0], "start": 0, "end": 1000, "count": 2}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000), (4000, 1000)]);
let (mut e, t, _) = engine("slip");
run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]);
}
#[test]
fn shuffle_paste_replaces_the_selection() {
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.copy", json!({"tracks": [t.0], "start": 0, "end": 1000}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 2000, "end": 2500}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 1000), (3000, 500)]);
}
#[test]
fn clip_list_drops_land_on_a_clip_boundary_in_shuffle() {
let (mut e, t, [_, b]) = engine("shuffle");
run(&mut e, "clip.place_source", json!({"source": 7, "track": t.0, "at": 1900}));
assert_eq!(clips(&e, t), vec![(0, 1000), (2000, 10_000), (12_000, 1000)]);
e.session_mut().find_clip_mut(b).unwrap().time_locked = true;
assert!(e.execute("clip.place_source", &json!({"source": 7, "track": t.0, "at": 0})).is_err());
assert_eq!(clips(&e, t).len(), 3);
}
#[test]
fn grid_moves_to_a_position_snap_or_keep_their_offset() {
let (mut e, t, [_, b]) = engine("grid");
let s = e.session();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": step + 1000}));
assert_eq!(clips(&e, t)[1].0, step);
run(&mut e, "edit.mode", json!({"mode": "relative_grid"}));
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 2 * step + 2000}));
assert_eq!(clips(&e, t)[1].0, 2 * step);
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "by": 200}));
run(&mut e, "edit.move_clips", json!({"clips": [b.0], "to": 3 * step}));
assert_eq!(clips(&e, t)[1].0, 3 * step + 200);
}
#[test]
fn clips_cannot_move_onto_tracks_without_a_playlist() {
let (mut e, t, [a, _]) = engine("slip");
let aux = e.execute("track.new", &json!({"kind": "aux"})).unwrap()["tracks"][0].clone();
assert!(e.execute("edit.move_clips", &json!({"clips": [a.0], "by": 0, "track": aux})).is_err());
assert_eq!(clips(&e, t).len(), 2);
}
#[test]
fn trim_clip_needs_an_edge_and_an_unlocked_clip() {
let (mut e, t, [a, b]) = engine("slip");
assert!(e.execute("edit.trim_clip", &json!({"clip": a.0})).is_err());
e.session_mut().find_clip_mut(b).unwrap().edit_locked = true;
assert!(e.execute("edit.trim_clip", &json!({"clip": b.0, "end": 2500})).is_err());
run(&mut e, "edit.trim_clip", json!({"clip": a.0, "end": 5000}));
assert_eq!(clips(&e, t), vec![(0, 2000), (2000, 1000)]);
}
#[test]
fn shuffle_lock_keeps_shuffle_mode_out() {
let (mut e, _, _) = engine("slip");
run(&mut e, "edit.shuffle_lock", json!({}));
assert!(e.execute("edit.mode", &json!({"mode": "shuffle"})).is_err());
run(&mut e, "edit.mode", json!({"mode": "grid"}));
run(&mut e, "edit.shuffle_lock", json!({"value": false}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
assert!(e.execute("edit.shuffle_lock", &json!({})).is_err());
}
#[test]
fn snap_to_grid_rides_along_with_the_other_modes() {
let (mut e, _, _) = engine("slip");
let snap = |e: &Engine| e.session().edit.flag(soundcraft_engine::edit::SNAP_TO_GRID);
run(&mut e, "edit.snap_to_grid", json!({}));
assert!(snap(&e));
run(&mut e, "edit.mode", json!({"mode": "spot", "snap_to_grid": true}));
assert!(snap(&e));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
assert!(!snap(&e));
run(&mut e, "edit.mode", json!({"mode": "grid", "snap_to_grid": true}));
assert!(!snap(&e));
assert!(e.execute("edit.snap_to_grid", &json!({})).is_err());
}
#[test]
fn shuffle_edits_that_would_push_a_locked_clip_are_refused() {
let (mut e, t, [_, b]) = engine("shuffle");
e.session_mut().find_clip_mut(b).unwrap().time_locked = true;
for id in ["edit.insert_silence", "edit.duplicate"] {
let err = e.execute(id, &json!({"tracks": [t.0], "start": 0, "end": 1500})).unwrap_err().to_string();
assert!(err.contains("locked clip"), "{id}: {err}");
}
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(1000, 1000), (2000, 1000)]);
run(&mut e, "event.cut_time", json!({"start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]);
}
#[test]
fn clips_dragged_from_several_tracks_keep_their_tracks_apart() {
let (mut e, t, [a, _]) = engine("slip");
let new = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let [t2, t3] = [0, 1].map(|i| TrackId(new[i].as_u64().unwrap()));
let s = e.session_mut();
let d = s.new_clip_id();
s.track_mut(t2).and_then(|t| t.playlist_mut()).unwrap().clips.push(Clip::audio(d, "d", SourceId(7), 0, 5000, 500));
run(&mut e, "edit.move_clips", json!({"clips": [a.0, d.0], "by": 0, "track": t2.0}));
assert_eq!(clips(&e, t), vec![(2000, 1000)]);
assert_eq!(clips(&e, t2), vec![(0, 1000)]);
assert_eq!(clips(&e, t3), vec![(5000, 500)]);
assert!(e.execute("edit.move_clips", &json!({"clips": [a.0, d.0], "by": 0, "track": t3.0})).is_err());
}
#[test]
fn relative_grid_pastes_keep_the_offset_from_the_grid() {
let (mut e, t, [_, b]) = engine("relative_grid");
let s = e.session();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
run(&mut e, "edit.select", json!({"clips": [b.0]}));
run(&mut e, "edit.copy", json!({}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 3 * step}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t).last(), Some(&(3 * step + 2000, 1000)));
run(&mut e, "edit.mode", json!({"mode": "slip"}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 5 * step}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t).last(), Some(&(5 * step, 1000)));
}
#[test]
fn shuffle_moves_keep_crossfade_partners_whole() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
let whole = material(&e, t);
let [a, b, c] = [ids[0], ids[1], ids[2]];
run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 5000}));
assert_eq!(material(&e, t), whole);
assert_eq!([start(&e, b), start(&e, c), start(&e, a)], [0, 1100, 2100]);
let (mut e, t, ids) = long("slip", &[(0, 1000, 10_000), (1000, 1000, 20_000), (2000, 1000, 30_000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 900, "end": 1100}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
for by in [2500, -2500] {
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": by}));
assert_eq!(material(&e, t), whole, "{by}");
assert_eq!(start(&e, ids[1]) - start(&e, ids[0]), 900, "{by}");
}
assert_eq!([start(&e, ids[0]), start(&e, ids[2])], [0, 2000]);
run(&mut e, "edit.mode", json!({"mode": "slip"}));
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": 5000}));
assert_eq!(material(&e, t), whole);
}
#[test]
fn pasting_copied_automation_in_shuffle_leaves_the_clips_alone() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]);
set_points(&mut e, t, &[200]);
run(&mut e, "edit.copy_all_automation", json!({"tracks": [t.0], "start": 0, "end": 500}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
run(&mut e, "edit.select", json!({"clips": [ids[1].0]}));
run(&mut e, "edit.paste", json!({}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]);
}
#[test]
fn automation_follows_shuffle_moves_trims_and_drops() {
let setup = || {
let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]);
set_points(&mut e, t, &[500, 1500, 2500]);
(e, t, ids)
};
let (mut e, t, ids) = setup();
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "by": 3000}));
assert_eq!(points(&e, t), [500, 1500, 2500]);
assert_eq!([start(&e, ids[1]), start(&e, ids[2]), start(&e, ids[0])], [0, 1000, 2000]);
let (mut e, t, ids) = setup();
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 500}));
assert_eq!(points(&e, t), [1000, 2000]);
let (mut e, t, _) = setup();
run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 1000}));
assert_eq!(points(&e, t), [500, 1_001_500, 1_002_500]);
}
#[test]
fn shuffle_automation_stops_at_locked_clips_and_needs_automation_follows_edit() {
let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (3000, 1000, 0)]);
e.session_mut().find_clip_mut(ids[2]).unwrap().time_locked = true;
set_points(&mut e, t, &[3500]);
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 500}));
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500}));
assert_eq!((start(&e, ids[2]), points(&e, t)), (3000, vec![3500]));
let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]);
set_points(&mut e, t, &[300, 1500, 5000]);
e.session_mut().edit.automation_follows_edit = false;
run(&mut e, "edit.duplicate", json!({"tracks": [t.0], "start": 0, "end": 1000}));
run(&mut e, "edit.insert_silence", json!({"tracks": [t.0], "start": 0, "end": 100}));
assert_eq!(points(&e, t), [300, 1500, 5000]);
}
#[test]
fn shuffle_clear_moves_automation_with_the_clips() {
let (mut e, t, _) = long("shuffle", &[(0, 10_000, 0), (20_000, 10_000, 0)]);
set_points(&mut e, t, &[0, 9_000, 20_000, 29_000]);
run(&mut e, "edit.clear", json!({"track": t.0, "start": 4_000, "end": 6_000}));
assert_eq!(clips(&e, t), [(0, 4_000), (4_000, 4_000), (18_000, 10_000)]);
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
}
#[test]
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
let (mut e, t, [a, _]) = engine("relative_grid");
run(&mut e, "event.tempo", json!({"bpm": 90, "at": 0}));
run(&mut e, "edit.select", json!({"clips": [a.0]}));
run(&mut e, "edit.copy", json!({}));
run(&mut e, "edit.select", json!({"tracks": [t.0], "start": 128_000}));
run(&mut e, "edit.paste", json!({}));
assert!(clips(&e, t).contains(&(128_000, 1000)), "{:?}", clips(&e, t));
run(&mut e, "edit.paste", json!({"tracks": [t.0], "at": 50_000}));
assert!(clips(&e, t).contains(&(50_000, 1000)), "{:?}", clips(&e, t));
}
#[test]
fn start_trims_keep_the_sync_point_on_its_audio() {
let (mut e, t, ids) = long("slip", &[(0, 10_000, 0)]);
let sync = |e: &Engine| e.session().find_clip(ids[0]).unwrap().1.sync_point;
e.session_mut().find_clip_mut(ids[0]).unwrap().sync_point = 5000;
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 2000}));
assert_eq!(sync(&e), 3000);
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 8000}));
assert_eq!(sync(&e), 0);
run(&mut e, "edit.mode", json!({"mode": "grid"}));
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0], "to": 30_000}));
assert_eq!(clips(&e, t), vec![(30_000, 2000)]);
}
#[test]
fn imported_time_stamps_count_at_the_session_rate() {
use soundcraft_audio_io::{AudioBuffer, BwfInfo, EncodeOptions, FileFormat};
let mut e = Engine::default();
let bwf = BwfInfo { time_reference: 1_587_600_000, ..BwfInfo::default() };
let opts = EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() };
let bytes = soundcraft_audio_io::encode(&AudioBuffer::new(44_100, 1, 441), &opts).unwrap();
soundcraft_engine::io::import_audio_bytes(&mut e, "stamped.wav", &bytes, None, None, 0).unwrap();
assert_eq!(e.session().sources[0].time_reference, 1_728_000_000);
}
#[test]
fn shuffle_trims_stop_at_a_locked_crossfade_and_stay_inside_the_file() {
let (mut e, t, ids) = long("slip", &[(0, 1000, 500), (1000, 1000, 5000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 950, "end": 1050}));
e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true;
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
let before = material(&e, t);
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "end": 1500}));
run(&mut e, "edit.trim_clip", json!({"clip": ids[0].0, "start": 0}));
assert_eq!(material(&e, t), before);
let (mut e, _, [_, b]) = engine("shuffle");
run(&mut e, "edit.trim_clip", json!({"clip": b.0, "start": 2500, "end": 20_000}));
let c = e.session().find_clip(b).unwrap().1;
assert_eq!((c.source_offset(), c.source_offset() + c.length), (1500, 10_000));
}
#[test]
fn duplicate_and_extend_makes_room_in_shuffle() {
let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]);
run(&mut e, "edit.duplicate_extend", json!({"tracks": [t.0], "start": 0, "end": 1000}));
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000), (2000, 1000)]);
let (mut e, t, ids) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0)]);
e.session_mut().find_clip_mut(ids[1]).unwrap().time_locked = true;
assert!(e.execute("edit.duplicate_extend", &json!({"tracks": [t.0], "start": 0, "end": 1000})).is_err());
assert_eq!(clips(&e, t), vec![(0, 1000), (1000, 1000)]);
}
#[test]
fn undo_leaves_the_edit_mode_and_shuffle_lock_alone() {
let (mut e, t, _) = engine("shuffle");
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": 0, "end": 500}));
run(&mut e, "edit.mode", json!({"mode": "slip"}));
run(&mut e, "edit.shuffle_lock", json!({}));
for step in [Engine::undo, Engine::redo] {
assert!(step(&mut e));
assert_eq!((e.session().edit.edit_mode, e.session().edit.flag(SHUFFLE_LOCK)), (EditMode::Slip, true));
}
}
#[test]
fn shuffle_cuts_reaching_before_the_session_start_keep_the_spacing() {
let (mut e, t, _) = long("shuffle", &[(0, 1000, 0), (1000, 1000, 0), (2000, 1000, 0)]);
run(&mut e, "edit.cut", json!({"tracks": [t.0], "start": -5000, "end": 500}));
assert_eq!(clips(&e, t), vec![(0, 500), (500, 1000), (1500, 1000)]);
}
#[test]
fn shuffle_never_pulls_a_crossfade_apart() {
let (mut e, t, ids) = long("slip", &[(0, 9000, 0), (9000, 11_000, 100_000), (20_000, 5000, 200_000)]);
run(&mut e, "edit.fades_create", json!({"tracks": [t.0], "start": 8000, "end": 10_000}));
run(&mut e, "edit.mode", json!({"mode": "shuffle"}));
let err = e.execute("edit.duplicate", &json!({"tracks": [t.0], "start": 5000, "end": 9000})).unwrap_err().to_string();
assert!(err.contains("crossfade"), "{err}");
run(&mut e, "clip.place_source", json!({"source": 8, "track": t.0, "at": 7900}));
assert_eq!(clips(&e, t)[0], (0, 1_000_000));
assert_eq!([start(&e, ids[0]), start(&e, ids[1])], [1_000_000, 1_008_000]);
}
#[test]
fn clips_move_to_and_from_hidden_tracks() {
let (mut e, t, [a, _]) = engine("slip");
let hidden = new_track(&mut e);
run(&mut e, "track.hide", json!({"tracks": [hidden.0], "hidden": true}));
run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": hidden.0}));
assert_eq!(clips(&e, hidden), vec![(0, 1000)]);
run(&mut e, "edit.move_clips", json!({"clips": [a.0], "by": 0, "track": t.0}));
assert_eq!(clips(&e, t).len(), 2);
}
#[test]
fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
let (mut e, t, _) = engine("shuffle");
assert!(e.execute("edit.shuffle_lock", &json!({"value": true, "tracks": [t.0]})).is_err());
run(&mut e, "edit.mode", json!({"mode": "grid"}));
assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
}

View File

@ -26,6 +26,43 @@ fn demo_json() -> Value {
serde_json::to_value(e.session()).unwrap()
}
#[test]
fn positions_at_the_ends_of_the_range_do_not_overflow_commands() {
let (max, min) = (serde_json::json!(i64::MAX), serde_json::json!(i64::MIN));
let kick = "/tracks/0/playlists/0/clips/0";
let cases = [
(format!("{kick}/start"), &max, "edit.duplicate"),
(format!("{kick}/start"), &max, "edit.insert_silence"),
(format!("{kick}/length"), &max, "edit.heal"),
(format!("{kick}/length"), &max, "edit.trim_to_file_boundaries"),
(format!("{kick}/content/Audio/offset"), &min, "edit.trim_to_file_boundaries"),
(format!("{kick}/sync_point"), &max, "clip.quantize_to_grid"),
(format!("{kick}/gain_env"), &serde_json::json!([[i64::MIN, 0.0]]), "edit.separate_on_grid"),
("/tracks/4/automation/0/points/0/at".into(), &min, "automation.thin_all"),
("/tracks/5/playlists/0/clips/0/content/Midi/sequence/notes/0/start".into(), &max, "event.extract_chords"),
("/markers/0/start".into(), &max, "event.insert_time"),
("/markers/1/end".into(), &min, "edit.copy"),
("/edit/selection/start".into(), &min, "edit.move_selection_left"),
];
let (demo, base) = (soundcraft_engine::demo::demo_engine(), demo_json());
let failed: Vec<String> = cases
.into_iter()
.filter_map(|(path, value, cmd)| {
let mut v = base.clone();
*v.pointer_mut(&path).unwrap() = value.clone();
let mut s = soundcraft_model::Session::from_json(&v.to_string()).unwrap();
s.pool = demo.session().pool.clone();
let mut e = soundcraft_engine::Engine::new(s);
if !path.starts_with("/edit") {
e.execute("edit.select_all", &serde_json::json!({})).unwrap();
}
let r = e.execute(cmd, &serde_json::json!({}));
matches!(r, Err(soundcraft_engine::EngineError::Internal(..))).then(|| format!("{path} = {value}, {cmd}: {r:?}"))
})
.collect();
assert!(failed.is_empty(), "{failed:#?}");
}
proptest! {
#![proptest_config(ProptestConfig { cases: 96, .. ProptestConfig::default() })]
#[test]

View File

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

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

@ -54,6 +54,8 @@ const CMDS: &[&str] = &[
"event.beat_detective",
"automation.thin",
"clip.remove_warp",
"clip.time_lock",
"clip.edit_lock",
];
fn params(i: usize, a: i64, b: i64, track: &str) -> Value {
@ -119,7 +121,7 @@ fn check_invariants(e: &Engine) -> Result<(), String> {
proptest! {
#![proptest_config(ProptestConfig { cases: 48, .. ProptestConfig::default() })]
#[test]
fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..48, -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) {
fn random_edits_keep_the_session_sane(ops in proptest::collection::vec((0usize..CMDS.len(), -100_000i64..3_000_000, -100_000i64..3_000_000, 0usize..5), 1..25)) {
let mut e = soundcraft_engine::demo::demo_engine();
let original = serde_json::to_value(e.session()).unwrap();
let names = ["Kick", "Snare", "Bass", "Pad", "Keys"];

View File

@ -0,0 +1,21 @@
//! A take being recorded is never replaced or quit away from.
use serde_json::json;
use soundcraft_engine::{Engine, EngineError};
#[test]
fn nothing_replaces_or_quits_a_session_while_it_records() {
let mut e = Engine::default();
e.transport.recording = true;
for (id, params) in [
("session.new", json!({})),
("session.open", json!({"path": "Song.scraft"})),
("session.close", json!({})),
("session.revert", json!({})),
("app.quit", json!({})),
] {
assert!(matches!(e.execute(id, &params), Err(EngineError::Disabled(_, why)) if why == "stop recording first"), "{id}");
}
assert!(e.execute("track.new", &json!({})).is_ok(), "editing goes on");
e.transport.recording = false;
assert!(e.execute("session.close", &json!({})).is_ok());
}

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

File diff suppressed because it is too large Load Diff

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

@ -1,8 +1,8 @@
//! Clips: windows onto audio sources or MIDI sequences, placed on a playlist.
use crate::{ClipId, SourceId};
use crate::{ClipId, MAX_NAME_CHARS, SourceId, truncate_chars};
use soundcraft_midi::Sequence;
use soundcraft_time::{Range, Samples};
use soundcraft_time::{MAX_POSITION, Range, Samples};
/// Fade curve shapes (Pro Tools offers standard, equal-power, S-curve and custom presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
@ -47,10 +47,37 @@ impl FadeShape {
}
/// A fade at a clip boundary, `len` samples long.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
pub struct Fade {
pub len: Samples,
pub shape: FadeShape,
/// Continuous bend (-1..1); zero preserves the original shape exactly.
#[serde(default)]
pub curvature: f32,
}
impl Fade {
/// The envelope used by both rendering and audio, with fixed zero/unity endpoints.
pub fn gain(self, t: f32) -> f32 {
let base = self.shape.gain(t);
let curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 };
if curvature == 0.0 || base <= 0.0 || base >= 1.0 {
return base;
}
let weighted = base * (curvature * 4.0).exp2();
weighted / (weighted + 1.0 - base)
}
/// Invert the bend at the midpoint so a vertical drag follows the pointer.
pub fn curvature_for_midpoint_gain(self, gain: f32) -> f32 {
let gain = if gain.is_finite() { gain.clamp(0.001, 0.999) } else { self.gain(0.5) };
let base = self.shape.gain(0.5);
(((gain / (1.0 - gain)).log2() - (base / (1.0 - base)).log2()) / 4.0).clamp(-1.0, 1.0)
}
pub fn sanitize(&mut self) {
self.curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 };
}
}
/// What a clip plays.
@ -105,6 +132,10 @@ pub struct Clip {
/// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews).
#[serde(default)]
pub group: Option<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 +162,7 @@ impl Clip {
color: None,
stretch: 1.0,
group: None,
loop_id: None,
}
}
@ -189,17 +221,23 @@ impl Clip {
/// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades.
pub fn gain_at(&self, rel: Samples) -> f32 {
self.gain_at_with_fades(rel, self.fade_in, self.fade_out)
}
/// Evaluate temporary fades while retaining clip gain and its envelope.
/// UI previews can use this without modifying the clip or rebuilding media.
pub fn gain_at_with_fades(&self, rel: Samples, fade_in: Fade, fade_out: Fade) -> f32 {
let mut db = self.gain_db;
if !self.gain_env.is_empty() {
db += env_value(&self.gain_env, rel);
}
let mut g = db_to_gain(db);
if self.fade_in.len > 0 && rel < self.fade_in.len {
g *= self.fade_in.shape.gain(rel as f32 / self.fade_in.len as f32);
if fade_in.len > 0 && rel < fade_in.len {
g *= fade_in.gain(rel as f32 / fade_in.len as f32);
}
let from_end = self.length - rel;
if self.fade_out.len > 0 && from_end <= self.fade_out.len {
g *= self.fade_out.shape.gain(from_end as f32 / self.fade_out.len as f32);
let from_end = self.length.saturating_sub(rel);
if fade_out.len > 0 && from_end <= fade_out.len {
g *= fade_out.gain(from_end as f32 / fade_out.len as f32);
}
g
}
@ -214,14 +252,24 @@ impl Clip {
// MIDI note ticks are re-anchored by the engine, which knows the tempo map.
self.length -= delta;
self.start = new_start;
if self.sync_point != 0 {
self.sync_point = self.sync_point.saturating_sub(delta);
}
self.drop_stray_sync();
self.clamp_fades();
self.gain_env = self.gain_env.iter().filter_map(|&(t, v)| (t - delta >= 0).then_some((t - delta, v))).collect();
// Keep the gain at the cut when earlier breakpoints are dropped.
if self.gain_env.iter().any(|&(t, _)| t < delta) && self.gain_env.iter().all(|&(t, _)| t != delta) {
self.gain_env.insert(0, (delta, env_value(&self.gain_env, delta)));
}
self.gain_env.retain(|&(t, _)| t >= delta);
self.gain_env.iter_mut().for_each(|(t, _)| *t = t.saturating_sub(delta));
}
/// Move the end edge to `new_end`.
pub fn trim_end_to(&mut self, new_end: Samples) {
let new_end = new_end.max(self.start + 1);
self.length = new_end - self.start;
self.drop_stray_sync();
self.clamp_fades();
}
@ -230,6 +278,48 @@ impl Clip {
self.fade_in.len = self.fade_in.len.clamp(0, self.length);
self.fade_out.len = self.fade_out.len.clamp(0, self.length);
}
/// A sync point (0 means none) that a trim left outside the clip no longer marks anything.
fn drop_stray_sync(&mut self) {
if !(0..self.length).contains(&self.sync_point) {
self.sync_point = 0;
}
}
/// Repair a clip read from a file: positions and lengths within ±[`MAX_POSITION`], as commands
/// keep theirs, fades within the clip, a name that can be drawn, and a usable gain and stretch.
pub fn sanitize(&mut self) {
bound(&mut self.start);
self.length = self.length.clamp(1, MAX_POSITION);
bound(&mut self.sync_point);
self.gain_env.iter_mut().for_each(|(at, _)| bound(at));
match &mut self.content {
ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => bound(offset),
ClipContent::Midi { sequence } => bound_sequence(sequence),
}
self.clamp_fades();
self.fade_in.sanitize();
self.fade_out.sanitize();
truncate_chars(&mut self.name, MAX_NAME_CHARS);
if !self.gain_db.is_finite() {
self.gain_db = 0.0;
}
if !self.stretch.is_finite() || self.stretch <= 0.0 {
self.stretch = 1.0;
}
}
}
pub(crate) fn bound(at: &mut Samples) {
*at = (*at).clamp(-MAX_POSITION, MAX_POSITION);
}
pub(crate) fn bound_sequence(s: &mut Sequence) {
for n in &mut s.notes {
bound(&mut n.start);
n.length = n.length.clamp(1, MAX_POSITION);
}
s.ctrls.iter_mut().for_each(|c| bound(&mut c.tick));
}
fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 {
@ -237,7 +327,11 @@ fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 {
for &(t, v) in env {
if t >= at {
return match prev {
Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
Some((pt, pv)) if t > pt => {
let elapsed = i128::from(at).saturating_sub(i128::from(pt));
let span = i128::from(t).saturating_sub(i128::from(pt));
pv + (v - pv) * (elapsed as f32 / span as f32)
}
_ => v,
};
}
@ -264,11 +358,78 @@ mod tests {
}
}
#[test]
fn fade_curvature_is_bounded_monotonic_and_inverts_at_the_midpoint() {
for shape in FadeShape::ALL {
let original = Fade { len: 1000, shape, curvature: 0.0 };
for i in 0..=128 {
let t = i as f32 / 128.0;
assert_eq!(original.gain(t), shape.gain(t), "legacy curves must be unchanged");
}
for curvature in [-1.0, -0.5, 0.0, 0.5, 1.0] {
let fade = Fade { curvature, ..original };
assert_eq!(fade.gain(0.0), 0.0);
assert_eq!(fade.gain(1.0), 1.0);
let mut previous = 0.0;
for i in 0..=128 {
let gain = fade.gain(i as f32 / 128.0);
assert!(gain.is_finite() && (0.0..=1.0).contains(&gain));
assert!(gain >= previous, "{shape:?}, {curvature}");
previous = gain;
}
assert!((fade.curvature_for_midpoint_gain(fade.gain(0.5)) - curvature).abs() < 1e-5);
}
assert!((Fade { curvature: 0.5, ..original }).gain(0.5) > original.gain(0.5));
assert!((Fade { curvature: -0.5, ..original }).gain(0.5) < original.gain(0.5));
}
}
#[test]
fn fade_curvature_loads_legacy_data_and_sanitizes_hostile_values() {
let fade: Fade = serde_json::from_str(r#"{"len":100,"shape":"SCurve"}"#).unwrap();
assert_eq!(fade.curvature, 0.0);
for curvature in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -10.0, 10.0] {
let mut fade = Fade { curvature, ..fade };
assert!(fade.gain(0.5).is_finite());
fade.sanitize();
assert!(fade.curvature.is_finite() && (-1.0..=1.0).contains(&fade.curvature));
let roundtrip: Fade = serde_json::from_str(&serde_json::to_string(&fade).unwrap()).unwrap();
assert_eq!(roundtrip, fade);
}
}
#[test]
fn temporary_fades_keep_clip_gain_and_envelope_without_mutating_the_clip() {
let mut clip = Clip::audio(ClipId(1), "preview", SourceId(1), 0, 0, 1000);
clip.gain_db = -6.0;
clip.gain_env = vec![(0, 0.0), (1000, 6.0)];
clip.fade_in = Fade { len: 100, shape: FadeShape::SCurve, curvature: 0.25 };
clip.fade_out = Fade { len: 100, shape: FadeShape::Logarithmic, curvature: -0.1 };
let before = clip.clone();
let fade_in = Fade { len: 1000, shape: FadeShape::Linear, curvature: 0.5 };
let fade_out = Fade { len: 1000, shape: FadeShape::Linear, curvature: -0.5 };
assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6);
let mut committed = clip.clone();
committed.fade_in = fade_in;
committed.fade_out = fade_out;
for rel in [0, 50, 500, 999, 1000] {
assert_eq!(clip.gain_at_with_fades(rel, fade_in, fade_out), committed.gain_at(rel));
}
for rel in [i64::MIN, i64::MAX] {
assert!(clip.gain_at(rel).is_finite());
assert!(clip.gain_at_with_fades(rel, fade_in, fade_out).is_finite());
}
assert_eq!(clip, before);
clip.gain_env = vec![(i64::MIN, 0.0), (i64::MAX, 6.0)];
assert!((clip.gain_at(500) - db_to_gain(-3.0)).abs() < 1e-6);
assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6);
}
#[test]
fn gain_with_fades() {
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000);
c.fade_in = Fade { len: 100, shape: FadeShape::Linear };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear };
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
assert!(c.gain_at(0).abs() < 1e-6);
assert!((c.gain_at(50) - 0.5).abs() < 1e-6);
assert!((c.gain_at(500) - 1.0).abs() < 1e-6);
@ -282,6 +443,27 @@ mod tests {
assert!((c.gain_at(500) - db_to_gain(-10.0)).abs() < 1e-4);
}
#[test]
fn trim_start_keeps_gain_envelope_at_cut() {
for (env, cut, expected) in [
(vec![(0, 0.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]),
(vec![(0, 0.0), (100, -20.0)], 500, vec![(0, -20.0)]),
(vec![(0, 0.0), (500, -10.0), (1000, -20.0)], 500, vec![(0, -10.0), (500, -20.0)]),
(vec![], 500, vec![]),
(vec![(100, -3.0)], 0, vec![(100, -3.0)]),
(vec![(100, -3.0)], -100, vec![(200, -3.0)]),
] {
let mut clip = Clip::audio(ClipId(1), "a", SourceId(1), 100, 0, 1000);
clip.gain_env = env;
let gain_at_cut = clip.gain_at(cut);
clip.trim_start_to(cut);
assert_eq!(clip.gain_env, expected);
if cut > 0 {
assert!((clip.gain_at(0) - gain_at_cut).abs() < 1e-6);
}
}
}
#[test]
fn trims_keep_material_anchored() {
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, 100, 1000);
@ -293,7 +475,7 @@ mod tests {
assert_eq!(c.length, 1);
// Regression: trimming the start must not leave a fade-out longer than the clip.
let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 1000);
c.fade_out = Fade { len: 800, shape: FadeShape::Linear };
c.fade_out = Fade { len: 800, shape: FadeShape::Linear, curvature: 0.0 };
c.trim_start_to(900);
assert!(c.fade_out.len <= c.length);
}

View File

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

View File

@ -1,5 +1,6 @@
//! The session: the whole document.
use crate::clip::{bound, bound_sequence};
use crate::{
Bus, BusId, ChannelFormat, Clip, ClipId, Group, GroupId, MarkerId, MarkerKind, MemoryLocation, OutputPath, Source, SourceId, SourcePool,
TRACK_COLORS, Track, TrackId, TrackKind, VideoSource,
@ -9,6 +10,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap
/// Native session file extension.
pub const SESSION_EXTENSION: &str = "scraft";
/// `EditState::values` key holding the feet+frames reading of sample 0, in frames.
pub const FEET_FRAMES_START: &str = "feet_frames.start";
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SessionError {
#[error("not a SoundCraft session: {0}")]
@ -148,6 +152,14 @@ impl Default for ZoomState {
}
}
/// The view Zoom Toggle goes back to.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ZoomToggle {
pub samples_per_px: f64,
pub scroll: Samples,
pub heights: Vec<(TrackId, crate::TrackHeight)>,
}
/// Edit-window state saved with the session (selection, tools, modes, view settings).
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EditState {
@ -172,6 +184,8 @@ pub struct EditState {
pub main_counter: TimeFormat,
pub sub_counter: Option<TimeFormat>,
pub zoom: ZoomState,
/// Set while Zoom Toggle is on.
pub zoom_toggle: Option<ZoomToggle>,
/// Visible rulers.
pub rulers: Vec<String>,
pub link_timeline_edit: bool,
@ -245,6 +259,7 @@ impl Default for EditState {
main_counter: TimeFormat::MinSecs,
sub_counter: Some(TimeFormat::Samples),
zoom: ZoomState::default(),
zoom_toggle: None,
rulers: vec!["bars_beats".into(), "min_secs".into(), "tempo".into(), "meter".into(), "markers".into()],
link_timeline_edit: true,
link_track_edit: false,
@ -421,6 +436,21 @@ impl Session {
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
}
/// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position).
pub fn feet_frames_start(&self) -> i64 {
self.edit.value(FEET_FRAMES_START, 0.0) as i64
}
/// Format a position against this session's timecode and feet+frames references.
pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String {
soundcraft_time::format_position(at, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
}
/// Parse a position typed in `fmt` against this session's references.
pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result<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])
@ -432,14 +462,15 @@ impl Session {
if !base.is_empty() && !taken(base) && !matches!(base, "Audio" | "Aux" | "MIDI" | "Inst" | "VCA" | "Folder" | "Master") {
return base.to_string();
}
let mut i = 1;
loop {
let cand = format!("{base} {i}");
if !taken(&cand) {
return cand;
}
i += 1;
}
let prefix = format!("{base} ");
let used: std::collections::HashSet<u64> = self
.tracks
.iter()
.filter_map(|t| t.name.strip_prefix(&prefix))
.filter_map(|n| n.parse().ok().filter(|i: &u64| i.to_string() == n))
.collect();
let i = (1..).find(|i| !used.contains(i)).unwrap_or(1);
format!("{base} {i}")
}
/// Add a track with defaults; returns its id.
@ -528,8 +559,12 @@ impl Session {
.max()
.unwrap_or(0);
self.next_id = self.next_id.max(max_id.saturating_add(1));
truncate_chars(&mut self.name, MAX_NAME_CHARS);
truncate_chars(&mut self.comments, MAX_COMMENT_CHARS);
for t in &mut self.tracks {
t.mixer.sanitize();
truncate_chars(&mut t.name, MAX_NAME_CHARS);
truncate_chars(&mut t.comments, MAX_COMMENT_CHARS);
if t.playlists.is_empty() {
t.playlists.push(crate::Playlist::new(t.name.clone()));
}
@ -537,24 +572,42 @@ impl Session {
t.active_playlist = 0;
}
for p in &mut t.playlists {
truncate_chars(&mut p.name, MAX_NAME_CHARS);
p.clips.retain(|c| c.length > 0);
for c in &mut p.clips {
c.fade_in.len = c.fade_in.len.clamp(0, c.length);
c.fade_out.len = c.fade_out.len.clamp(0, c.length);
if !c.gain_db.is_finite() {
c.gain_db = 0.0;
}
if !c.stretch.is_finite() || c.stretch <= 0.0 {
c.stretch = 1.0;
}
}
p.clips.iter_mut().for_each(Clip::sanitize);
p.sort();
}
for l in &mut t.automation {
l.points.retain(|p| p.value.is_finite());
l.points.iter_mut().for_each(|p| bound(&mut p.at));
l.points.sort_by_key(|p| p.at);
}
}
for m in &mut self.markers {
bound(&mut m.start);
bound(&mut m.end);
truncate_chars(&mut m.name, MAX_NAME_CHARS);
truncate_chars(&mut m.comments, MAX_COMMENT_CHARS);
}
for (at, key) in &mut self.key_signatures {
bound(at);
truncate_chars(key, MAX_NAME_CHARS);
}
self.midi_originals.values_mut().for_each(bound_sequence);
let e = &mut self.edit;
for at in [
&mut e.selection.start,
&mut e.selection.end,
&mut e.timeline_selection.start,
&mut e.timeline_selection.end,
&mut e.playhead,
&mut e.pre_roll,
&mut e.post_roll,
&mut e.zoom.scroll,
&mut self.timecode_start,
] {
bound(at);
}
for v in &mut self.videos {
if !v.frame_rate.is_finite() || v.frame_rate < 0.0 {
v.frame_rate = 0.0;
@ -562,7 +615,12 @@ impl Session {
if !v.duration.is_finite() || v.duration < 0.0 {
v.duration = 0.0;
}
truncate_chars(&mut v.name, MAX_NAME_CHARS);
}
self.busses.iter_mut().for_each(|b| truncate_chars(&mut b.name, MAX_NAME_CHARS));
self.outputs.iter_mut().for_each(|o| truncate_chars(&mut o.name, MAX_NAME_CHARS));
self.groups.iter_mut().for_each(|g| truncate_chars(&mut g.name, MAX_NAME_CHARS));
self.sources.iter_mut().for_each(|s| truncate_chars(&mut s.name, MAX_NAME_CHARS));
// Drop performances whose clip no longer exists anywhere (any playlist).
let clip_ids: std::collections::BTreeSet<u64> =
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))).collect();
@ -570,6 +628,23 @@ impl Session {
if !self.edit.zoom.samples_per_px.is_finite() || self.edit.zoom.samples_per_px <= 0.0 {
self.edit.zoom.samples_per_px = 1024.0;
}
if SampleRate::new(self.sample_rate.hz()).is_err() {
self.sample_rate = SampleRate::default();
}
self.tempo.sanitize();
}
}
/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing
/// a name of a million characters overflows the renderer's buffers.
pub const MAX_NAME_CHARS: usize = 256;
/// The most characters kept of a comment read from a file.
pub const MAX_COMMENT_CHARS: usize = 4096;
/// Cut `s` to at most `max` characters.
pub fn truncate_chars(s: &mut String, max: usize) {
if let Some((end, _)) = s.char_indices().nth(max) {
s.truncate(end);
}
}
@ -641,6 +716,39 @@ mod tests {
assert_eq!(c.source_offset(), 580);
}
#[test]
fn unique_track_names_take_the_first_free_number() {
let mut s = Session::default();
for name in ["Piano", "Piano 1", "Piano 3", "Piano 02", "Pianos 2"] {
s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(name));
}
assert_eq!(s.unique_track_name("Piano"), "Piano 2");
assert_eq!(s.unique_track_name("Organ"), "Organ");
assert_eq!(s.unique_track_name("Audio"), "Audio 1");
}
#[test]
fn names_from_a_file_are_cut_to_a_length_that_can_be_drawn() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
if let Some(tr) = s.track_mut(t) {
tr.name = "é".repeat(1_000);
}
s.comments = "c".repeat(10_000);
let back = Session::from_json(&s.to_json().unwrap()).unwrap();
assert_eq!(back.track(t).map(|tr| tr.name.chars().count()), Some(MAX_NAME_CHARS));
assert_eq!(back.comments.len(), MAX_COMMENT_CHARS);
}
#[test]
fn a_bad_sample_rate_or_tempo_is_repaired_on_load() {
let mut v = serde_json::to_value(Session::default()).unwrap();
v["sample_rate"] = serde_json::json!(0);
v["tempo"]["tempos"] = serde_json::json!([{"tick": 0, "bpm": 4e9}]);
let s = Session::from_json(&v.to_string()).unwrap();
assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default()));
}
#[test]
fn rejects_garbage_and_future_versions() {
assert!(Session::from_json("not json").is_err());

View File

@ -85,6 +85,8 @@ impl TrackKind {
/// Track heights (View › Track Height presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
pub enum TrackHeight {
/// Any height from Micro to Extreme, in points.
Custom(u16),
Micro,
Mini,
Small,
@ -93,7 +95,8 @@ pub enum TrackHeight {
Large,
Jumbo,
Extreme,
Fit,
/// Fit To Window: this many points.
Fit(u16),
}
impl TrackHeight {
@ -108,6 +111,7 @@ impl TrackHeight {
];
pub fn points(self) -> f32 {
match self {
TrackHeight::Custom(points) => f32::from(points).clamp(TrackHeight::Micro.points(), TrackHeight::Extreme.points()),
TrackHeight::Micro => 16.0,
TrackHeight::Mini => 24.0,
TrackHeight::Small => 46.0,
@ -115,11 +119,12 @@ impl TrackHeight {
TrackHeight::Large => 132.0,
TrackHeight::Jumbo => 220.0,
TrackHeight::Extreme => 440.0,
TrackHeight::Fit => 88.0,
TrackHeight::Fit(points) => f32::from(points.max(16)),
}
}
pub fn label(self) -> &'static str {
match self {
TrackHeight::Custom(_) => "custom",
TrackHeight::Micro => "micro",
TrackHeight::Mini => "mini",
TrackHeight::Small => "small",
@ -127,12 +132,24 @@ impl TrackHeight {
TrackHeight::Large => "large",
TrackHeight::Jumbo => "jumbo",
TrackHeight::Extreme => "extreme",
TrackHeight::Fit => "fit to window",
TrackHeight::Fit(_) => "fit to window",
}
}
pub fn from_id(s: &str) -> Option<TrackHeight> {
TrackHeight::ALL.into_iter().find(|h| h.label().eq_ignore_ascii_case(s))
}
/// The preset this many points tall, else a custom height clamped to Micro..Extreme.
pub fn from_points(points: f32) -> TrackHeight {
let p = points.max(TrackHeight::Micro.points()).min(TrackHeight::Extreme.points()).round() as u16;
TrackHeight::ALL.into_iter().find(|h| h.points() == f32::from(p)).unwrap_or(TrackHeight::Custom(p))
}
/// The next preset taller (or shorter) than this height; Extreme (or Micro) stays.
pub fn step(self, taller: bool) -> TrackHeight {
let p = self.points();
let next =
if taller { TrackHeight::ALL.into_iter().find(|h| h.points() > p) } else { TrackHeight::ALL.into_iter().rev().find(|h| h.points() < p) };
next.unwrap_or(self)
}
}
/// The default track colour palette (original, chosen for SoundCraft).
@ -337,4 +354,20 @@ mod tests {
assert_eq!(TrackKind::from_id(k.id()), Some(k));
}
}
#[test]
fn custom_heights_round_to_presets_and_step_through_them() {
assert_eq!(TrackHeight::from_points(88.0), TrackHeight::Medium);
assert_eq!(TrackHeight::from_points(57.4), TrackHeight::Custom(57));
assert_eq!(TrackHeight::from_points(9999.0), TrackHeight::Extreme);
assert_eq!(TrackHeight::from_points(f32::NAN), TrackHeight::Micro);
assert_eq!(TrackHeight::Custom(u16::MAX).points(), 440.0);
assert_eq!(TrackHeight::Custom(57).step(true), TrackHeight::Medium);
assert_eq!(TrackHeight::Custom(57).step(false), TrackHeight::Small);
assert_eq!(TrackHeight::Extreme.step(true), TrackHeight::Extreme);
assert_eq!(TrackHeight::Micro.step(false), TrackHeight::Micro);
let json = serde_json::to_string(&TrackHeight::Custom(57)).unwrap();
assert_eq!(serde_json::from_str::<TrackHeight>(&json).unwrap(), TrackHeight::Custom(57));
assert_eq!(serde_json::from_str::<TrackHeight>("\"Large\"").unwrap(), TrackHeight::Large);
}
}

View File

@ -15,26 +15,29 @@
//! Plugins and threads: hosted third-party plugins (CLAP/VST3) are created, given their stored
//! state and prepared on the thread that calls [`Player::update_session`] (the UI thread), and
//! travel to the audio thread together with the session snapshot that needs them; the mix
//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires come back
//! on a return channel and are dropped on the UI thread ([`Player::idle`]). Plugin states for
//! saving are read on request ([`Player::capture_states`]: a command with a reply; the audio
//! thread answers between two blocks, never blocking). Plugin editors are driven on the UI thread
//! engine adopts them (see `soundcraft_mix`'s crate docs). Instances the engine retires, and the
//! session snapshots the callback replaces, come back on return channels and are dropped on the UI
//! thread ([`Player::idle`]). Plugin editors are driven, and plugin states for saving read
//! ([`Player::capture_states`]), on the UI thread
//! through handles taken from each instance before it leaves ([`Player::open_editor`]).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod record;
use soundcraft_dsp::{Plugin, PluginEditor};
use soundcraft_dsp::{Plugin, PluginEditor, PluginStateReader};
use soundcraft_mix::{MAX_CHANNELS, MixEngine, PreparedInstance, StripMeter, itu_stereo_fold, main_channels};
use soundcraft_model::{Session, TrackId};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
use std::sync::mpsc::{Receiver, Sender, channel};
use std::sync::mpsc::{Receiver, Sender, SyncSender, channel, sync_channel};
use std::sync::{Arc, Mutex, PoisonError};
const BLOCK: usize = 512;
/// Room for replaced snapshots on their way back; `update_session` empties it before each send.
const OLD_SESSIONS: usize = 16;
/// The realtime audio thread marker lives in the mix engine, which carries it into the strips it
/// renders on worker threads; the cpal callbacks here set it.
pub use soundcraft_mix::{mark_audio_thread, on_audio_thread};
@ -55,8 +58,8 @@ pub type EditorEdit = (TrackId, usize, String, f32);
enum Cmd {
/// A new snapshot plus the third-party instances it needs (created on the UI thread).
Session(Arc<Session>, Vec<PreparedInstance>),
/// Read every plugin state and reply.
CaptureStates(Sender<PluginStates>),
/// Where the callback sends the snapshots it replaces (freeing one takes thousands of frees).
OldSessions(SyncSender<Arc<Session>>),
Input(Arc<record::InputRing>),
Recording(bool),
Play {
@ -76,6 +79,73 @@ struct Shared {
meters: Mutex<MeterSnapshot>,
}
/// What stopped the audio output.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputTrouble {
/// A block panicked; the output stays silent.
Bug,
/// The device went away.
Gone,
/// The device stopped asking for audio while playing.
Stalled,
}
/// How the audio output is doing, for the UI.
#[derive(Default)]
struct Fault {
/// A block panicked: output stays silent from then on.
failed: AtomicBool,
gone: AtomicBool,
/// Blocks so far, to tell a device that stopped asking for audio.
beats: std::sync::atomic::AtomicU64,
/// The UI has been told.
reported: AtomicBool,
}
impl Fault {
/// What stopped the output, once.
fn trouble(&self, stalled: bool) -> Option<OutputTrouble> {
let trouble = if self.failed.load(Ordering::Relaxed) {
OutputTrouble::Bug
} else if self.gone.load(Ordering::Relaxed) {
OutputTrouble::Gone
} else if stalled {
OutputTrouble::Stalled
} else {
return None;
};
(!self.reported.swap(true, Ordering::Relaxed)).then_some(trouble)
}
}
/// How long a playing output may go without a block before it counts as stalled.
#[cfg(not(target_arch = "wasm32"))]
const STALL: std::time::Duration = std::time::Duration::from_secs(2);
/// True once the block count `beats` has stood still for `STALL` while playing; `seen` is the
/// count last seen and since when.
#[cfg(not(target_arch = "wasm32"))]
fn stalled(seen: &mut Option<(u64, std::time::Instant)>, beats: u64, playing: bool) -> bool {
match *seen {
Some((b, since)) if playing && b == beats => since.elapsed() >= STALL,
_ => {
*seen = Some((beats, std::time::Instant::now()));
false
}
}
}
/// Fill one block with `fill`. If it panics, the block and every later one are silent and the
/// transport stops, rather than the panic taking the app down.
fn guarded_block(fault: &Fault, shared: &Shared, out: &mut [f32], fill: impl FnOnce(&mut [f32])) {
fault.beats.fetch_add(1, Ordering::Relaxed);
if fault.failed.load(Ordering::Relaxed) || std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fill(out))).is_err() {
fault.failed.store(true, Ordering::Relaxed);
shared.playing.store(false, Ordering::Relaxed);
out.fill(0.0);
}
}
/// State owned by the audio callback.
struct AudioState {
rx: Receiver<Cmd>,
@ -97,6 +167,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 +183,7 @@ impl AudioState {
let nch = main_channels(&session);
AudioState {
garbage,
old_sessions: None,
fold: fold_gains(&session),
rx,
shared,
@ -136,10 +208,6 @@ impl AudioState {
match c {
Cmd::Input(r) => self.input = Some(r),
Cmd::Recording(r) => self.recording = r,
Cmd::CaptureStates(reply) => {
self.mix.ensure_synced(&self.session);
let _ = reply.send(self.mix.capture_states());
}
Cmd::Session(s, instances) => {
changed = true;
if (s.sample_rate.as_f64() - self.session.sample_rate.as_f64()).abs() > 0.5 {
@ -155,8 +223,12 @@ impl AudioState {
self.read = 0.0;
}
self.fold = fold_gains(&s);
self.session = s;
let old = std::mem::replace(&mut self.session, s);
if let Some(tx) = &self.old_sessions {
let _ = tx.try_send(old);
}
}
Cmd::OldSessions(tx) => self.old_sessions = Some(tx),
Cmd::Play { from, end, looped } => {
self.pos = from;
self.end = end;
@ -176,12 +248,13 @@ impl AudioState {
self.pos = at;
self.pending_len = 0;
self.read = 0.0;
self.mix.reset();
}
}
}
if changed {
// Place adopted instances now (not only once playback starts), so state captures and
// editors see them; `sync` creates no third-party plugins here.
// Place adopted instances now (not only once playback starts), so editors see them;
// `sync` creates no third-party plugins here.
self.mix.ensure_synced(&self.session);
}
self.send_garbage();
@ -325,19 +398,44 @@ struct Side {
rate: f32,
/// Editor handles of the live instances.
editors: HashMap<(TrackId, usize), Box<dyn PluginEditor>>,
/// State readers of the live instances.
states: BTreeMap<(TrackId, usize), Box<dyn PluginStateReader>>,
/// Instances that refused their stored state: saves keep that state until the plugin's editor
/// is opened.
refused: HashSet<(TrackId, usize)>,
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).
fn new(rate: f32, garbage: Receiver<Vec<Box<dyn Plugin>>>) -> Side {
Side { shadow: HashMap::new(), rate, editors: HashMap::new(), states: BTreeMap::new(), refused: HashSet::new(), garbage, old_sessions: None }
}
/// Drops retired instances (closing their editors first, inside the plugin's drop) and
/// replaced session snapshots.
fn collect_garbage(&mut self) {
while let Ok(g) = self.garbage.try_recv() {
drop(g);
for p in g {
log::debug!("plugin instance released: {}", p.info().name);
drop(p);
}
}
if let Some(rx) = &self.old_sessions {
rx.try_iter().for_each(drop);
}
}
fn return_old_sessions(&mut self, tx: &Sender<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)) {
self.shadow.remove(&key);
self.states.remove(&key);
self.refused.remove(&key);
if let Some(mut e) = self.editors.remove(&key) {
e.close();
}
@ -347,6 +445,10 @@ impl Side {
/// The transport and audio device.
pub struct Player {
shared: Arc<Shared>,
fault: Arc<Fault>,
/// The block count last seen while playing, and since when.
#[cfg(not(target_arch = "wasm32"))]
watch: Mutex<Option<(u64, std::time::Instant)>>,
tx: Sender<Cmd>,
side: Mutex<Side>,
#[cfg(not(target_arch = "wasm32"))]
@ -365,18 +467,32 @@ impl Player {
/// Open the default output device. Never fails: without a device it runs a silent clock.
pub fn new(session: Arc<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
}
/// What stopped the audio output, once: a bug in the engine, or the device went away or
/// stopped asking for audio while playing.
pub fn trouble(&self) -> Option<OutputTrouble> {
#[cfg(not(target_arch = "wasm32"))]
let stalled = {
let playing = self.shared.playing.load(Ordering::Relaxed) && !self.silent;
stalled(&mut self.watch.lock().unwrap_or_else(PoisonError::into_inner), self.fault.beats.load(Ordering::Relaxed), playing)
};
#[cfg(target_arch = "wasm32")]
let stalled = false;
self.fault.trouble(stalled)
}
fn side(&self) -> std::sync::MutexGuard<'_, Side> {
self.side.lock().unwrap_or_else(PoisonError::into_inner)
}
fn open(session: Arc<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::new(session.sample_rate.as_f64() as f32, grx));
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
@ -391,10 +507,16 @@ impl Player {
let rate = config.sample_rate.0;
let channels = usize::from(config.channels);
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
let fault = Arc::new(Fault::default());
let (failed, guard) = (Arc::clone(&fault), Arc::clone(&shared));
// cpal may call this on the audio thread (on ALSA in a loop while the device
// stays broken): marked, so the logger hands the record off instead of writing it.
let err_fn = |e: cpal::StreamError| {
let gone = Arc::clone(&fault);
let err_fn = move |e: cpal::StreamError| {
mark_audio_thread();
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
gone.gone.store(true, Ordering::Relaxed);
}
log::warn!("audio stream error: {e}");
};
use cpal::traits::{DeviceTrait, StreamTrait};
@ -402,7 +524,7 @@ impl Player {
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
mark_audio_thread();
state.fill(data, channels);
guarded_block(&failed, &guard, data, |d| state.fill(d, channels));
},
err_fn,
None,
@ -414,6 +536,9 @@ impl Player {
log::info!("audio device: {name} @ {rate} Hz, {channels} ch");
Player {
shared,
fault,
#[cfg(not(target_arch = "wasm32"))]
watch: Mutex::new(None),
tx,
side,
_stream: Some(stream),
@ -465,6 +590,8 @@ impl Player {
) -> Player {
let rate = session.sample_rate.hz();
let mut state = AudioState::new(rx, Arc::clone(&shared), session, f64::from(rate), gtx);
let fault = Arc::new(Fault::default());
let failed = Arc::clone(&fault);
let spawn = std::thread::Builder::new().name("soundcraft-clock".into()).spawn(move || {
let mut buf = vec![0.0f32; 4096];
let mut last = std::time::Instant::now();
@ -475,7 +602,8 @@ impl Player {
last = now;
let n = (frames * 2).min(buf.len());
if let Some(b) = buf.get_mut(..n) {
state.fill(b, 2);
let shared = Arc::clone(&state.shared);
guarded_block(&failed, &shared, b, |b| state.fill(b, 2));
}
if Arc::strong_count(&state.shared) <= 1 {
break;
@ -487,6 +615,8 @@ impl Player {
}
Player {
shared,
fault,
watch: Mutex::new(None),
tx,
side,
_stream: None,
@ -508,6 +638,7 @@ impl Player {
) -> Player {
Player {
shared,
fault: Arc::default(),
tx,
side,
_stream: None,
@ -525,22 +656,9 @@ impl Player {
let _ = self.tx.send(Cmd::Session(s, instances));
}
/// Reads the state of every live third-party plugin (for saving the session). Waits briefly
/// for the audio thread to answer between two blocks; empty when it does not answer.
/// Reads the state of every live third-party plugin, for saving the session.
pub fn capture_states(&self) -> PluginStates {
self.side().collect_garbage();
#[cfg(not(target_arch = "wasm32"))]
{
let (tx, rx) = channel();
if self.tx.send(Cmd::CaptureStates(tx)).is_err() {
return Vec::new();
}
rx.recv_timeout(std::time::Duration::from_secs(2)).unwrap_or_default()
}
#[cfg(target_arch = "wasm32")]
{
Vec::new()
}
self.side().capture_states()
}
/// Per-UI-frame housekeeping: drops retired instances and services plugin editors. Returns
@ -564,10 +682,7 @@ impl Player {
/// Opens the plugin's own editor window. Main thread only.
pub fn open_editor(&self, track: TrackId, slot: usize) -> Result<(), String> {
match self.side().editors.get_mut(&(track, slot)) {
Some(e) => e.open(),
None => Err("this plugin has no editor (or it is not loaded)".into()),
}
self.side().open_editor((track, slot))
}
pub fn close_editor(&self, track: TrackId, slot: usize) {
@ -656,6 +771,17 @@ fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
}
impl Side {
fn capture_states(&mut self) -> PluginStates {
self.states.iter_mut().filter(|(k, _)| !self.refused.contains(*k)).filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect()
}
fn open_editor(&mut self, key: (TrackId, usize)) -> Result<(), String> {
let e = self.editors.get_mut(&key).ok_or_else(|| "this plugin has no editor (or it is not loaded)".to_string())?;
e.open()?;
self.refused.remove(&key);
Ok(())
}
/// Creates the third-party instances `s` needs that the audio thread does not have yet.
fn prepare(&mut self, s: &Session) -> Vec<PreparedInstance> {
self.collect_garbage();
@ -681,11 +807,18 @@ impl Side {
}
self.forget(key);
let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);
log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some());
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
if let Some(mut plugin) = made {
if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {
if let Some(e) = plugin.editor() {
self.editors.insert(key, e);
}
if let Some(r) = plugin.state_reader() {
self.states.insert(key, r);
}
if refused_state {
self.refused.insert(key);
}
out.push(PreparedInstance { track: spec.track, slot: spec.slot, id: spec.id, ch: spec.ch, plugin });
}
}
@ -755,7 +888,7 @@ mod tests {
let keep = TrackId(1);
let stale = TrackId(2);
let mut source = MeterSnapshot::default();
source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 });
source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0, ..StripMeter::default() });
source.main.peaks = vec![0.75, 0.5];
let mut out = MeterSnapshot::default();
out.tracks.insert(stale, StripMeter::default());
@ -765,6 +898,46 @@ mod tests {
assert_eq!(out.main, source.main);
}
#[test]
fn a_block_that_panics_goes_silent_and_is_reported_once() {
let shared = Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(true),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
};
let fault = Fault::default();
let mut out = [0.5f32; 8];
guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25));
assert_eq!((out, fault.trouble(false)), ([0.25; 8], None));
guarded_block(&fault, &shared, &mut out, |_| std::panic::panic_any("a bug in the mix"));
guarded_block(&fault, &shared, &mut out, |o| o.fill(0.25));
assert_eq!(out, [0.0; 8]);
assert!(!shared.playing.load(Ordering::Relaxed));
assert_eq!((fault.trouble(false), fault.trouble(false)), (Some(OutputTrouble::Bug), None));
assert_eq!(fault.beats.load(Ordering::Relaxed), 3);
}
#[test]
fn a_lost_or_stalled_device_is_reported_once() {
let ago = |seen: &mut Option<(u64, std::time::Instant)>| {
if let Some(s) = seen {
s.1 = std::time::Instant::now().checked_sub(STALL).unwrap();
}
};
let mut seen = None;
assert!(!stalled(&mut seen, 5, true));
ago(&mut seen);
assert!(stalled(&mut seen, 5, true));
assert!(!stalled(&mut seen, 6, true), "it asked again");
ago(&mut seen);
assert!(!stalled(&mut seen, 6, false), "stopped, so nothing to ask for");
assert_eq!(Fault::default().trouble(true), Some(OutputTrouble::Stalled));
let fault = Fault::default();
fault.gone.store(true, Ordering::Relaxed);
assert_eq!((fault.trouble(false), fault.trouble(true)), (Some(OutputTrouble::Gone), None));
}
#[test]
fn audio_state_plays_and_stops_at_end() {
let s = Arc::new(soundcraft_model::Session::default());
@ -837,32 +1010,6 @@ mod tests {
assert_eq!(shared.meters.lock().unwrap().main.peaks.len(), 6);
}
struct Stateful(Box<dyn Plugin>);
impl Plugin for Stateful {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.0.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.0.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.0.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.0.set_param(id, v)
}
fn param(&self, id: &str) -> Option<f32> {
self.0.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.0.process(io, frames);
}
fn save_state(&mut self) -> Option<Vec<u8>> {
Some(b"blob".to_vec())
}
}
#[test]
fn instances_arrive_with_the_session_and_leave_through_the_return_channel() {
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
@ -879,14 +1026,10 @@ mod tests {
let (tx, rx) = channel();
let (gtx, grx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx);
let plugin: Box<dyn Plugin> = Box::new(Stateful(soundcraft_dsp::create("gain").unwrap()));
let plugin = soundcraft_dsp::create("gain").unwrap();
tx.send(Cmd::Session(Arc::clone(&s), vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin }])).unwrap();
let (rtx, rrx) = channel();
tx.send(Cmd::CaptureStates(rtx)).unwrap();
// Stopped: the callback only drains commands (adopting the instance) and answers.
let mut out = vec![0.0f32; 256];
st.fill(&mut out, 2);
assert_eq!(rrx.try_recv().unwrap(), vec![(t, 1, b"blob".to_vec())]);
assert!(grx.try_recv().is_err(), "nothing retired yet");
// A session without the insert retires the instance to the return channel.
tx.send(Cmd::Session(Arc::new(soundcraft_model::Session::default()), Vec::new())).unwrap();
@ -895,13 +1038,7 @@ mod tests {
// A sample-rate change rebuilds the engine without dropping plugins on this thread.
tx.send(Cmd::Session(
Arc::clone(&s),
vec![PreparedInstance {
track: t,
slot: 1,
id: "clap:test.fake".into(),
ch: 2,
plugin: Box::new(Stateful(soundcraft_dsp::create("gain").unwrap())),
}],
vec![PreparedInstance { track: t, slot: 1, id: "clap:test.fake".into(), ch: 2, plugin: soundcraft_dsp::create("gain").unwrap() }],
))
.unwrap();
st.fill(&mut out, 2);
@ -912,11 +1049,51 @@ mod tests {
assert_eq!(grx.try_recv().unwrap().len(), 1);
}
#[test]
fn plugin_states_are_read_without_the_audio_thread() {
let mut side = Side::new(48_000.0, channel().1);
let (a, b) = (TrackId(1), TrackId(2));
side.states.insert((b, 0), Box::new(|| Some(b"b0".to_vec())));
side.states.insert((a, soundcraft_mix::INSTRUMENT_SLOT), Box::new(|| Some(b"synth".to_vec())));
side.states.insert((a, 3), Box::new(|| Some(b"a3".to_vec())));
side.states.insert((a, 4), Box::new(|| None));
let want = vec![(a, 3, b"a3".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec()), (b, 0, b"b0".to_vec())];
assert_eq!(side.capture_states(), want);
side.forget((a, 3));
assert_eq!(side.capture_states(), want[1..]);
}
struct Window;
impl PluginEditor for Window {
fn open(&mut self) -> Result<(), String> {
Ok(())
}
fn close(&mut self) {}
fn is_open(&self) -> bool {
false
}
fn idle(&mut self) -> Vec<(String, f32)> {
Vec::new()
}
}
#[test]
fn a_refused_state_is_kept_until_the_editor_opens() {
let mut side = Side::new(48_000.0, channel().1);
let k = (TrackId(1), 0);
side.states.insert(k, Box::new(|| Some(b"defaults".to_vec())));
side.editors.insert(k, Box::new(Window));
side.refused.insert(k);
assert!(side.capture_states().is_empty());
side.open_editor(k).unwrap();
assert_eq!(side.capture_states(), vec![(TrackId(1), 0, b"defaults".to_vec())]);
}
#[test]
fn side_creates_each_missing_instance_once() {
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
let (_gtx, grx) = channel();
let mut side = Side { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx };
let mut side = Side::new(48_000.0, channel().1);
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:does.not.exist"));
@ -930,6 +1107,34 @@ mod tests {
assert!(side.shadow.is_empty());
}
#[test]
fn replaced_sessions_are_freed_on_the_ui_thread() {
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let (gtx, grx) = channel();
let mut st = AudioState::new(rx, shared, Arc::new(soundcraft_model::Session::default()), 48_000.0, gtx);
let mut side = Side::new(48_000.0, grx);
side.return_old_sessions(&tx);
let mut out = vec![0.0f32; 256];
let mut watched = Vec::new();
for _ in 0..3 * OLD_SESSIONS {
let s = Arc::new(soundcraft_model::Session::default());
watched.push(Arc::downgrade(&s));
side.collect_garbage();
tx.send(Cmd::Session(s, Vec::new())).unwrap();
st.fill(&mut out, 2);
let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i));
assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread");
}
side.collect_garbage();
assert_eq!(watched.iter().filter(|w| w.strong_count() > 0).count(), 1, "only the live snapshot is left");
}
#[test]
fn looping_wraps() {
let s = Arc::new(soundcraft_model::Session::default());
@ -949,6 +1154,49 @@ mod tests {
assert!(shared.playing.load(Ordering::Relaxed));
}
#[test]
fn locate_while_playing_drops_the_old_position_audio() {
use soundcraft_model::{ChannelFormat, Clip, Insert, SourceAudio, SourceId, TrackKind};
for (plugin, params) in [("compressor", &[("lookahead", 5.0), ("ratio", 1.0)][..]), ("room_reverb", &[][..])] {
let mut s = soundcraft_model::Session::default();
s.edit.click = false;
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(
SourceId(5),
Arc::new(SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })),
);
let id = s.new_clip_id();
let tr = s.track_mut(t).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::audio(id, "x", SourceId(5), 0, 0, 4800));
let mut ins = Insert::new(plugin);
for (k, v) in params {
ins.params.insert((*k).into(), *v);
}
tr.mixer.inserts[0] = Some(ins);
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
let mut out = vec![0.0f32; 2 * 512];
let mut before = 0.0f32;
for _ in 0..9 {
st.fill(&mut out, 2);
before = out.iter().fold(before, |m, x| m.max(x.abs()));
}
assert!(before > 0.1, "{plugin}: the clip plays before the locate");
tx.send(Cmd::Locate(1_000_000)).unwrap();
for _ in 0..8 {
st.fill(&mut out, 2);
assert!(out.iter().all(|x| x.abs() < 1e-6), "{plugin}: audio from before the locate");
}
}
}
#[test]
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
use soundcraft_model::{ChannelFormat, TrackKind};

View File

@ -8,8 +8,8 @@
use std::fs::File;
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::{Duration, Instant};
use soundcraft_model::TrackId;
@ -17,7 +17,29 @@ use soundcraft_model::TrackId;
/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met.
pub type Takes = (Vec<(TrackId, PathBuf)>, Option<String>);
/// Frames each block of a [`LiveTake`] sums up.
pub const LIVE_BLOCK: usize = 128;
/// Blocks a live take keeps per channel at most (three hours at 48 kHz); its file records on.
const LIVE_BLOCKS: usize = 1 << 22;
/// A take while it records, summed up for drawing: per channel, the (min, max) of each
/// [`LIVE_BLOCK`] frames so far.
pub type LiveTake = Vec<Vec<(f32, f32)>>;
const RING_SECONDS: usize = 4;
/// How long a recording input may go without a callback before it counts as gone.
const STALL: Duration = Duration::from_secs(2);
const GONE: u8 = 1;
const BROKEN: u8 = 2;
/// Why the input can't go on.
#[derive(Debug, Clone, PartialEq)]
pub enum Trouble {
/// The device went away or stopped sending audio, or its callback hit a bug.
Device(String),
/// Writing a take failed (a full disk, say).
Disk(String),
}
/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots
/// are atomics, so it needs no lock and no `unsafe`.
@ -57,19 +79,44 @@ impl CaptureRing {
}
}
/// An input sample, or silence for NaN and infinities, which would poison the take and the mix.
fn finite(x: f32) -> f32 {
if x.is_finite() { x } else { 0.0 }
}
struct Shared {
armed: AtomicBool,
ring: CaptureRing,
/// Samples lost while the ring was full.
dropped: AtomicUsize,
/// Input callbacks so far, to tell a device that stopped sending.
beats: AtomicU64,
/// Set from the input stream when it can't go on: `GONE` or `BROKEN`.
fault: AtomicU8,
/// The first failure writing a take, until it's reported.
failed: Mutex<Option<String>>,
}
impl Shared {
fn new(ring: CaptureRing) -> Shared {
let (beats, fault, failed) = (AtomicU64::new(0), AtomicU8::new(0), Mutex::new(None));
Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0), beats, fault, failed }
}
/// Run the input callback's `work`. After a panic in it, the input is broken and does
/// nothing more.
fn guarded(&self, work: impl FnOnce()) {
self.beats.fetch_add(1, Ordering::Relaxed);
if self.fault.load(Ordering::Relaxed) != BROKEN && std::panic::catch_unwind(std::panic::AssertUnwindSafe(work)).is_err() {
self.fault.store(BROKEN, Ordering::Relaxed);
}
}
/// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued
/// as silence before the next block, so the take keeps its timing.
fn capture(&self, data: &[f32], owed: &mut usize) {
*owed -= self.ring.push(*owed, |_| 0.0);
let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 };
let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().map_or(0.0, finite)) } else { 0 };
*owed = owed.saturating_add(data.len() - kept);
self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed);
}
@ -84,7 +131,10 @@ pub struct Recorder {
pub device_name: String,
pub sample_rate: u32,
pub channels: usize,
writer: Option<std::thread::JoinHandle<Takes>>,
/// The take writer, and what each track's take holds so far.
writer: Option<(std::thread::JoinHandle<Takes>, Vec<(TrackId, Arc<Mutex<LiveTake>>)>)>,
/// The callback count last seen, and since when.
seen: (u64, Instant),
}
impl Recorder {
@ -103,8 +153,8 @@ impl Recorder {
let config = cfg.config();
let channels = usize::from(config.channels).max(1);
let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) });
let sh = Arc::clone(&shared);
let shared = Arc::new(Shared::new(ring));
let (sh, errors) = (Arc::clone(&shared), Arc::clone(&shared));
let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
let mon = Arc::clone(&monitor);
let mut owed = 0;
@ -113,23 +163,29 @@ impl Recorder {
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
mon.push(data);
if sh.armed.load(Ordering::Acquire) {
sh.capture(data, &mut owed);
} else {
owed = 0;
}
sh.guarded(|| {
mon.push(data);
if sh.armed.load(Ordering::Acquire) {
sh.capture(data, &mut owed);
} else {
owed = 0;
}
});
},
|e| {
move |e| {
// Possibly the audio thread (see `crate::mark_audio_thread`).
crate::mark_audio_thread();
if matches!(e, cpal::StreamError::DeviceNotAvailable) {
let _ = errors.fault.compare_exchange(0, GONE, Ordering::Relaxed, Ordering::Relaxed);
}
log::warn!("input stream error: {e}");
},
None,
)
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None })
let seen = (0, Instant::now());
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None, seen })
}
#[cfg(target_os = "freebsd")]
@ -145,12 +201,14 @@ impl Recorder {
}
let takes =
takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::<Result<Vec<_>, _>>()?;
let live = takes.iter().map(|t| (t.id, Arc::clone(&t.live))).collect();
let ring = &self.shared.ring;
ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release);
*self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = None;
self.shared.armed.store(true, Ordering::Release);
let (shared, channels) = (Arc::clone(&self.shared), self.channels);
match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) {
Ok(w) => self.writer = Some(w),
Ok(w) => self.writer = Some((w, live)),
Err(e) => {
self.shared.armed.store(false, Ordering::Release);
return Err(e.to_string());
@ -159,9 +217,40 @@ impl Recorder {
Ok(())
}
/// What the take recording onto `track` holds so far.
pub fn live_take(&self, track: TrackId) -> Option<MutexGuard<'_, LiveTake>> {
let (_, take) = self.writer.as_ref()?.1.iter().find(|(id, _)| *id == track)?;
Some(take.lock().unwrap_or_else(PoisonError::into_inner))
}
/// Why the input can't go on, if it can't: the device went away or, while recording, stopped
/// sending audio; its callback hit a bug; or writing a take failed (reported once).
pub fn trouble(&mut self) -> Option<Trouble> {
if let Some(e) = self.shared.failed.lock().unwrap_or_else(PoisonError::into_inner).take() {
return Some(Trouble::Disk(e));
}
let name = &self.device_name;
match self.shared.fault.load(Ordering::Relaxed) {
GONE => return Some(Trouble::Device(format!("“{name}” was disconnected"))),
BROKEN => return Some(Trouble::Device("the audio input hit an internal error (see the log)".into())),
_ => {}
}
let beats = self.shared.beats.load(Ordering::Relaxed);
if beats != self.seen.0 || !self.shared.armed.load(Ordering::Relaxed) {
self.seen = (beats, Instant::now());
}
(self.seen.1.elapsed() >= STALL).then(|| Trouble::Device(format!("“{name}” stopped sending audio")))
}
/// Input the current take has lost to a slow disk so far, in milliseconds.
pub fn lost_ms(&self) -> u64 {
let frames = self.shared.dropped.load(Ordering::Relaxed) / self.channels.max(1);
(frames as u64).saturating_mul(1000) / u64::from(self.sample_rate.max(1))
}
pub fn finish(&mut self) -> Takes {
self.shared.armed.store(false, Ordering::Release);
let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default();
let (takes, problem) = self.writer.take().and_then(|(w, _)| w.join().ok()).unwrap_or_default();
let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1);
let slow =
(lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize));
@ -176,7 +265,7 @@ impl Drop for Recorder {
}
fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) -> Takes {
let (mut block, mut header_written) = (Vec::new(), Instant::now());
let (mut block, mut header_written, mut reported) = (Vec::new(), Instant::now(), false);
loop {
let stopping = !shared.armed.load(Ordering::Acquire);
// The ring holds interleaved samples and a pop can end mid-frame: write whole frames and
@ -185,6 +274,10 @@ fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) ->
let whole = block.len() - block.len() % channels.max(1);
takes.iter_mut().for_each(|t| t.write(&block[..whole], channels));
block.drain(..whole);
if !reported && let Some(e) = takes.iter().find_map(|t| t.error.clone()) {
*shared.failed.lock().unwrap_or_else(PoisonError::into_inner) = Some(e);
reported = true;
}
if stopping {
break;
}
@ -216,13 +309,15 @@ struct TakeWriter {
rate: u32,
frames: u64,
error: Option<String>,
live: Arc<Mutex<LiveTake>>,
}
impl TakeWriter {
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path));
let file = file.map_err(|e| format!("{}: {e}", path.display()))?;
let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None };
let live = Arc::new(Mutex::new(vec![Vec::new(); inputs.len()]));
let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None, live };
take.write_header();
take.error.take().map_or(Ok(take), Err)
}
@ -238,11 +333,30 @@ impl TakeWriter {
.flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes()))
.collect();
match self.file.write_all(&bytes) {
Ok(()) => self.frames += (block.len() / channels) as u64,
Ok(()) => {
self.summarize(block, channels);
self.frames += (block.len() / channels) as u64;
}
Err(e) => self.fail(e),
}
}
/// Add this take's channels of `block` to its live take.
fn summarize(&self, block: &[f32], channels: usize) {
let mut live = self.live.lock().unwrap_or_else(PoisonError::into_inner);
for (f, n) in block.chunks_exact(channels).zip(self.frames..) {
let at = (n / LIVE_BLOCK as u64) as usize;
for (blocks, &i) in live.iter_mut().zip(&self.inputs) {
let v = f.get(i).copied().unwrap_or(0.0);
match blocks.get_mut(at) {
Some(b) => *b = (b.0.min(v), b.1.max(v)),
None if at < LIVE_BLOCKS => blocks.push((v, v)),
None => {}
}
}
}
}
fn write_header(&mut self) {
let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) {
Ok(h) => h,
@ -269,7 +383,7 @@ mod tests {
#[test]
fn a_full_ring_owes_the_lost_samples_as_silence() {
let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) };
let shared = Shared::new(CaptureRing::new(8));
let (mut owed, mut out) = (0, Vec::new());
shared.capture(&[1.0; 6], &mut owed);
shared.capture(&[2.0; 4], &mut owed);
@ -280,6 +394,75 @@ mod tests {
assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2));
}
#[test]
fn nan_and_infinite_input_records_as_silence() {
let shared = Shared::new(CaptureRing::new(8));
let (mut owed, mut out) = (0, Vec::new());
shared.capture(&[0.5, f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.5], &mut owed);
shared.ring.pop_all(&mut out);
assert_eq!(out, [0.5, 0.0, 0.0, 0.0, -0.5]);
}
fn recorder(shared: &Arc<Shared>) -> Recorder {
Recorder {
shared: Arc::clone(shared),
monitor: InputRing::new(1, 48_000),
#[cfg(not(target_os = "freebsd"))]
_stream: None,
device_name: "Mic In".into(),
sample_rate: 48_000,
channels: 1,
writer: None,
seen: (0, Instant::now()),
}
}
#[test]
fn a_bug_a_stall_or_a_lost_device_is_trouble() {
let shared = Arc::new(Shared::new(CaptureRing::new(64)));
let mut r = recorder(&shared);
shared.armed.store(true, Ordering::Release);
assert_eq!(r.trouble(), None);
r.seen.1 = Instant::now().checked_sub(STALL).unwrap();
assert_eq!(r.trouble(), Some(Trouble::Device("“Mic In” stopped sending audio".into())));
shared.guarded(|| {});
assert_eq!(r.trouble(), None, "it sent again");
shared.armed.store(false, Ordering::Release);
r.seen.1 = Instant::now().checked_sub(STALL).unwrap();
assert_eq!(r.trouble(), None, "a quiet input that isn't recording is fine");
shared.guarded(|| std::panic::panic_any("a bug in the input"));
let mut ran = false;
shared.guarded(|| ran = true);
assert!(!ran);
assert_eq!(r.trouble(), Some(Trouble::Device("the audio input hit an internal error (see the log)".into())));
let gone = Arc::new(Shared::new(CaptureRing::new(64)));
gone.fault.store(GONE, Ordering::Relaxed);
assert_eq!(recorder(&gone).trouble(), Some(Trouble::Device("“Mic In” was disconnected".into())));
}
#[test]
fn a_failed_write_is_trouble_while_still_recording() {
let dir = std::env::temp_dir().join(format!("soundcraft-failed-take-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut take = TakeWriter::create(TrackId(1), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap();
take.file = File::open(&take.path).unwrap();
let shared = Arc::new(Shared::new(CaptureRing::new(1 << 12)));
shared.armed.store(true, Ordering::Release);
let mut r = recorder(&shared);
let sh = Arc::clone(&shared);
let writer = std::thread::spawn(move || write_takes(&sh, vec![take], 1));
shared.capture(&[0.5; 480], &mut 0);
let trouble = (0..300).find_map(|_| {
std::thread::sleep(Duration::from_millis(10));
r.trouble()
});
assert!(matches!(&trouble, Some(Trouble::Disk(e)) if e.contains("Vox_01.wav")), "{trouble:?}");
assert_eq!(r.trouble(), None, "reported once");
shared.armed.store(false, Ordering::Release);
assert!(writer.join().unwrap().1.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn takes_stream_into_their_files() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
@ -292,7 +475,8 @@ mod tests {
vox.write(&[0.25; 150], 3);
assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen");
let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) });
let shared = Arc::new(Shared::new(CaptureRing::new(1 << 16)));
shared.armed.store(true, Ordering::Release);
let sh = Arc::clone(&shared);
let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3));
let input: Vec<f32> = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect();
@ -306,6 +490,19 @@ mod tests {
assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_sums_itself_up_for_drawing_while_it_records() {
let dir = std::env::temp_dir().join(format!("soundcraft-live-take-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut take = TakeWriter::create(TrackId(4), vec![2, 0], dir.join("Gtr_01.wav"), 48_000).unwrap();
let input: Vec<f32> = (0..300).flat_map(|f| [f as f32, -1.0, -(f as f32)]).collect();
take.write(&input[..3 * 100], 3);
take.write(&input[3 * 100..], 3);
let live = take.live.lock().unwrap().clone();
assert_eq!(live, [[(-127.0, 0.0), (-255.0, -128.0), (-299.0, -256.0)], [(0.0, 127.0), (128.0, 255.0), (256.0, 299.0)]]);
let _ = std::fs::remove_dir_all(&dir);
}
}
/// Ring state behind the lock: interleaved input frames at the input device's rate, plus the
@ -342,7 +539,7 @@ impl InputRing {
pub fn push(&self, samples: &[f32]) {
if let Ok(mut s) = self.state.try_lock() {
s.data.extend(samples.iter().copied());
s.data.extend(samples.iter().copied().map(finite));
while s.data.len() > self.cap {
s.data.pop_front();
}
@ -437,4 +634,14 @@ mod ring_tests {
ring.pop_into(&mut out, 16, 48_000);
assert!(out[0].iter().all(|v| *v == 0.0));
}
/// NaN and infinities from the device monitor as silence.
#[test]
fn monitor_silences_nan_and_infinite_input() {
let ring = InputRing::new(1, 48_000);
ring.push(&[0.5, f32::NAN, f32::INFINITY, -0.5, 0.0, 0.0, 0.0, 0.0]);
let mut out = vec![vec![1.0f32; 4]];
ring.pop_into(&mut out, 4, 48_000);
assert_eq!(out[0], [0.5, 0.0, 0.0, -0.5]);
}
}

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> =
@ -133,14 +142,35 @@ mod tests {
(SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps30)
}
#[test]
fn huge_typed_positions_parse_without_overflowing() {
let (sr, map, rate) = ctx();
for (text, fmt) in [
("999999999999999999:0", TimeFormat::Timecode),
("100000000000000:00:00:00", TimeFormat::Timecode),
("-9223372036854775808", TimeFormat::BarsBeats),
] {
let _ = parse_position(text, fmt, sr, &map, rate, 0, 0);
}
}
#[test]
fn formats_like_pro_tools_counters() {
let (sr, map, rate) = ctx();
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345");
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345");
}
#[test]
fn minimum_bar_parses_without_overflow() {
let (sr, map, rate) = ctx();
let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0);
assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0));
assert_eq!(p("2|3|000").ok(), Some(144_000));
assert_eq!(p("0|1|000").ok(), Some(-96_000));
}
#[test]
@ -148,8 +178,8 @@ mod tests {
let (sr, map, rate) = ctx();
for fmt in TimeFormat::ALL {
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
let text = format_position(s, fmt, sr, &map, rate, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap();
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
}
}
@ -158,9 +188,9 @@ mod tests {
#[test]
fn parse_rejects_garbage() {
let (sr, map, rate) = ctx();
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err());
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
}
#[test]

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;
@ -123,7 +141,13 @@ impl GridValue {
pub fn line_at_or_before(&self, pos: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
match *self {
GridValue::Note { value, dotted, triplet } => {
let t = map.samples_to_ticks_f(pos, sr).floor() as i64;
// Float ticks can land a hair either side of a line's own tick.
let mut t = map.samples_to_ticks_f(pos, sr).floor() as i64;
if map.tick_to_samples(t, sr) > pos {
t = t.saturating_sub(1);
} else if map.tick_to_samples(t.saturating_add(1), sr) <= pos {
t = t.saturating_add(1);
}
// Grid lines restart at each bar line so odd meters stay aligned.
let bb = map.bar_beat_at_tick(t);
let bar_tick = map.bar_start_tick(bb.bar);
@ -133,7 +157,7 @@ impl GridValue {
}
_ => {
let step = self.step_samples(pos, sr, map, rate);
pos.div_euclid(step) * step
pos.div_euclid(step).saturating_mul(step)
}
}
}
@ -167,6 +191,19 @@ impl GridValue {
mod tests {
use super::*;
#[test]
fn grid_lines_are_their_own_line_at_odd_tempos() {
let (g, sr, rate) = (GridValue::default(), SampleRate::HZ_48000, FrameRate::Fps30);
for bpm in [90.0, 95.0, 110.0, 130.0, 140.0] {
let map = TempoMap::new(bpm, 4, 4).unwrap();
let mut line = 0;
for _ in 0..64 {
assert_eq!(g.line_at_or_before(line, sr, &map, rate), line, "{bpm} bpm");
line = g.next_line(line, sr, &map, rate);
}
}
}
#[test]
fn sixteenth_grid_at_120() {
let g = GridValue::default();
@ -199,4 +236,19 @@ mod tests {
let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false };
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
}
#[test]
fn values_step_to_the_next_larger_or_smaller() {
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false));
assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true));
assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false));
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
}
}

View File

@ -19,6 +19,10 @@ pub use timecode::{FrameRate, Timecode};
/// A position or length in samples.
pub type Samples = i64;
/// Largest position accepted from parameters and files: 2^40 samples (about 265 days at 48 kHz).
/// Clamping there keeps every later addition and subtraction on positions far away from overflow.
pub const MAX_POSITION: Samples = 1 << 40;
/// Errors from parsing or converting time values.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum TimeError {

View File

@ -55,6 +55,10 @@ fn valid_bpm(bpm: f64) -> Result<f64, TimeError> {
if bpm.is_finite() && (5.0..=1000.0).contains(&bpm) { Ok(bpm) } else { Err(TimeError::Tempo(bpm)) }
}
fn valid_meter(numerator: u32, denominator: u32) -> bool {
(1..=64).contains(&numerator) && [1, 2, 4, 8, 16, 32, 64].contains(&denominator)
}
impl TempoMap {
pub fn new(bpm: f64, numerator: u32, denominator: u32) -> Result<Self, TimeError> {
let mut m = TempoMap::default();
@ -70,6 +74,25 @@ impl TempoMap {
&self.meters
}
/// Repair a map read from a file: only valid tempos and meters, in tick order, with one of each
/// at tick 0.
pub fn sanitize(&mut self) {
self.tempos.retain(|e| e.tick >= 0 && valid_bpm(e.bpm).is_ok());
self.tempos.sort_by_key(|e| e.tick);
self.tempos.dedup_by_key(|e| e.tick);
if self.tempos.first().is_none_or(|e| e.tick > 0) {
let bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
self.tempos.insert(0, TempoEvent { tick: 0, bpm });
}
self.meters.retain(|m| m.tick >= 0 && valid_meter(m.numerator, m.denominator));
self.meters.sort_by_key(|m| m.tick);
self.meters.dedup_by_key(|m| m.tick);
if self.meters.first().is_none_or(|m| m.tick > 0) {
let (numerator, denominator) = self.meters.first().map_or((4, 4), |m| (m.numerator, m.denominator));
self.meters.insert(0, MeterEvent { tick: 0, numerator, denominator });
}
}
/// Insert or replace the tempo at `tick`.
pub fn set_tempo(&mut self, tick: i64, bpm: f64) -> Result<(), TimeError> {
let bpm = valid_bpm(bpm)?;
@ -96,13 +119,31 @@ impl TempoMap {
}
/// Insert or replace the meter at `tick` (snapped to the bar line at or before it).
/// Later meter changes keep their bar numbers, so they stay on bar lines.
pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> {
if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) {
if !valid_meter(numerator, denominator) {
return Err(TimeError::Meter(numerator, denominator));
}
let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) };
let ev = MeterEvent { tick, numerator, denominator };
match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) {
let later = self.take_meters_after(tick);
self.put_meter(MeterEvent { tick, numerator, denominator });
self.restore_meters_at_bars(later);
Ok(())
}
/// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers.
pub fn remove_meter(&mut self, tick: i64) -> bool {
if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) {
return false;
}
let later = self.take_meters_after(tick);
self.meters.retain(|e| e.tick != tick);
self.restore_meters_at_bars(later);
true
}
fn put_meter(&mut self, ev: MeterEvent) {
match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) {
Ok(i) => {
if let Some(e) = self.meters.get_mut(i) {
*e = ev;
@ -110,16 +151,21 @@ impl TempoMap {
}
Err(i) => self.meters.insert(i, ev),
}
Ok(())
}
pub fn remove_meter(&mut self, tick: i64) -> bool {
if tick == 0 {
return false;
/// Remove the meters after `tick`, returning each with the bar it starts.
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
self.meters.retain(|e| e.tick <= tick);
later
}
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
for (bar, ev) in later {
let tick = self.bar_start_tick(bar);
self.put_meter(MeterEvent { tick, ..ev });
}
let before = self.meters.len();
self.meters.retain(|e| e.tick != tick);
before != self.meters.len()
}
/// Tempo in effect at `tick`.
@ -200,7 +246,7 @@ impl TempoMap {
let mut cur_tick = 0i64;
let first = self.meter_at_tick(0);
if bar < 1 {
return (bar - 1).saturating_mul(first.ticks_per_bar());
return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar());
}
for (i, m) in self.meters.iter().enumerate() {
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
@ -288,6 +334,13 @@ mod tests {
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
}
#[test]
fn minimum_bar_saturates_instead_of_overflowing() {
let m = TempoMap::default();
assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN);
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
}
#[test]
fn tempo_change_is_integrated() {
let mut m = TempoMap::default();
@ -317,6 +370,22 @@ mod tests {
assert_eq!(m.bar_beat_at_tick(TICKS_PER_QUARTER / 2), BarBeat { bar: 1, beat: 2, tick: 0 });
}
#[test]
fn sanitize_keeps_what_the_setters_accept() {
let tempo = |tick, bpm| TempoEvent { tick, bpm };
let meter = |tick, numerator, denominator| MeterEvent { tick, numerator, denominator };
let mut m = TempoMap {
tempos: vec![tempo(960, 4e9), tempo(3840, 90.0), tempo(-5, 100.0), tempo(1920, 60.0), tempo(1920, 70.0)],
meters: vec![meter(0, 0, 4), meter(3840, 3, 4)],
};
m.sanitize();
assert_eq!(m.tempos(), [tempo(0, 60.0), tempo(1920, 60.0), tempo(3840, 90.0)]);
assert_eq!(m.meters(), [meter(0, 3, 4), meter(3840, 3, 4)]);
let mut empty = TempoMap { tempos: Vec::new(), meters: Vec::new() };
empty.sanitize();
assert_eq!(empty, TempoMap::default());
}
#[test]
fn rejects_bad_values() {
let mut m = TempoMap::default();

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

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