Merge main into #306
This commit is contained in:
commit
056bc288c4
|
|
@ -3424,7 +3424,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -3442,7 +3442,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
|
|
@ -3452,7 +3452,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3464,7 +3464,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde_json",
|
||||
|
|
@ -3474,7 +3474,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
|
|
@ -3486,7 +3486,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
|
|
@ -3497,7 +3497,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3508,7 +3508,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
|
|
@ -3530,7 +3530,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3540,7 +3540,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
|
|
@ -3556,7 +3556,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3569,7 +3569,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
|
|
@ -3582,7 +3582,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
|
|
@ -3592,7 +3592,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"egui",
|
||||
|
|
@ -3618,7 +3618,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"proptest",
|
||||
|
|
@ -3629,7 +3629,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"log",
|
||||
|
|
@ -3644,7 +3644,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
|
|
@ -5201,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -374,7 +527,8 @@ pub struct Instance {
|
|||
|
||||
// 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 {
|
||||
|
|
@ -399,10 +553,12 @@ 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,
|
||||
|
|
@ -412,7 +568,27 @@ impl Instance {
|
|||
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 {
|
||||
|
|
@ -492,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.
|
||||
|
|
@ -768,13 +945,15 @@ 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -783,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);
|
||||
|
|
@ -793,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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -199,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
|
||||
}
|
||||
|
|
@ -226,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) {
|
||||
|
|
|
|||
|
|
@ -196,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}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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))]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¶ms, &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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
@ -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;
|
||||
|
|
@ -1207,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()));
|
||||
|
|
@ -1240,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),
|
||||
|
|
@ -1253,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()
|
||||
|
|
@ -1285,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -319,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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -299,6 +299,7 @@ impl Sampler {
|
|||
v.env.gate_off();
|
||||
}
|
||||
}
|
||||
EventKind::Sustain(_) | EventKind::Bend(_) | EventKind::ResetControllers => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,9 @@ struct GroupFile {
|
|||
|
||||
/// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written.
|
||||
pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
||||
let p = with_extension(path);
|
||||
// First, so a refused group file leaves nothing behind and is refused whatever else is wrong.
|
||||
crate::file_access::check_write(&p).map_err(EngineError::Io)?;
|
||||
let s = e.session();
|
||||
let mut lanes: Vec<(usize, Vec<Clip>)> = Vec::new();
|
||||
for id in ids {
|
||||
|
|
@ -47,7 +50,6 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
|||
};
|
||||
let n = clips.len();
|
||||
lanes.sort_by_key(|(i, _)| *i);
|
||||
let p = with_extension(path);
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string();
|
||||
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
|
||||
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
|
|
@ -62,10 +64,10 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
|||
&& let (Some(buf), Some(src)) = (s.pool.get(source), s.sources.iter().find(|x| x.id == source))
|
||||
{
|
||||
let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0);
|
||||
std::fs::create_dir_all(dir.join(&audio_dir_name)).map_err(|err| EngineError::Io(format!("{audio_dir_name}: {err}")))?;
|
||||
crate::io::make_dir(dir.join(&audio_dir_name))?;
|
||||
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
|
||||
let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(dir.join(&rel), bytes).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
crate::io::write_path(dir.join(&rel), bytes)?;
|
||||
slot.insert(rel);
|
||||
}
|
||||
lane.push(c);
|
||||
|
|
@ -74,7 +76,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
|||
}
|
||||
let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio };
|
||||
let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
|
||||
crate::io::write_path(&p, text)?;
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
|
|
@ -82,7 +84,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
|
|||
/// are made when the session runs out). Returns the ids of the placed clips.
|
||||
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
|
||||
let id = "file.import_clip_groups";
|
||||
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let text = crate::io::read_text(path)?;
|
||||
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
|
||||
if file.format != FORMAT {
|
||||
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
|
||||
|
|
@ -98,7 +100,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
|
|||
if !full.starts_with(&dir) || rel.contains("..") {
|
||||
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
|
||||
}
|
||||
let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?;
|
||||
let bytes = crate::io::read_file(&full)?;
|
||||
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
|
||||
decoded.insert(*old, (rel.clone(), buf));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¶ms, r);
|
||||
}
|
||||
}
|
||||
let ids = clip_ids_param(e, p);
|
||||
if ids.is_empty() {
|
||||
return Err(bad("audiosuite.process", "select audio clips first"));
|
||||
}
|
||||
let sr = e.session().sample_rate;
|
||||
let ratio = f64::from(get("ratio", 1.0).clamp(0.25, 4.0));
|
||||
let semitones = get("semitones", 0.0).clamp(-24.0, 24.0);
|
||||
let to = (sr.as_f64() / f64::from(get("speed", 1.0).clamp(0.25, 4.0))).round().max(1.0) as u32;
|
||||
// These processes cut their output at MAX_OFFLINE_SAMPLES frames.
|
||||
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
|
||||
let most = match name.as_str() {
|
||||
"time_stretch" => longest(ratio),
|
||||
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
|
||||
"varispeed" => longest(f64::from(to) / sr.as_f64()),
|
||||
_ => usize::MAX,
|
||||
};
|
||||
let mut done = 0;
|
||||
for id in ids {
|
||||
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
|
||||
if !clip.is_audio() {
|
||||
continue;
|
||||
}
|
||||
let channels = e.session().track(track).map_or(1, |t| t.channels());
|
||||
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
|
||||
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
|
||||
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
|
||||
let mut tmp = e.session().clone();
|
||||
|
|
@ -78,20 +97,11 @@ fn process(e: &mut Engine, p: &Value) -> Result<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 ¶ms {
|
||||
pl.set_param(k, *v);
|
||||
}
|
||||
soundcraft_dsp::offline::apply_plugin(&mut audio, pl.as_mut(), srf);
|
||||
apply(plugin, ¶ms, &mut audio, srf)?;
|
||||
audio
|
||||
}
|
||||
};
|
||||
|
|
@ -122,6 +132,46 @@ fn process(e: &mut Engine, p: &Value) -> Result<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)]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! Edit menu commands.
|
||||
|
||||
use super::more_util::toggle_flag;
|
||||
use super::*;
|
||||
use crate::edit as ops;
|
||||
use crate::{Clipboard, cmd};
|
||||
|
|
@ -10,16 +11,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
vec![
|
||||
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))),
|
||||
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))),
|
||||
cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection),
|
||||
cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection),
|
||||
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")),
|
||||
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
|
||||
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
|
||||
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
"edit.cut_clip_gain"
|
||||
)),
|
||||
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| {
|
||||
super::edit_more::copy_clip_gain(e, p)?;
|
||||
reset_clip_gain(e, p, "edit.cut_clip_gain")
|
||||
}),
|
||||
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
|
||||
e,
|
||||
p,
|
||||
|
|
@ -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",
|
||||
|
|
@ -125,12 +133,34 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
|
||||
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
|
||||
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
|
||||
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge),
|
||||
cmd!(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,7 +795,7 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let s = e.session_mut();
|
||||
let mut kept = 0;
|
||||
for t in &tracks {
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post);
|
||||
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
|
||||
}
|
||||
Ok(json!({"clips": kept}))
|
||||
}
|
||||
|
|
@ -715,10 +845,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,26 +862,48 @@ 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)?;
|
||||
|
|
@ -759,7 +912,7 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -782,4 +935,74 @@ mod tests {
|
|||
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
|
||||
assert_ne!(e.revision, rev);
|
||||
}
|
||||
|
||||
/// 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]]));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(¶m).set_point(at, v);
|
||||
let lane = tr.lane_mut(¶m);
|
||||
if let Some(from) = from {
|
||||
lane.clear_range(from.saturating_add(1), at);
|
||||
}
|
||||
lane.set_point(at, v);
|
||||
}
|
||||
}
|
||||
Ok(json!({"at": at, "value": v}))
|
||||
|
|
@ -788,6 +813,17 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_point_from_drops_the_points_in_between() {
|
||||
let (mut e, t) = tone_session(&[(100, -20.0), (150, -20.0), (200, -20.0), (250, -20.0), (300, -20.0)]);
|
||||
e.execute("automation.set_point", &json!({"track": t.0, "from": 100, "at": 250, "value": 0.0})).unwrap();
|
||||
// A loop wrap: `from` lies after `at`, so nothing goes.
|
||||
e.execute("automation.set_point", &json!({"track": t.0, "from": 400, "at": 50, "value": -6.0})).unwrap();
|
||||
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
|
||||
let points: Vec<(i64, f32)> = lane.points.iter().map(|p| (p.at, p.value)).collect();
|
||||
assert_eq!(points, [(50, -6.0), (100, -20.0), (250, 0.0), (300, -20.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_gain_points_inside_the_selection_are_kept() {
|
||||
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
|
||||
|
|
@ -802,6 +838,42 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_params_rejects_unknown_and_non_numeric_keys() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let t = e.session().tracks[0].id.0;
|
||||
e.execute("mix.insert", &json!({"track": t, "slot": 0, "plugin": "gain"})).unwrap();
|
||||
fn gain(e: &Engine) -> f32 {
|
||||
e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().params["gain"]
|
||||
}
|
||||
|
||||
// A typo names no parameter: the call fails and says which key.
|
||||
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain_db": -14.0}})).unwrap_err();
|
||||
assert!(err.to_string().contains("gain_db"), "{err}");
|
||||
|
||||
// Mixed keys fail as a whole and change nothing.
|
||||
let err = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0, "width": 1.0}})).unwrap_err();
|
||||
assert!(err.to_string().contains("width"), "{err}");
|
||||
assert_eq!(gain(&e), 0.0);
|
||||
|
||||
// Non-numeric and non-finite values are rejected too.
|
||||
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": "loud"}})).is_err());
|
||||
assert!(e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": null}})).is_err());
|
||||
assert_eq!(gain(&e), 0.0);
|
||||
|
||||
// Correct keys still apply, clamped to the parameter range.
|
||||
let r = e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": -14.0}})).unwrap();
|
||||
assert_eq!(r["set"], 1);
|
||||
assert_eq!(gain(&e), -14.0);
|
||||
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {"gain": 999.0}})).unwrap();
|
||||
assert_eq!(gain(&e), 24.0);
|
||||
|
||||
// Empty params with a preset name still recall (the rename path).
|
||||
e.execute("mix.insert_params", &json!({"track": t, "slot": 0, "params": {}, "preset": "Saved"})).unwrap();
|
||||
assert_eq!(e.session().tracks[0].mixer.inserts[0].as_ref().unwrap().preset, "Saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeting_a_send_keeps_its_settings() {
|
||||
let mut e = Engine::default();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
@ -275,7 +281,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 +303,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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}))
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
values.push((k.clone(), pi.clamp(x as f32)));
|
||||
}
|
||||
}
|
||||
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let bytes = crate::io::read_file(&path)?;
|
||||
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
|
||||
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
|
||||
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let r = soundcraft_time::Range::new(0, end);
|
||||
let mut n = 0;
|
||||
for t in tracks {
|
||||
if crate::io::consolidate(e, t, r)? {
|
||||
// Each track keeps its own length (its material plus a second), as before.
|
||||
let tr = crate::io::bounded_to_track(e.session(), t, r);
|
||||
if crate::io::consolidate(e, id, t, tr)? {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -318,7 +320,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let at = s.edit.selection.start.max(0);
|
||||
let frames_at = s.frame_rate.frames_at(at, s.sample_rate);
|
||||
let start = ft * 16 + fr - frames_at;
|
||||
s.edit.values.insert("feet_frames.start".into(), start as f64);
|
||||
s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64);
|
||||
Ok(json!({"feet_frames_start": start}))
|
||||
}
|
||||
|
||||
|
|
@ -404,7 +406,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
/// Decode a source file of another session (paths relative to that session's folder).
|
||||
fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io::AudioBuffer> {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
let bytes = std::fs::read(&p).ok()?;
|
||||
let bytes = crate::io::read_file(&p).ok()?;
|
||||
let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?;
|
||||
if buf.sample_rate != sr && buf.sample_rate > 0 {
|
||||
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
|
||||
|
|
@ -416,7 +418,7 @@ fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io:
|
|||
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let id = "file.import_session_data";
|
||||
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
|
||||
let text_v = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let text_v = crate::io::read_text(&path)?;
|
||||
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let names: Option<Vec<String>> =
|
||||
|
|
@ -556,8 +558,7 @@ mod tests {
|
|||
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
|
||||
e.execute("setup.current_timecode", &json!({"timecode": "01:00:00:00"})).unwrap();
|
||||
let s = e.session();
|
||||
let shown =
|
||||
soundcraft_time::format_position(48_000, soundcraft_time::TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
|
||||
let shown = s.format_position(48_000, soundcraft_time::TimeFormat::Timecode);
|
||||
assert_eq!(shown, "01:00:00:00");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,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,
|
||||
|
|
@ -98,7 +112,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"track": t}))
|
||||
}),
|
||||
cmd!("track.clear_clip_indicators", "Clear All Clip Indicators", ["Track"], Some("Alt+C"), "{}", always, |_, _| Ok(json!({"cleared": true}))),
|
||||
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, |_, _| Ok(json!({}))),
|
||||
cmd!("track.clear_trim_automation", "Clear Trim Automation", ["Track"], None, "{tracks?}", has_selection, super::track_more::clear_trim),
|
||||
cmd!("track.create_click", "Create Click Track", ["Track"], None, "{}", always, create_click),
|
||||
cmd!("track.playlist_new", "New Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, false)),
|
||||
cmd!("track.playlist_duplicate", "Duplicate Playlist", [], None, "{track?, name?}", has_selection, |e, p| new_playlist(e, p, true)),
|
||||
|
|
@ -128,7 +142,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)
|
||||
{
|
||||
|
|
@ -174,7 +191,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}))
|
||||
}
|
||||
),
|
||||
|
|
@ -485,7 +502,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) {
|
||||
|
|
@ -495,6 +515,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();
|
||||
|
|
@ -680,4 +720,40 @@ mod tests {
|
|||
assert!(matches!(kick.mixer.output, Route::Bus(_)));
|
||||
assert!(s.tracks.iter().any(|t| t.kind == TrackKind::Folder && t.mixer.input == kick.mixer.output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)}))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
@ -156,18 +160,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
|
|||
}
|
||||
|
||||
/// 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> {
|
||||
/// `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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
make_room(s, track, at, length)?;
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for c in clips {
|
||||
|
|
@ -177,7 +174,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 +186,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 +343,7 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
|
|||
n += 1;
|
||||
}
|
||||
}
|
||||
pl.sort();
|
||||
n
|
||||
}
|
||||
|
||||
|
|
@ -279,14 +402,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 +421,298 @@ 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.
|
||||
pub fn shown(s: &Session, t: &Track) -> bool {
|
||||
!t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open)
|
||||
}
|
||||
|
||||
/// 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 +721,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();
|
||||
|
|
@ -460,7 +854,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 +862,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 +894,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:#?}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,50 @@ use std::sync::Arc;
|
|||
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
|
||||
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
|
||||
|
||||
// ─── Checked file access ──────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every file a command names by path is read and written through the helpers below, so the
|
||||
// automation roots (`crate::file_access`, `soundcraft-cli mcp --automation-read-root` and
|
||||
// `--automation-write-root`) are checked in one place. A test in `file_access` keeps the rest of
|
||||
// the engine from calling `std::fs` itself. Audio is decoded into the session's pool the moment it
|
||||
// enters it (import, open, sampler load), so playback and bounce read nothing from disk and
|
||||
// need no check of their own.
|
||||
|
||||
/// The bytes of the file at `path`, which must be inside the automation read root when there is one.
|
||||
pub fn read_file(path: impl AsRef<Path>) -> Result<Vec<u8>> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).map_err(EngineError::Io)?;
|
||||
std::fs::read(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// [`read_file`] for a text file.
|
||||
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).map_err(EngineError::Io)?;
|
||||
std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// Is there a file at `path` the automation read root lets us see? A path outside the root is
|
||||
/// reported as not found, so a command can't probe for files beyond it.
|
||||
pub fn file_found(path: impl AsRef<Path>) -> bool {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_read(path).is_ok() && path.is_file()
|
||||
}
|
||||
|
||||
/// Create `dir` and its parents, inside the automation write root when there is one.
|
||||
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
|
||||
let dir = dir.as_ref();
|
||||
crate::file_access::check_write(dir).map_err(EngineError::Io)?;
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
|
||||
let path = path.as_ref();
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
|
||||
}
|
||||
|
||||
/// A relative `Audio Files/` path no other source in `s` already uses.
|
||||
fn unique_audio_rel(s: &Session, stem: &str) -> String {
|
||||
let stem = sanitize_name(stem);
|
||||
|
|
@ -54,6 +98,15 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
|
|||
id
|
||||
}
|
||||
|
||||
/// A BWF time stamp (samples at the file's rate) at the session's rate, as the audio is resampled.
|
||||
fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
|
||||
if file_rate == 0 || file_rate == session_rate {
|
||||
return stamp;
|
||||
}
|
||||
let (from, to) = (u128::from(file_rate), u128::from(session_rate));
|
||||
u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// Decode and place an audio file. With no target track, a new track of matching width is made.
|
||||
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
|
||||
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
|
||||
|
|
@ -65,10 +118,12 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
|
|||
return Err(EngineError::Io(format!("{name}: no audio")));
|
||||
}
|
||||
let channels = buf.num_channels();
|
||||
let file_rate = info.sample_rate;
|
||||
let s = e.session_mut();
|
||||
let session_rate = s.sample_rate.hz();
|
||||
let src = add_source(s, name, buf, path, info.format);
|
||||
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
|
||||
sx.time_reference = bwf.time_reference;
|
||||
sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
|
||||
}
|
||||
let frames = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
|
||||
let track = match target {
|
||||
|
|
@ -152,12 +207,13 @@ pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec<String> {
|
|||
let mut missing = Vec::new();
|
||||
for v in &mut s.videos {
|
||||
let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) };
|
||||
if p.is_file() {
|
||||
if file_found(&p) {
|
||||
continue;
|
||||
}
|
||||
let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default();
|
||||
let near =
|
||||
[session_dir.join(&name), session_dir.join("Video Files").join(&name)].into_iter().find(|c| !name.as_os_str().is_empty() && c.is_file());
|
||||
let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)]
|
||||
.into_iter()
|
||||
.find(|c| !name.as_os_str().is_empty() && file_found(c));
|
||||
match near {
|
||||
Some(c) => v.path = c.to_string_lossy().into_owned(),
|
||||
None => missing.push(format!("{} (video)", p.display())),
|
||||
|
|
@ -237,61 +293,260 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
|
|||
soundcraft_midi::write_smf(&smf).map_err(|err| EngineError::Io(err.to_string()))
|
||||
}
|
||||
|
||||
/// Render the main mix. Returns the encoded file and (peak dBFS, integrated LUFS).
|
||||
pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||||
bounce_bytes_with(e, r, opts, normalize, false)
|
||||
}
|
||||
|
||||
/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV
|
||||
/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`.
|
||||
pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||||
bounce_source(e, r, opts, normalize, fold_stereo, None)
|
||||
}
|
||||
|
||||
/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named.
|
||||
pub fn bounce_source(
|
||||
e: &Engine,
|
||||
r: Range,
|
||||
opts: &EncodeOptions,
|
||||
normalize: bool,
|
||||
fold_stereo: bool,
|
||||
/// A bounce of the main mix, or of one bus, over a range, written to its file block by block so no
|
||||
/// bounce has to fit in memory, and rendered a slice at a time ([`Bounce::step`]) so a UI can show
|
||||
/// progress. The file appears at its path only once complete; a bounce that fails, or is dropped
|
||||
/// unfinished, leaves any older file there alone.
|
||||
pub struct Bounce {
|
||||
session: Arc<Session>,
|
||||
path: PathBuf,
|
||||
part: PathBuf,
|
||||
range: Range,
|
||||
bus: Option<BusId>,
|
||||
) -> Result<(Vec<u8>, (f32, f32, f32))> {
|
||||
let s = e.session();
|
||||
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
|
||||
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
|
||||
}
|
||||
let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus);
|
||||
if normalize {
|
||||
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
|
||||
}
|
||||
let peak = soundcraft_dsp::offline::peak_db(&ch);
|
||||
let mut lm = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, ch.len());
|
||||
let frames = ch.first().map_or(0, Vec::len);
|
||||
lm.process(&ch, frames);
|
||||
let lufs = lm.integrated_lufs();
|
||||
let true_peak = lm.true_peak_db();
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
Ok((bytes, (peak, lufs, true_peak)))
|
||||
fold: Option<ChannelFormat>,
|
||||
normalize: bool,
|
||||
/// Measuring the peak to normalize to, the first of two passes.
|
||||
measuring: bool,
|
||||
render: soundcraft_mix::RangeRender,
|
||||
block: Vec<Vec<f32>>,
|
||||
done: usize,
|
||||
gain: f32,
|
||||
peak: f32,
|
||||
meter: soundcraft_dsp::meter::LoudnessMeter,
|
||||
enc: Option<soundcraft_audio_io::FileEncoder<Out>>,
|
||||
}
|
||||
|
||||
/// The main mix over `r` and its format (folded to stereo on request).
|
||||
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, ChannelFormat) {
|
||||
render_bounce(s, r, fold_stereo, None)
|
||||
/// A finished bounce: the size of its file and its levels.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Bounced {
|
||||
pub bytes: u64,
|
||||
pub peak_db: f32,
|
||||
pub lufs: f32,
|
||||
pub true_peak_db: f32,
|
||||
}
|
||||
|
||||
/// The main mix, or one bus, over `r`.
|
||||
fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option<BusId>) -> (Vec<Vec<f32>>, ChannelFormat) {
|
||||
let (ch, fmt) = if let Some(id) = bus {
|
||||
let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo);
|
||||
(soundcraft_mix::render_bus(s, id, r, 1024), fmt)
|
||||
} else {
|
||||
let ch = soundcraft_mix::render_range(s, r, 1024);
|
||||
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
|
||||
(ch, fmt)
|
||||
impl Bounce {
|
||||
/// Starts a bounce of `s`'s main mix, or of `bus`, over `r` to `path`: every channel (SMPTE/WAV
|
||||
/// order, with the WAV speaker mask of its format), or an ITU stereo fold-down when
|
||||
/// `fold_stereo`, peak-normalized to -0.1 dBFS when `normalize` (which renders twice).
|
||||
pub fn new(
|
||||
s: Arc<Session>,
|
||||
path: &Path,
|
||||
r: Range,
|
||||
opts: &EncodeOptions,
|
||||
bus: Option<BusId>,
|
||||
normalize: bool,
|
||||
fold_stereo: bool,
|
||||
) -> Result<Bounce> {
|
||||
if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) {
|
||||
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
|
||||
}
|
||||
let (fmt, channels, fold) = bounce_layout(&s, bus, fold_stereo);
|
||||
let name = path.file_name().map_or_else(|| "bounce".into(), |n| n.to_string_lossy().into_owned());
|
||||
let part = path.with_file_name(format!("{name}.part"));
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
crate::file_access::check_write(&part).map_err(EngineError::Io)?;
|
||||
let enc = (|| -> std::result::Result<_, soundcraft_audio_io::AudioError> {
|
||||
let enc = soundcraft_audio_io::FileEncoder::new(Out::create(&part)?, opts, channels, s.sample_rate.hz(), Some(fmt.channel_mask()))?;
|
||||
enc.check_room(u64::try_from(r.len()).unwrap_or(0))?;
|
||||
Ok(enc)
|
||||
})()
|
||||
.map_err(|err| {
|
||||
let _ = std::fs::remove_file(&part);
|
||||
EngineError::Io(format!("{}: {err}", path.display()))
|
||||
})?;
|
||||
let render = soundcraft_mix::RangeRender::new(&s, bus, r, 1024);
|
||||
let meter = soundcraft_dsp::meter::LoudnessMeter::new(s.sample_rate.as_f64() as f32, channels);
|
||||
Ok(Bounce {
|
||||
session: s,
|
||||
path: path.to_path_buf(),
|
||||
part,
|
||||
range: r,
|
||||
bus,
|
||||
fold,
|
||||
normalize,
|
||||
measuring: normalize,
|
||||
render,
|
||||
block: Vec::new(),
|
||||
done: 0,
|
||||
gain: 1.0,
|
||||
peak: 0.0,
|
||||
meter,
|
||||
enc: Some(enc),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
pub fn range(&self) -> Range {
|
||||
self.range
|
||||
}
|
||||
|
||||
/// How far along the bounce is, from 0 to 1.
|
||||
pub fn progress(&self) -> f32 {
|
||||
let pass = (self.done as f64 / self.range.len().max(1) as f64).min(1.0) as f32;
|
||||
match (self.normalize, self.measuring) {
|
||||
(false, _) => pass,
|
||||
(true, true) => pass / 2.0,
|
||||
(true, false) => 0.5 + pass / 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders blocks until `more` returns false (at least one per call). Returns the finished
|
||||
/// bounce once its file is complete.
|
||||
pub fn step(&mut self, mut more: impl FnMut() -> bool) -> Result<Option<Bounced>> {
|
||||
loop {
|
||||
match self.render.next_block(&self.session) {
|
||||
Some((chunk, n)) => {
|
||||
fill(&mut self.block, chunk, n, self.fold, self.gain);
|
||||
self.done += n;
|
||||
self.peak = self.peak.max(soundcraft_dsp::offline::peak(&self.block));
|
||||
if !self.measuring {
|
||||
self.meter.process(&self.block, n);
|
||||
if let Some(enc) = &mut self.enc {
|
||||
enc.write(&self.block, n).map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
None if self.measuring => {
|
||||
self.gain = if self.peak > 0.0 { soundcraft_dsp::db_to_gain(-0.1) / self.peak } else { 1.0 };
|
||||
self.peak = 0.0;
|
||||
self.done = 0;
|
||||
self.measuring = false;
|
||||
self.render = soundcraft_mix::RangeRender::new(&self.session, self.bus, self.range, 1024);
|
||||
}
|
||||
None => return self.finish().map(Some),
|
||||
}
|
||||
if !more() {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the whole bounce (see [`Bounce::step`]).
|
||||
pub fn run(mut self) -> Result<Bounced> {
|
||||
loop {
|
||||
if let Some(done) = self.step(|| true)? {
|
||||
return Ok(done);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Result<Bounced> {
|
||||
let bytes = match self.enc.take() {
|
||||
Some(enc) => enc.finish().map_err(|err| EngineError::Io(format!("{}: {err}", self.path.display())))?.complete(&self.part, &self.path)?,
|
||||
None => 0,
|
||||
};
|
||||
Ok(Bounced {
|
||||
bytes,
|
||||
peak_db: soundcraft_dsp::gain_to_db(self.peak),
|
||||
lufs: self.meter.integrated_lufs(),
|
||||
true_peak_db: self.meter.true_peak_db(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bounce {
|
||||
/// Removes the part file unless the bounce completed (which renamed it).
|
||||
fn drop(&mut self) {
|
||||
drop(self.enc.take());
|
||||
let _ = std::fs::remove_file(&self.part);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where a bounce is written: its part file, or memory in the browser, which has no file system.
|
||||
enum Out {
|
||||
File(std::io::BufWriter<std::fs::File>),
|
||||
Memory(std::io::Cursor<Vec<u8>>),
|
||||
}
|
||||
|
||||
impl Out {
|
||||
fn create(part: &Path) -> std::io::Result<Out> {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
|
||||
}
|
||||
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
|
||||
}
|
||||
|
||||
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
|
||||
/// over it; in the browser the bytes are delivered ([`write_file`]).
|
||||
fn complete(self, part: &Path, path: &Path) -> Result<u64> {
|
||||
match self {
|
||||
Out::File(out) => {
|
||||
let mut len = 0;
|
||||
let synced = out.into_inner().map_err(std::io::IntoInnerError::into_error).and_then(|f| {
|
||||
f.sync_all()?;
|
||||
len = f.metadata()?.len();
|
||||
Ok(())
|
||||
});
|
||||
replace_with(part, path, synced).map(|()| len)
|
||||
}
|
||||
Out::Memory(m) => {
|
||||
let bytes = m.into_inner();
|
||||
write_file(&path.to_string_lossy(), &bytes)?;
|
||||
Ok(bytes.len() as u64)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Write for Out {
|
||||
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
|
||||
match self {
|
||||
Out::File(f) => f.write(buf),
|
||||
Out::Memory(m) => {
|
||||
// A long bounce may not fit in the browser's memory: fail instead of aborting.
|
||||
let end = usize::try_from(m.position()).unwrap_or(usize::MAX).saturating_add(buf.len());
|
||||
let v = m.get_mut();
|
||||
v.try_reserve(end.saturating_sub(v.len())).map_err(|_| std::io::ErrorKind::OutOfMemory)?;
|
||||
m.write(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
match self {
|
||||
Out::File(f) => f.flush(),
|
||||
Out::Memory(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::io::Seek for Out {
|
||||
fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result<u64> {
|
||||
match self {
|
||||
Out::File(f) => f.seek(pos),
|
||||
Out::Memory(m) => m.seek(pos),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_BOUNCE_SECS: f64 = 4.0 * 3600.0;
|
||||
|
||||
/// The format, channel count and fold-down source of a bounce of the main mix or `bus`: every
|
||||
/// channel (a mono or stereo main mix as stereo), or an ITU stereo fold-down of a surround source
|
||||
/// when `fold_stereo`.
|
||||
fn bounce_layout(s: &Session, bus: Option<BusId>, fold_stereo: bool) -> (ChannelFormat, usize, Option<ChannelFormat>) {
|
||||
let nch = soundcraft_mix::source_channels(s, bus);
|
||||
let fmt = match bus {
|
||||
Some(id) => s.bus(id).map_or(ChannelFormat::Stereo, |b| b.format),
|
||||
None if nch <= 2 => ChannelFormat::Stereo,
|
||||
None => s.main_format(),
|
||||
};
|
||||
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
|
||||
if fold_stereo && nch > 2 { (ChannelFormat::Stereo, 2, Some(fmt)) } else { (fmt, nch, None) }
|
||||
}
|
||||
|
||||
/// The first `n` frames of `chunk`, folded down from `fold` when set and scaled by `gain`.
|
||||
fn fill(block: &mut Vec<Vec<f32>>, chunk: &[Vec<f32>], n: usize, fold: Option<ChannelFormat>, gain: f32) {
|
||||
let folded = fold.map(|from| soundcraft_mix::fold_down_stereo(from, chunk));
|
||||
let src = folded.as_deref().unwrap_or(chunk);
|
||||
block.resize_with(src.len(), Vec::new);
|
||||
for (d, c) in block.iter_mut().zip(src) {
|
||||
d.clear();
|
||||
d.extend(c.iter().take(n).map(|v| v * gain));
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included)
|
||||
|
|
@ -303,7 +558,10 @@ pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Re
|
|||
/// [`bounce_stems`] in the main format, or folded down to stereo when `fold_stereo`.
|
||||
pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions, fold_stereo: bool) -> Result<Vec<String>> {
|
||||
let s = e.session();
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||||
if r.len() > s.sample_rate.samples(MAX_BOUNCE_SECS) {
|
||||
return Err(EngineError::BadParams("file.bounce_stems".into(), "bounces are limited to 4 hours".into()));
|
||||
}
|
||||
make_dir(dir)?;
|
||||
let ext = match opts.format {
|
||||
FileFormat::Aiff => "aif",
|
||||
FileFormat::Flac => "flac",
|
||||
|
|
@ -321,11 +579,8 @@ pub fn bounce_stems_with(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions,
|
|||
for t in &mut solo.tracks {
|
||||
t.mixer.solo = t.id == id;
|
||||
}
|
||||
let (ch, fmt) = render_main(&solo, r, fold_stereo);
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||||
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf, opts, fmt.channel_mask()).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
|
||||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
Bounce::new(Arc::new(solo), &path, r, opts, None, false, fold_stereo)?.run()?;
|
||||
out.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(out)
|
||||
|
|
@ -349,20 +604,13 @@ pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Sample
|
|||
}
|
||||
|
||||
/// Strip Silence: keep only the non-silent ranges of clips inside `r`.
|
||||
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> usize {
|
||||
pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<usize> {
|
||||
let r = bounded_to_track(s, t, r);
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
let keep = soundcraft_dsp::offline::non_silent_ranges(
|
||||
&audio,
|
||||
thr,
|
||||
usize::try_from(min_len).unwrap_or(0),
|
||||
usize::try_from(pre).unwrap_or(0),
|
||||
usize::try_from(post).unwrap_or(0),
|
||||
);
|
||||
let keep = non_silent(s, t, r, thr, min_len, pre, post)?;
|
||||
let mut cut_points = vec![r.start];
|
||||
for (a, b) in &keep {
|
||||
cut_points.push(r.start + *a as i64);
|
||||
cut_points.push(r.start + *b as i64);
|
||||
cut_points.push(*a);
|
||||
cut_points.push(*b);
|
||||
}
|
||||
cut_points.push(r.end);
|
||||
for p in &cut_points {
|
||||
|
|
@ -372,11 +620,10 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
|
|||
let mut gaps = Vec::new();
|
||||
let mut prev = r.start;
|
||||
for (a, b) in &keep {
|
||||
let a = r.start + *a as i64;
|
||||
if a > prev {
|
||||
gaps.push(Range::new(prev, a));
|
||||
if *a > prev {
|
||||
gaps.push(Range::new(prev, *a));
|
||||
}
|
||||
prev = r.start + *b as i64;
|
||||
prev = *b;
|
||||
}
|
||||
if prev < r.end {
|
||||
gaps.push(Range::new(prev, r.end));
|
||||
|
|
@ -384,21 +631,67 @@ pub fn strip_silence(s: &mut Session, t: TrackId, r: Range, thr: f32, min_len: S
|
|||
if let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) {
|
||||
pl.clips.retain(|c| !gaps.iter().any(|g| c.start >= g.start && c.end() <= g.end));
|
||||
}
|
||||
keep.len()
|
||||
Ok(keep.len())
|
||||
}
|
||||
|
||||
/// Consolidate the selection on a track into one new clip backed by a rendered source.
|
||||
pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
|
||||
/// Most samples (over all channels) Strip Silence renders at once.
|
||||
const STRIP_WINDOW_SAMPLES: usize = 1 << 24;
|
||||
|
||||
/// The non-silent ranges of `t`'s clips in `r`. The audio is rendered a window at a time where
|
||||
/// audio clips play, so all of `r` is scanned however long it is (one render stops at
|
||||
/// `MAX_RENDER_SAMPLES`); at most a day of clips, Pro Tools' longest session.
|
||||
fn non_silent(s: &Session, t: TrackId, r: Range, thr: f32, min_len: Samples, pre: Samples, post: Samples) -> Result<Vec<(Samples, Samples)>> {
|
||||
let Some(tr) = s.track(t) else { return Ok(Vec::new()) };
|
||||
let mut clips: Vec<Range> = tr.clips().iter().filter(|c| c.is_audio() && !c.muted).filter_map(|c| c.range().intersect(&r)).collect();
|
||||
clips.sort_by_key(|x| x.start);
|
||||
let mut spans: Vec<Range> = Vec::new();
|
||||
for x in clips {
|
||||
match spans.last_mut() {
|
||||
Some(last) if x.start <= last.end => last.end = last.end.max(x.end),
|
||||
_ => spans.push(x),
|
||||
}
|
||||
}
|
||||
if spans.iter().map(Range::len).fold(0, i64::saturating_add) > s.sample_rate.samples(86_400.0) {
|
||||
return Err(EngineError::BadParams("edit.strip_silence".into(), format!("`{}` has more than a day of clips to scan", tr.name)));
|
||||
}
|
||||
let window = i64::try_from(STRIP_WINDOW_SAMPLES / tr.channels().max(1)).unwrap_or(i64::MAX);
|
||||
let len = |n: Samples| u64::try_from(n).unwrap_or(0);
|
||||
let mut scan = soundcraft_dsp::offline::SilenceScan::new(thr, len(min_len));
|
||||
let mut at = r.start;
|
||||
for x in spans {
|
||||
scan.skip(len(x.start - at));
|
||||
at = x.start;
|
||||
while at < x.end {
|
||||
let end = at.saturating_add(window).min(x.end);
|
||||
scan.push(&soundcraft_mix::render_clips(s, t, Range::new(at, end)));
|
||||
at = end;
|
||||
}
|
||||
}
|
||||
scan.skip(len(r.end - at));
|
||||
Ok(scan.finish(len(pre), len(post)).into_iter().map(|(a, b)| (r.start.saturating_add_unsigned(a), r.start.saturating_add_unsigned(b))).collect())
|
||||
}
|
||||
|
||||
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
|
||||
/// whole range, silence included; a range too long to render is refused rather than shortened.
|
||||
pub fn consolidate(e: &mut Engine, cmd: &str, t: TrackId, r: Range) -> Result<bool> {
|
||||
let s = e.session();
|
||||
if s.track(t).is_none_or(|tr| !tr.kind.has_playlist() || tr.kind.is_midi()) {
|
||||
let Some(tr) = s.track(t).filter(|tr| tr.kind.has_playlist() && !tr.kind.is_midi()) else { return Ok(false) };
|
||||
let start = r.start.max(0);
|
||||
let len = r.end.saturating_sub(start);
|
||||
if len <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let r = bounded_to_track(s, t, r);
|
||||
if r.is_empty() {
|
||||
return Ok(false);
|
||||
let max = soundcraft_mix::MAX_RENDER_SAMPLES / tr.channels().max(1);
|
||||
if usize::try_from(len).map_or(true, |n| n > max) {
|
||||
let secs = max as f64 / s.sample_rate.as_f64();
|
||||
return Err(crate::cmd::bad(cmd, format!("selection is too long to consolidate on `{}` (at most {secs:.0} s)", tr.name)));
|
||||
}
|
||||
let r = Range::new(start, start + len);
|
||||
let audio = soundcraft_mix::render_clips(s, t, r);
|
||||
let name = format!("{}_consolidated", s.track(t).map_or("Audio", |x| x.name.as_str()));
|
||||
if audio.iter().any(|ch| ch.len() as i64 != len) {
|
||||
return Err(EngineError::Internal(cmd.to_string(), "consolidate rendered the wrong length".into()));
|
||||
}
|
||||
let name = format!("{}_consolidated", tr.name);
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
|
||||
let s = e.session_mut();
|
||||
let src = add_source(s, &name, buf, None, FileFormat::Wav);
|
||||
|
|
@ -408,13 +701,28 @@ pub fn consolidate(e: &mut Engine, t: TrackId, r: Range) -> Result<bool> {
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
/// The most frames of `channels`-channel audio one offline render holds.
|
||||
pub fn render_limit(channels: usize) -> usize {
|
||||
soundcraft_mix::MAX_RENDER_SAMPLES / channels.max(1)
|
||||
}
|
||||
|
||||
/// Refuses `len` frames of `name` past `max`, where a render would cut the audio short.
|
||||
pub fn refuse_longer(s: &Session, cmd: &str, name: &str, verb: &str, len: Samples, max: usize) -> Result<()> {
|
||||
if len <= Samples::try_from(max).unwrap_or(Samples::MAX) {
|
||||
return Ok(());
|
||||
}
|
||||
let secs = max as f64 / s.sample_rate.as_f64();
|
||||
Err(crate::cmd::bad(cmd, format!("`{name}` is too long to {verb} (at most {secs:.0} s)")))
|
||||
}
|
||||
|
||||
/// Render clip gain into a new source and reset the clip's gain.
|
||||
pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result<bool> {
|
||||
pub fn render_clip_gain(e: &mut Engine, cmd: &str, id: soundcraft_model::ClipId) -> Result<bool> {
|
||||
let s = e.session();
|
||||
let Some((t, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { return Ok(false) };
|
||||
if !c.is_audio() {
|
||||
return Ok(false);
|
||||
}
|
||||
refuse_longer(s, cmd, &c.name, "render", c.length, render_limit(s.track(t).map_or(1, |x| x.channels())))?;
|
||||
let mut c2 = c.clone();
|
||||
c2.fade_in = Default::default();
|
||||
c2.fade_out = Default::default();
|
||||
|
|
@ -449,6 +757,8 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
|
|||
return Ok(None);
|
||||
}
|
||||
let range = Range::new(0, end + s.sample_rate.samples(2.0));
|
||||
// The track renders through a main mix of at least two channels.
|
||||
refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?;
|
||||
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
|
||||
let name = format!("{}.cm", tr.name);
|
||||
let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2));
|
||||
|
|
@ -479,7 +789,7 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
|
|||
|
||||
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
|
||||
let s = e.session();
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||||
make_dir(dir)?;
|
||||
let mut n = 0;
|
||||
for id in ids {
|
||||
let Some((t, c)) = s.find_clip(*id) else { continue };
|
||||
|
|
@ -496,7 +806,7 @@ pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) ->
|
|||
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
|
||||
}
|
||||
}
|
||||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
write_path(&path, bytes)?;
|
||||
n += 1;
|
||||
}
|
||||
Ok(n)
|
||||
|
|
@ -541,6 +851,8 @@ pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
|
|||
/// files written and the final path.
|
||||
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
|
||||
let p = session_file(path);
|
||||
// Before any audio is written: a session saved outside the write root changes nothing.
|
||||
crate::file_access::check_write(&p).map_err(EngineError::Io)?;
|
||||
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let audio_dir = dir.join("Audio Files");
|
||||
let mut s = session.clone();
|
||||
|
|
@ -568,7 +880,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
|||
continue;
|
||||
}
|
||||
let Some(audio) = s.pool.get(src.id) else { continue };
|
||||
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
|
||||
make_dir(&audio_dir)?;
|
||||
let mut stem = sanitize_name(&src.name);
|
||||
if overwrite {
|
||||
// One file per source id so autosaves never collide or pile up.
|
||||
|
|
@ -595,7 +907,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
|||
s.name = stem.to_string();
|
||||
}
|
||||
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
|
||||
make_dir(&dir)?;
|
||||
// Atomic-ish write: temp file then rename.
|
||||
let tmp = p.with_extension("scraft.tmp");
|
||||
if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) {
|
||||
|
|
@ -608,7 +920,15 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
|
|||
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
|
||||
/// old or the new contents, never a mix.
|
||||
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
|
||||
if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) {
|
||||
crate::file_access::check_write(file).map_err(EngineError::Io)?;
|
||||
crate::file_access::check_write(tmp).map_err(EngineError::Io)?;
|
||||
replace_with(tmp, file, write_synced(tmp, bytes))
|
||||
}
|
||||
|
||||
/// Renames `tmp` over `file` once it is `written`, or removes it when writing or renaming failed.
|
||||
/// The caller has checked both paths ([`crate::file_access::check_write`]).
|
||||
fn replace_with(tmp: &Path, file: &Path, written: std::io::Result<()>) -> Result<()> {
|
||||
if let Err(err) = written.and_then(|()| std::fs::rename(tmp, file)) {
|
||||
let _ = std::fs::remove_file(tmp);
|
||||
return Err(EngineError::Io(format!("{}: {err}", file.display())));
|
||||
}
|
||||
|
|
@ -636,6 +956,10 @@ fn back_up(file: &Path) {
|
|||
return;
|
||||
};
|
||||
let backups = dir.join("Backups");
|
||||
if let Err(err) = crate::file_access::check_write(&backups) {
|
||||
log::warn!("no backup of {}: {err}", file.display());
|
||||
return;
|
||||
}
|
||||
let prefix = format!("{stem} backup ");
|
||||
let suffix = format!(".{ext}");
|
||||
let numbers: Vec<u32> = std::fs::read_dir(&backups)
|
||||
|
|
@ -659,7 +983,9 @@ fn back_up(file: &Path) {
|
|||
/// extension and isn't an existing file.
|
||||
pub fn session_file(path: &str) -> PathBuf {
|
||||
let p = PathBuf::from(path);
|
||||
if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
|
||||
// Only a path the write root allows is probed, so a refused one can't be used to test for files.
|
||||
let is_file = crate::file_access::check_write(&p).is_ok() && p.is_file();
|
||||
if p.extension().is_none() && !is_file { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
|
||||
}
|
||||
|
||||
/// True when `command` would write a session over the existing `file`. Save, Save As and Save
|
||||
|
|
@ -708,12 +1034,17 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
|
|||
|
||||
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
|
||||
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
|
||||
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
|
||||
let text = read_text(path)?;
|
||||
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
|
||||
let mut missing = Vec::new();
|
||||
for src in s.sources.clone() {
|
||||
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
|
||||
// A session file is hostile input too: a path in it that leaves the read root is not read.
|
||||
if let Err(why) = crate::file_access::check_read(&p) {
|
||||
missing.push(why);
|
||||
continue;
|
||||
}
|
||||
match std::fs::read(&p) {
|
||||
Ok(bytes) => {
|
||||
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
|
||||
|
|
@ -774,6 +1105,7 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
|||
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
|
||||
return deliver(name, bytes).map_err(EngineError::Io);
|
||||
}
|
||||
crate::file_access::check_write(path).map_err(EngineError::Io)?;
|
||||
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
|
||||
}
|
||||
|
||||
|
|
@ -925,6 +1257,37 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// A recording's plan, made when it starts: each record-enabled track's take file, where the
|
||||
/// takes start on the timeline, the loop length they split into when loop recording, and the
|
||||
/// punch range they're trimmed to.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Recording {
|
||||
pub start: Samples,
|
||||
pub pass: Option<Samples>,
|
||||
pub punch: Option<Range>,
|
||||
/// The input's sample rate, which the take files are written at.
|
||||
pub rate: u32,
|
||||
pub takes: Vec<Take>,
|
||||
}
|
||||
|
||||
/// One track's take file.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Take {
|
||||
pub track: TrackId,
|
||||
pub path: PathBuf,
|
||||
pub channels: usize,
|
||||
}
|
||||
|
||||
/// Place recorded take files as `plan` says, as one undo step ("Record").
|
||||
pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec<soundcraft_model::ClipId> {
|
||||
let ids = add_recording(e, plan.start, takes, plan.pass);
|
||||
if let Some(p) = plan.punch {
|
||||
let clips: Vec<u64> = ids.iter().map(|c| c.0).collect();
|
||||
let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record");
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
|
||||
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
|
||||
/// 1000), each pass after the first on a new playlist.
|
||||
|
|
@ -967,6 +1330,15 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu
|
|||
out
|
||||
}
|
||||
|
||||
/// Where a take recording from `start` shows while it holds `len` samples, as it will be placed:
|
||||
/// just the pass under way when loop recording in passes of `pass`, within the `punch` range.
|
||||
/// Returns the timeline position of its first sample and the span it covers.
|
||||
pub fn live_span(start: Samples, pass: Option<Samples>, punch: Option<Range>, len: Samples) -> Option<(Samples, Range)> {
|
||||
let origin = start.saturating_sub(pass.filter(|p| *p > 0).map_or(0, |p| len / p * p));
|
||||
let span = Range::new(start, origin.saturating_add(len));
|
||||
Some((origin, span.intersect(&punch.unwrap_or(span))?))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod record_tests {
|
||||
use super::*;
|
||||
|
|
@ -1029,6 +1401,17 @@ mod record_tests {
|
|||
assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
|
||||
assert_eq!(live_span(1_000, None, None, 0), None);
|
||||
assert_eq!(live_span(1_000, None, None, 500), Some((1_000, Range::new(1_000, 1_500))));
|
||||
assert_eq!(live_span(1_000, Some(400), None, 900), Some((200, Range::new(1_000, 1_100))));
|
||||
assert_eq!(live_span(1_000, Some(400), None, 800), None);
|
||||
let punch = Some(Range::new(1_200, 1_400));
|
||||
assert_eq!(live_span(1_000, None, punch, 150), None);
|
||||
assert_eq!(live_span(1_000, None, punch, 500), Some((1_000, Range::new(1_200, 1_400))));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -1139,7 +1522,10 @@ mod save_tests {
|
|||
open_session(&mut missing, &path("A/A.scraft")).unwrap();
|
||||
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
|
||||
let mut r = Engine::default();
|
||||
assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]);
|
||||
assert_eq!(
|
||||
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
|
||||
vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()]
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
|
@ -1229,6 +1615,43 @@ mod stem_tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod strip_tests {
|
||||
use super::*;
|
||||
|
||||
/// A stereo track with `length`-long clips at `starts`, each playing a second of steady tone.
|
||||
fn tones(starts: &[Samples], length: Samples) -> (Engine, TrackId) {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"format": "stereo"})).unwrap();
|
||||
let t = e.session().tracks[0].id;
|
||||
let s = e.session_mut();
|
||||
let tone = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 48_000]; 2] };
|
||||
let src = add_source(s, "tone", tone, None, FileFormat::Wav);
|
||||
for at in starts {
|
||||
let id = s.new_clip_id();
|
||||
crate::edit::place_clip(s, t, Clip::audio(id, "tone", src, 0, *at, length));
|
||||
}
|
||||
(e, t)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clips_past_what_one_render_holds_are_kept() {
|
||||
// 100 minutes in: a stereo render stops at 2^28 frames, about 93 minutes.
|
||||
let far = 48_000 * 6000;
|
||||
let (mut e, t) = tones(&[0, far], 48_000);
|
||||
let r = e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": far + 96_000})).unwrap();
|
||||
assert_eq!(r["clips"], 2);
|
||||
assert!(e.session().track(t).unwrap().clips().iter().any(|c| c.start >= far));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_than_a_day_of_clips_is_refused() {
|
||||
let (mut e, t) = tones(&[0], 48_000 * 86_401);
|
||||
assert!(e.execute("edit.strip_silence", &json!({"track": t.0, "start": 0, "end": 48_000i64 * 86_402})).is_err());
|
||||
assert_eq!(e.session().track(t).unwrap().clips().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bundle_tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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"}));
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
//! A take being recorded is never replaced or quit away from.
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::{Engine, EngineError};
|
||||
|
||||
#[test]
|
||||
fn nothing_replaces_or_quits_a_session_while_it_records() {
|
||||
let mut e = Engine::default();
|
||||
e.transport.recording = true;
|
||||
for (id, params) in [
|
||||
("session.new", json!({})),
|
||||
("session.open", json!({"path": "Song.scraft"})),
|
||||
("session.close", json!({})),
|
||||
("session.revert", json!({})),
|
||||
("app.quit", json!({})),
|
||||
] {
|
||||
assert!(matches!(e.execute(id, ¶ms), Err(EngineError::Disabled(_, why)) if why == "stop recording first"), "{id}");
|
||||
}
|
||||
assert!(e.execute("track.new", &json!({})).is_ok(), "editing goes on");
|
||||
e.transport.recording = false;
|
||||
assert!(e.execute("session.close", &json!({})).is_ok());
|
||||
}
|
||||
|
|
@ -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)]);
|
||||
}
|
||||
|
|
@ -51,12 +51,14 @@
|
|||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
|
||||
use soundcraft_dsp::{Plugin, SampleSource, db_to_gain};
|
||||
use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain};
|
||||
use soundcraft_midi::CtrlKind;
|
||||
use soundcraft_model::{
|
||||
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
|
||||
};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER};
|
||||
use std::collections::HashMap;
|
||||
use std::ops::Bound;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Default (stereo) main mix width. The actual width is [`main_channels`].
|
||||
|
|
@ -224,6 +226,8 @@ struct Strip {
|
|||
sample: Option<Arc<SourceAudio>>,
|
||||
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
|
||||
held: Vec<u8>,
|
||||
/// The instrument's next block starts somewhere new: send it the controllers in effect there.
|
||||
chase: bool,
|
||||
muted: bool,
|
||||
gr: f32,
|
||||
/// Per-clip effect chains (Clip Effects) and a scratch buffer to render clips into.
|
||||
|
|
@ -240,6 +244,10 @@ struct Strip {
|
|||
failures: PluginFailures,
|
||||
}
|
||||
|
||||
/// Most latency delay compensation handles: 2^20 samples, about 22 s at 48 kHz. A strip reports
|
||||
/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders.
|
||||
const MAX_LATENCY: usize = 1 << 20;
|
||||
|
||||
/// A multichannel delay line (samples), resized only when the delay changes.
|
||||
#[derive(Default)]
|
||||
struct DelayLine {
|
||||
|
|
@ -250,7 +258,7 @@ struct DelayLine {
|
|||
|
||||
impl DelayLine {
|
||||
fn set(&mut self, len: usize, ch: usize) {
|
||||
let len = len.min(1 << 20);
|
||||
let len = len.min(MAX_LATENCY);
|
||||
if len != self.len || self.bufs.len() != ch {
|
||||
self.len = len;
|
||||
self.idx = 0;
|
||||
|
|
@ -277,6 +285,9 @@ impl DelayLine {
|
|||
}
|
||||
self.idx = (start + frames) % self.len.max(1);
|
||||
}
|
||||
fn clear(&mut self) {
|
||||
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Clip Effects: an EQ and a compressor per clip, configured from `edit.values`
|
||||
|
|
@ -299,6 +310,7 @@ impl Strip {
|
|||
instrument: None,
|
||||
sample: None,
|
||||
held: Vec::new(),
|
||||
chase: true,
|
||||
muted: false,
|
||||
gr: 0.0,
|
||||
clip_fx: HashMap::new(),
|
||||
|
|
@ -371,6 +383,13 @@ impl SlotCtx<'_> {
|
|||
}
|
||||
}
|
||||
|
||||
/// True when the host handed over an instance for this slot. It replaces whatever the slot
|
||||
/// holds, even an instance of the same plugin: the host made it because it no longer tracks
|
||||
/// the old one (it was removed and re-added between two renders), and drives its editor.
|
||||
fn waiting(&self, t: TrackId, idx: usize) -> bool {
|
||||
self.external && self.adopt.contains_key(&(t, idx))
|
||||
}
|
||||
|
||||
/// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels.
|
||||
fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option<PluginSlot> {
|
||||
let third = is_third_party(&ins.plugin);
|
||||
|
|
@ -485,6 +504,10 @@ impl MixEngine {
|
|||
i.failed = false;
|
||||
}
|
||||
s.held.clear();
|
||||
s.chase = true;
|
||||
s.align.clear();
|
||||
s.align_pre.clear();
|
||||
s.out_extra.clear();
|
||||
}
|
||||
self.meters.clear();
|
||||
self.main_meter = StripMeter::default();
|
||||
|
|
@ -522,7 +545,7 @@ impl MixEngine {
|
|||
strip.plugins.resize_with(t.mixer.inserts.len(), || None);
|
||||
for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() {
|
||||
match ins {
|
||||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => {
|
||||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => {
|
||||
let old = slot.take();
|
||||
cx.retire(old);
|
||||
*slot = cx.make(t.id, idx, i, ch);
|
||||
|
|
@ -536,7 +559,9 @@ impl MixEngine {
|
|||
}
|
||||
let want = t.instrument.as_ref();
|
||||
let ich = ch.max(2);
|
||||
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich)) {
|
||||
if strip.instrument.as_ref().map(|p| (p.id.as_str(), p.ch)) != want.map(|i| (i.plugin.as_str(), ich))
|
||||
|| (want.is_some() && cx.waiting(t.id, INSTRUMENT_SLOT))
|
||||
{
|
||||
let old = strip.instrument.take();
|
||||
cx.retire(old);
|
||||
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
|
||||
|
|
@ -587,6 +612,7 @@ impl MixEngine {
|
|||
i.plugin.all_notes_off();
|
||||
}
|
||||
st.held.clear();
|
||||
st.chase = true;
|
||||
}
|
||||
}
|
||||
self.last_end = pos.saturating_add(frames as i64);
|
||||
|
|
@ -681,6 +707,7 @@ impl MixEngine {
|
|||
}
|
||||
self.order = order;
|
||||
// Master faders process the main mix.
|
||||
let transport = transport_at(s, pos, !self.monitor_only, self.recording);
|
||||
for t in s.tracks.iter().filter(|t| t.kind == TrackKind::Master && !t.inactive) {
|
||||
if let Some(strip) = self.strips.get_mut(&t.id) {
|
||||
for (dst, src) in strip.buf.iter_mut().zip(self.main.iter()) {
|
||||
|
|
@ -695,6 +722,7 @@ impl MixEngine {
|
|||
&& !slot.failed
|
||||
{
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.set_transport(&transport);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
if contain_failure(slot, &mut strip.buf, frames) {
|
||||
strip.failures.record(i);
|
||||
|
|
@ -782,8 +810,7 @@ impl MixEngine {
|
|||
let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law);
|
||||
apply_matrix(src, frames, &mut dst.buf, &m, gain);
|
||||
}
|
||||
let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() };
|
||||
match &t.mixer.output {
|
||||
let src: &[Vec<f32>] = match &t.mixer.output {
|
||||
Route::Main if strip.out_extra.len > 0 => {
|
||||
if strip.outbuf.len() != strip.buf.len() {
|
||||
strip.outbuf = vec![Vec::new(); strip.buf.len()];
|
||||
|
|
@ -793,20 +820,25 @@ impl MixEngine {
|
|||
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
|
||||
}
|
||||
strip.out_extra.process(&mut strip.outbuf, frames);
|
||||
let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law);
|
||||
apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0);
|
||||
&strip.outbuf
|
||||
}
|
||||
Route::Main => {
|
||||
let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law);
|
||||
apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0);
|
||||
}
|
||||
Route::Bus(b) => {
|
||||
if let Some(dst) = self.busses.get_mut(b) {
|
||||
let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law);
|
||||
apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
_ => &strip.buf,
|
||||
};
|
||||
let (dst, dst_fmt) = match &t.mixer.output {
|
||||
Route::Main => (&mut self.main, self.main_fmt),
|
||||
Route::Bus(b) => match self.busses.get_mut(b) {
|
||||
Some(d) => (&mut d.buf, d.fmt),
|
||||
None => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let mut start = 0;
|
||||
while start < frames {
|
||||
let (at, end) = pan_segment(t, pos, start, frames);
|
||||
let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() };
|
||||
let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law);
|
||||
apply_matrix_span(src, start..end, dst, &m, 1.0);
|
||||
start = end;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -833,6 +865,7 @@ fn process_strip(
|
|||
for c in strip.buf.iter_mut() {
|
||||
c.iter_mut().take(frames).for_each(|x| *x = 0.0);
|
||||
}
|
||||
let transport = transport_at(s, pos, !live.monitor_only, live.recording);
|
||||
match t.kind {
|
||||
TrackKind::Audio if !live.input.is_empty() && (t.mixer.input_monitor || (t.mixer.record_arm && (live.monitor_only || live.recording))) => {
|
||||
// Input monitoring: the track hears its hardware input instead of its clips.
|
||||
|
|
@ -871,7 +904,7 @@ fn process_strip(
|
|||
}
|
||||
render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch);
|
||||
let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch));
|
||||
fx.configure(¶ms);
|
||||
fx.configure(params);
|
||||
fx.process(&mut strip.clipbuf, frames);
|
||||
for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) {
|
||||
for (x, y) in d.iter_mut().zip(c.iter()).take(frames) {
|
||||
|
|
@ -913,6 +946,10 @@ fn process_strip(
|
|||
if let Some(ins) = &t.instrument {
|
||||
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
|
||||
}
|
||||
inst.plugin.set_transport(&transport);
|
||||
if std::mem::take(&mut strip.chase) {
|
||||
chase_controllers(s, t, pos, inst);
|
||||
}
|
||||
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
|
||||
if strip.buf.len() >= 2 {
|
||||
inst.plugin.process(&mut strip.buf, frames);
|
||||
|
|
@ -948,6 +985,7 @@ fn process_strip(
|
|||
continue;
|
||||
}
|
||||
apply_params(slot, ins, t, i, pos);
|
||||
slot.plugin.set_transport(&transport);
|
||||
slot.plugin.process(&mut strip.buf, frames);
|
||||
if contain_failure(slot, &mut strip.buf, frames) {
|
||||
strip.failures.record(i);
|
||||
|
|
@ -959,7 +997,7 @@ fn process_strip(
|
|||
}
|
||||
}
|
||||
strip.gr = gr;
|
||||
strip.latency = latency;
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
|
||||
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
|
||||
strip.muted = t.mixer.mute || auto_mute || soloed_out;
|
||||
|
|
@ -1020,19 +1058,49 @@ fn contain_failure(slot: &mut PluginSlot, buf: &mut [Vec<f32>], frames: usize) -
|
|||
}
|
||||
|
||||
/// Clip-effect parameters of a clip, or None when it has none or they are bypassed.
|
||||
fn clip_fx_params(s: &Session, clip: u64) -> Option<Vec<(String, f32)>> {
|
||||
if s.edit.values.is_empty() {
|
||||
fn clip_fx_params(s: &Session, clip: u64) -> Option<impl Iterator<Item = (&str, f32)> + Clone> {
|
||||
if s.edit.values.is_empty() || Key::new(format_args!("clip_fx.bypass.{clip}")).is_none_or(|k| s.edit.flag(k.as_str())) {
|
||||
return None;
|
||||
}
|
||||
let pre = format!("clip_fx.{clip}.");
|
||||
let v: Vec<(String, f32)> = s
|
||||
let pre = Key::new(format_args!("clip_fx.{clip}."))?;
|
||||
let mut params = s
|
||||
.edit
|
||||
.values
|
||||
.range(pre.clone()..)
|
||||
.take_while(|(k, _)| k.starts_with(&pre))
|
||||
.map(|(k, v)| (k.get(pre.len()..).unwrap_or("").to_string(), *v as f32))
|
||||
.collect();
|
||||
if v.is_empty() || s.edit.flag(&format!("clip_fx.bypass.{clip}")) { None } else { Some(v) }
|
||||
.range::<str, _>((Bound::Included(pre.as_str()), Bound::Unbounded))
|
||||
.take_while(move |(k, _)| k.starts_with(pre.as_str()))
|
||||
.map(move |(k, v)| (k.get(pre.len..).unwrap_or(""), *v as f32))
|
||||
.peekable();
|
||||
params.peek()?;
|
||||
Some(params)
|
||||
}
|
||||
|
||||
/// A short key formatted on the stack: lookups made on the audio thread must not allocate.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Key {
|
||||
buf: [u8; 64],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// None when the key doesn't fit.
|
||||
fn new(args: std::fmt::Arguments<'_>) -> Option<Key> {
|
||||
let mut k = Key { buf: [0; 64], len: 0 };
|
||||
std::fmt::Write::write_fmt(&mut k, args).ok()?;
|
||||
Some(k)
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &str {
|
||||
self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Write for Key {
|
||||
fn write_str(&mut self, s: &str) -> std::fmt::Result {
|
||||
let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?;
|
||||
self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes());
|
||||
self.len = end;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ClipFx {
|
||||
|
|
@ -1046,10 +1114,10 @@ impl ClipFx {
|
|||
})
|
||||
}
|
||||
/// Modules exist only when one of their parameters is set.
|
||||
fn configure(&mut self, params: &[(String, f32)]) {
|
||||
fn configure<'a>(&mut self, params: impl Iterator<Item = (&'a str, f32)> + Clone) {
|
||||
self.gain_db = 0.0;
|
||||
let has_eq = params.iter().any(|(k, _)| k.starts_with("eq."));
|
||||
let has_comp = params.iter().any(|(k, _)| k.starts_with("comp."));
|
||||
let has_eq = params.clone().any(|(k, _)| k.starts_with("eq."));
|
||||
let has_comp = params.clone().any(|(k, _)| k.starts_with("comp."));
|
||||
if has_eq && self.eq.is_none() {
|
||||
self.eq = self.make("eq_7band");
|
||||
} else if !has_eq {
|
||||
|
|
@ -1063,18 +1131,18 @@ impl ClipFx {
|
|||
for (k, v) in params {
|
||||
if let Some(p) = k.strip_prefix("eq.") {
|
||||
if let Some(eq) = &mut self.eq
|
||||
&& eq.param(p) != Some(*v)
|
||||
&& eq.param(p) != Some(v)
|
||||
{
|
||||
eq.set_param(p, *v);
|
||||
eq.set_param(p, v);
|
||||
}
|
||||
} else if let Some(p) = k.strip_prefix("comp.") {
|
||||
if let Some(c) = &mut self.comp
|
||||
&& c.param(p) != Some(*v)
|
||||
&& c.param(p) != Some(v)
|
||||
{
|
||||
c.set_param(p, *v);
|
||||
c.set_param(p, v);
|
||||
}
|
||||
} else if k == "gain" {
|
||||
self.gain_db = *v;
|
||||
self.gain_db = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1345,9 +1413,27 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
|
|||
}
|
||||
}
|
||||
|
||||
/// Send note on/offs for MIDI clips overlapping the block.
|
||||
/// The track's MIDI Track Offset (`midi.offset.<id>` in the edit values), in samples. Looked up
|
||||
/// without formatting the key, since this runs on the audio thread every block.
|
||||
fn midi_offset(s: &Session, t: &Track) -> i64 {
|
||||
const PREFIX: &str = "midi.offset.";
|
||||
s.edit
|
||||
.values
|
||||
.range::<str, _>((std::ops::Bound::Included(PREFIX), std::ops::Bound::Unbounded))
|
||||
.take_while(|(k, _)| k.starts_with(PREFIX))
|
||||
.find(|(k, _)| k.get(PREFIX.len()..).and_then(|id| id.parse::<u64>().ok()) == Some(t.id.0))
|
||||
.map(|(_, v)| *v)
|
||||
.filter(|v| v.is_finite())
|
||||
.map_or(0, |v| v as i64)
|
||||
}
|
||||
|
||||
/// Send note on/offs and controllers for MIDI clips overlapping the block.
|
||||
fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {
|
||||
let sr = s.sample_rate;
|
||||
// MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so
|
||||
// the block looks that far back into the clips.
|
||||
let offset = midi_offset(s, t);
|
||||
let pos = pos.saturating_sub(offset);
|
||||
let end = pos.saturating_add(frames as i64);
|
||||
for clip in t.clips() {
|
||||
if clip.muted {
|
||||
|
|
@ -1359,7 +1445,7 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
|
|||
}
|
||||
let base = s.tempo.samples_to_ticks(clip.start, sr);
|
||||
// MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay).
|
||||
let rtp = |name: &str, d: f64| s.edit.value(&format!("rtp.{}.{name}", t.id.0), d);
|
||||
let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d));
|
||||
let (dv, dp, dur, delay) = if s.edit.values.is_empty() {
|
||||
(0i64, 0i64, 1.0f64, 0i64)
|
||||
} else {
|
||||
|
|
@ -1392,6 +1478,80 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
|
|||
held.retain(|p| *p != n.pitch);
|
||||
}
|
||||
}
|
||||
let near = s.tempo.samples_to_ticks(pos, sr).saturating_sub(base).saturating_sub(1)
|
||||
..=s.tempo.samples_to_ticks(end, sr).saturating_sub(base).saturating_add(1);
|
||||
for c in &sequence.ctrls {
|
||||
let tick = c.tick.saturating_add(delay).max(0);
|
||||
if !near.contains(&tick) {
|
||||
continue;
|
||||
}
|
||||
let at = s.tempo.tick_to_samples(base.saturating_add(tick), sr);
|
||||
if at >= pos && at < end && at < clip.end() {
|
||||
inst.plugin.midi(usize::try_from(at - pos).unwrap_or(0), ctrl_message(c.kind, c.value, dp));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A controller event as a MIDI 1.0 message on channel 1 (where the notes go), with `transpose`
|
||||
/// applied to a poly-pressure key.
|
||||
fn ctrl_message(kind: CtrlKind, value: i32, transpose: i64) -> [u8; 3] {
|
||||
let v7 = value.clamp(0, 127) as u8;
|
||||
match kind {
|
||||
CtrlKind::Cc(n) => [0xB0, n & 0x7F, v7],
|
||||
CtrlKind::PitchBend => {
|
||||
let v = value.saturating_add(8192).clamp(0, 16383);
|
||||
[0xE0, (v & 0x7F) as u8, (v >> 7) as u8]
|
||||
}
|
||||
CtrlKind::ChannelPressure => [0xD0, v7, 0],
|
||||
CtrlKind::PolyPressure(k) => [0xA0, (i64::from(k) + transpose).clamp(0, 127) as u8, v7],
|
||||
CtrlKind::Program => [0xC0, v7, 0],
|
||||
}
|
||||
}
|
||||
|
||||
/// Before an instrument plays from `pos`: reset its controllers, then send each one the last value
|
||||
/// it had on this track before `pos`.
|
||||
fn chase_controllers(s: &Session, t: &Track, pos: Samples, inst: &mut PluginSlot) {
|
||||
inst.plugin.midi(0, [0xB0, 121, 0]);
|
||||
const BEND: usize = 128;
|
||||
const PRESSURE: usize = 129;
|
||||
const PROGRAM: usize = 130;
|
||||
let mut last = [(i64::MIN, 0i32); 131];
|
||||
let sr = s.sample_rate;
|
||||
for clip in t.clips() {
|
||||
let ClipContent::Midi { sequence } = &clip.content else { continue };
|
||||
if clip.muted || clip.start >= pos {
|
||||
continue;
|
||||
}
|
||||
let base = s.tempo.samples_to_ticks(clip.start, sr);
|
||||
let until = pos.min(clip.end());
|
||||
for c in &sequence.ctrls {
|
||||
let slot = match c.kind {
|
||||
CtrlKind::Cc(n) => usize::from(n & 0x7F),
|
||||
CtrlKind::PitchBend => BEND,
|
||||
CtrlKind::ChannelPressure => PRESSURE,
|
||||
CtrlKind::Program => PROGRAM,
|
||||
CtrlKind::PolyPressure(_) => continue,
|
||||
};
|
||||
let at = s.tempo.tick_to_samples(base.saturating_add(c.tick), sr);
|
||||
if at < until
|
||||
&& let Some(l) = last.get_mut(slot)
|
||||
&& at >= l.0
|
||||
{
|
||||
*l = (at, c.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (slot, &(at, value)) in last.iter().enumerate() {
|
||||
let kind = match slot {
|
||||
BEND => CtrlKind::PitchBend,
|
||||
PRESSURE => CtrlKind::ChannelPressure,
|
||||
PROGRAM => CtrlKind::Program,
|
||||
n => CtrlKind::Cc(u8::try_from(n).unwrap_or(0)),
|
||||
};
|
||||
if at != i64::MIN {
|
||||
inst.plugin.midi(0, ctrl_message(kind, value, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1440,7 +1600,7 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
|
|||
apply_params(slot, ins, t, i, pos);
|
||||
latency = latency.saturating_add(slot.plugin.latency());
|
||||
}
|
||||
strip.latency = latency;
|
||||
strip.latency = latency.min(MAX_LATENCY);
|
||||
}
|
||||
|
||||
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
|
||||
|
|
@ -1476,6 +1636,27 @@ fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: im
|
|||
}
|
||||
}
|
||||
|
||||
fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
|
||||
let sr = s.sample_rate;
|
||||
let ticks = Some(s.tempo.samples_to_ticks_f(pos, sr)).filter(|t| t.is_finite()).unwrap_or(0.0);
|
||||
let tick = ticks.floor() as i64;
|
||||
let meter = s.tempo.meter_at_tick(tick);
|
||||
let bar = s.tempo.bar_beat_at_tick(tick).bar;
|
||||
let q = TICKS_PER_QUARTER as f64;
|
||||
Transport {
|
||||
playing,
|
||||
recording,
|
||||
tempo: s.tempo.tempo_at_tick(tick),
|
||||
numerator: meter.numerator,
|
||||
denominator: meter.denominator,
|
||||
samples: pos,
|
||||
seconds: pos as f64 / sr.as_f64().max(1.0),
|
||||
quarters: ticks / q,
|
||||
bar_start: s.tempo.bar_start_tick(bar) as f64 / q,
|
||||
bar: bar - 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn volume_db_at(t: &Track, pos: Samples) -> f32 {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
|
||||
|
|
@ -1498,6 +1679,23 @@ fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
|
|||
base
|
||||
}
|
||||
|
||||
/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block,
|
||||
/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically.
|
||||
const PAN_STEP: Samples = 64;
|
||||
|
||||
/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end
|
||||
/// of the frames that share it. Without pan automation the whole block shares one pan.
|
||||
fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) {
|
||||
let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty());
|
||||
if !automated {
|
||||
return (pos, frames);
|
||||
}
|
||||
let here = pos.saturating_add(start as i64);
|
||||
let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP);
|
||||
let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1);
|
||||
(at, start.saturating_add(left).min(frames))
|
||||
}
|
||||
|
||||
fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
|
||||
if t.mixer.automation_mode.reads()
|
||||
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
|
||||
|
|
@ -1747,6 +1945,11 @@ fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: u
|
|||
|
||||
/// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation).
|
||||
fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
|
||||
apply_matrix_span(src, 0..frames, dst, m, gain);
|
||||
}
|
||||
|
||||
/// [`apply_matrix`] over the frames in `span` only.
|
||||
fn apply_matrix_span(src: &[Vec<f32>], span: std::ops::Range<usize>, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
|
||||
let ns = src.len().min(MAX_CHANNELS);
|
||||
for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) {
|
||||
let mut idx = [(0usize, 0.0f32); MAX_CHANNELS];
|
||||
|
|
@ -1764,7 +1967,7 @@ fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matri
|
|||
if taps.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, o) in out.iter_mut().enumerate().take(frames) {
|
||||
for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) {
|
||||
let mut acc = 0.0f32;
|
||||
for &(s, g) in taps {
|
||||
acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g;
|
||||
|
|
@ -1774,88 +1977,102 @@ fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matri
|
|||
}
|
||||
}
|
||||
|
||||
/// Ceiling on one offline render (samples summed over channels): 2^29 f32 = 2 GiB, about 93
|
||||
/// minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory.
|
||||
/// Ceiling on one in-memory offline render (samples summed over channels): 2^29 f32 = 2 GiB, about
|
||||
/// 93 minutes of stereo at 48 kHz. Longer requests are truncated rather than exhausting memory;
|
||||
/// [`RangeRender`] streams any length.
|
||||
pub const MAX_RENDER_SAMPLES: usize = 1 << 29;
|
||||
|
||||
/// Offline render of the main mix over `range` (planar, [`main_channels`] channels in SMPTE/WAV
|
||||
/// order). The mix's delay-compensation latency is removed, so the result lines up with the
|
||||
/// timeline.
|
||||
pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||||
let nch = main_channels(s);
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1));
|
||||
// Probe the latency with one block, then render `len + latency` and drop the head.
|
||||
let latency = {
|
||||
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = probe.max_block();
|
||||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||||
probe.render(s, range.start, b.min(len.max(1)), &mut tmp);
|
||||
probe.latency
|
||||
};
|
||||
let total = len.saturating_add(latency);
|
||||
let mut out = vec![vec![0.0f32; total]; nch];
|
||||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = eng.max_block();
|
||||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||||
let mut done = 0usize;
|
||||
while done < total {
|
||||
let n = (total - done).min(b);
|
||||
eng.render(s, range.start + done as i64, n, &mut tmp);
|
||||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||||
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||||
d.copy_from_slice(sr);
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
if latency > 0 {
|
||||
for c in &mut out {
|
||||
c.drain(..latency.min(c.len()));
|
||||
}
|
||||
}
|
||||
out
|
||||
render_in_memory(s, None, range, block)
|
||||
}
|
||||
|
||||
/// Offline render of one bus over `range` (planar, the bus format's channel count).
|
||||
/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline.
|
||||
/// An unknown bus is silent.
|
||||
pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||||
let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0);
|
||||
if nch == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch);
|
||||
let latency = {
|
||||
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = probe.max_block();
|
||||
probe.render(s, range.start, b.min(len.max(1)), &mut []);
|
||||
probe.track_latency
|
||||
};
|
||||
let total = len.saturating_add(latency);
|
||||
let mut out = vec![vec![0.0f32; total]; nch];
|
||||
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let b = eng.max_block();
|
||||
let mut tmp = vec![vec![0.0f32; b]; nch];
|
||||
let mut done = 0usize;
|
||||
while done < total {
|
||||
let n = (total - done).min(b);
|
||||
eng.render(s, range.start + done as i64, n, &mut []);
|
||||
eng.copy_bus(bus, n, &mut tmp);
|
||||
for (o, t) in out.iter_mut().zip(tmp.iter()) {
|
||||
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
|
||||
d.copy_from_slice(sr);
|
||||
}
|
||||
}
|
||||
done += n;
|
||||
}
|
||||
if latency > 0 {
|
||||
for c in &mut out {
|
||||
c.drain(..latency.min(c.len()));
|
||||
render_in_memory(s, Some(bus), range, block)
|
||||
}
|
||||
|
||||
fn render_in_memory(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Vec<Vec<f32>> {
|
||||
let nch = source_channels(s, bus);
|
||||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch.max(1));
|
||||
let mut out: Vec<Vec<f32>> = (0..nch).map(|_| Vec::with_capacity(len)).collect();
|
||||
let mut render = RangeRender::new(s, bus, Range::new(range.start, range.start + len as i64), block);
|
||||
while let Some((chunk, n)) = render.next_block(s) {
|
||||
for (o, c) in out.iter_mut().zip(chunk) {
|
||||
o.extend_from_slice(c.get(..n).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Channels of the main mix, or of `bus` (none for an unknown bus).
|
||||
pub fn source_channels(s: &Session, bus: Option<BusId>) -> usize {
|
||||
match bus {
|
||||
Some(id) => s.bus(id).map_or(0, |b| b.format.channels()),
|
||||
None => main_channels(s),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`render_range`] or [`render_bus`] of any length, a block at a time.
|
||||
pub struct RangeRender {
|
||||
eng: MixEngine,
|
||||
bus: Option<BusId>,
|
||||
tmp: Vec<Vec<f32>>,
|
||||
start: Samples,
|
||||
done: usize,
|
||||
total: usize,
|
||||
latency: usize,
|
||||
}
|
||||
|
||||
impl RangeRender {
|
||||
/// Renders the main mix over `range`, or `bus` when one is named.
|
||||
pub fn new(s: &Session, bus: Option<BusId>, range: Range, block: usize) -> Self {
|
||||
let nch = source_channels(s, bus);
|
||||
let eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
|
||||
let tmp = vec![vec![0.0f32; eng.max_block()]; nch];
|
||||
let len = if nch == 0 { 0 } else { usize::try_from(range.len().max(0)).unwrap_or(0) };
|
||||
RangeRender { eng, bus, tmp, start: range.start, done: 0, total: len, latency: 0 }
|
||||
}
|
||||
|
||||
fn render(&mut self, s: &Session, pos: Samples, n: usize) {
|
||||
match self.bus {
|
||||
Some(id) => {
|
||||
self.eng.render(s, pos, n, &mut []);
|
||||
self.eng.copy_bus(id, n, &mut self.tmp);
|
||||
}
|
||||
None => self.eng.render(s, pos, n, &mut self.tmp),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one more block of the mix of `s` (the session given to [`RangeRender::new`]) and
|
||||
/// returns it as `(channels, n)`: the next `n` samples of the range lead each channel (none
|
||||
/// while the mix's latency is still being skipped). `None` once the range is done.
|
||||
pub fn next_block(&mut self, s: &Session) -> Option<(&[Vec<f32>], usize)> {
|
||||
if self.done >= self.total {
|
||||
return None;
|
||||
}
|
||||
let n = (self.total - self.done).min(self.eng.max_block());
|
||||
self.render(s, self.start + self.done as i64, n);
|
||||
if self.done == 0 {
|
||||
// The first block tells the mix's latency: render that much more and drop the head.
|
||||
self.latency = if self.bus.is_some() { self.eng.track_latency } else { self.eng.latency };
|
||||
self.total = self.total.saturating_add(self.latency);
|
||||
}
|
||||
let skip = self.latency.saturating_sub(self.done).min(n);
|
||||
self.done += n;
|
||||
if skip > 0 && skip < n {
|
||||
for c in &mut self.tmp {
|
||||
c.copy_within(skip..n, 0);
|
||||
}
|
||||
}
|
||||
Some((&self.tmp, n - skip))
|
||||
}
|
||||
}
|
||||
|
||||
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
|
||||
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
|
||||
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
||||
|
|
@ -1892,17 +2109,14 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>>
|
|||
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
|
||||
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
|
||||
let mut scratch = Vec::new();
|
||||
for c in t.clips() {
|
||||
if c.muted {
|
||||
continue;
|
||||
}
|
||||
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
|
||||
match clip_fx_params(s, c.id.0) {
|
||||
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
|
||||
Some(params) => {
|
||||
let mut tmp = vec![vec![0.0f32; len]; out.len()];
|
||||
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
|
||||
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
|
||||
fx.configure(¶ms);
|
||||
fx.configure(params);
|
||||
let mut done = 0;
|
||||
while done < len {
|
||||
let n = (len - done).min(4096);
|
||||
|
|
@ -2108,6 +2322,22 @@ mod tests {
|
|||
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_automation_does_not_depend_on_the_block_size() {
|
||||
// Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244).
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
{
|
||||
let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0));
|
||||
l.set_point(0, -1.0);
|
||||
l.set_point(480, 1.0);
|
||||
}
|
||||
let bounce = render_range(&s, Range::new(0, 4800), 1024);
|
||||
assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]);
|
||||
for block in [512, 100, 37] {
|
||||
assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fades_and_clip_gain_apply() {
|
||||
let (mut s, t) = session_with_dc(1.0, 48_000);
|
||||
|
|
@ -2209,6 +2439,158 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
struct Clock {
|
||||
inner: Box<dyn Plugin>,
|
||||
seen: Arc<std::sync::Mutex<Vec<Transport>>>,
|
||||
}
|
||||
|
||||
impl Plugin for Clock {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.inner.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
fn set_transport(&mut self, t: &Transport) {
|
||||
self.seen.lock().unwrap().push(*t);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugins_get_the_session_transport() {
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000 * 4);
|
||||
s.tempo.set_tempo(0, 90.0).unwrap();
|
||||
s.tempo.set_meter(0, 3, 4).unwrap();
|
||||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.clock"));
|
||||
let seen = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let clock = Clock { inner: soundcraft_dsp::create("gain").unwrap(), seen: Arc::clone(&seen) };
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.clock".into(), ch: 1, plugin: Box::new(clock) }]);
|
||||
render_block(&mut eng, &s, 96_000);
|
||||
eng.monitor_only = true;
|
||||
render_block(&mut eng, &s, 96_000);
|
||||
let seen = seen.lock().unwrap();
|
||||
let t0 = seen[0];
|
||||
assert!(t0.playing && !t0.recording);
|
||||
assert_eq!((t0.tempo, t0.numerator, t0.denominator, t0.samples, t0.bar), (90.0, 3, 4, 96_000, 1));
|
||||
assert!((t0.seconds - 2.0).abs() < 1e-9 && (t0.quarters - 3.0).abs() < 1e-9 && (t0.bar_start - 3.0).abs() < 1e-9);
|
||||
assert!(!seen[1].playing);
|
||||
}
|
||||
|
||||
struct Ears {
|
||||
inner: Box<dyn Plugin>,
|
||||
heard: Arc<std::sync::Mutex<Vec<(usize, [u8; 3])>>>,
|
||||
}
|
||||
|
||||
impl Plugin for Ears {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.inner.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
|
||||
self.heard.lock().unwrap().push((offset, msg));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn controllers_play_at_their_offsets_and_are_chased_after_a_seek() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||||
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.ears"));
|
||||
let cc = |tick, kind, value| soundcraft_midi::CtrlEvent { tick, channel: 0, kind, value };
|
||||
let ctrls = vec![cc(4, CtrlKind::Cc(64), 127), cc(960, CtrlKind::PitchBend, -8192), cc(1920, CtrlKind::Cc(64), 0)];
|
||||
let seq = soundcraft_midi::Sequence { ctrls, ..Default::default() };
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000 * 4, seq));
|
||||
let heard = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let ears = Ears { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) };
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.ears".into(), ch: 2, plugin: Box::new(ears) }]);
|
||||
render_block(&mut eng, &s, 0);
|
||||
assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (100, [0xB0, 64, 127])]);
|
||||
render_block(&mut eng, &s, 36_000);
|
||||
assert_eq!(std::mem::take(&mut *heard.lock().unwrap()), vec![(0, [0xB0, 121, 0]), (0, [0xB0, 64, 127]), (0, [0xE0, 0, 0])]);
|
||||
render_block(&mut eng, &s, 36_512);
|
||||
assert!(heard.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
struct Keys {
|
||||
inner: Box<dyn Plugin>,
|
||||
heard: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
|
||||
}
|
||||
|
||||
impl Plugin for Keys {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.inner.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.inner.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.inner.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.inner.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.inner.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.inner.process(io, frames);
|
||||
}
|
||||
fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) {
|
||||
self.heard.lock().unwrap().push((note, velocity));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_time_properties_reach_the_instrument() {
|
||||
let mut s = Session::default();
|
||||
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
|
||||
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys"));
|
||||
let mut seq = soundcraft_midi::Sequence::default();
|
||||
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
|
||||
let cid = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq));
|
||||
s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0);
|
||||
s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0);
|
||||
let heard = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) };
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.set_external_instances(true);
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]);
|
||||
render_block(&mut eng, &s, 0);
|
||||
assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]);
|
||||
}
|
||||
|
||||
fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 {
|
||||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||||
eng.render(s, pos, 512, &mut out);
|
||||
|
|
@ -2324,6 +2706,31 @@ mod tests {
|
|||
assert_eq!(render_block(&mut eng, &s, 1024), only_b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
|
||||
// The host removed and re-added the plugin, and both session updates reached the audio
|
||||
// thread in one go: the slot still holds the old instance (same id, same channels), but
|
||||
// the host made a new one and drives its editor. The new one must win, or the editor
|
||||
// talks to an instance that was retired ("the plugin is gone").
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake"));
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.set_external_instances(true);
|
||||
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let make = |blob: &[u8]| {
|
||||
let mut gain = soundcraft_dsp::create("gain").unwrap();
|
||||
gain.prepare(48_000.0, 512, 1);
|
||||
Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) })
|
||||
};
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]);
|
||||
render_block(&mut eng, &s, 0);
|
||||
assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]);
|
||||
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]);
|
||||
render_block(&mut eng, &s, 512);
|
||||
assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]);
|
||||
assert_eq!(eng.take_retired().len(), 1, "the old instance is retired");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_state_comes_first_then_session_params() {
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
|
|
@ -2393,6 +2800,27 @@ mod tests {
|
|||
assert_ne!(argmax(&out[0]), argmax(&out[1]));
|
||||
}
|
||||
|
||||
/// A reset (stop, locate) must not leave the previous position's audio in the alignment delay.
|
||||
#[test]
|
||||
fn reset_clears_delay_compensation_history() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
s.edit.click = false;
|
||||
s.track_mut(t).unwrap().mixer.pan = vec![1.0];
|
||||
let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let mut comp = Insert::new("compressor");
|
||||
comp.params.insert("lookahead".into(), 5.0);
|
||||
s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp);
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
let mut out = vec![vec![0.0f32; 512]; 2];
|
||||
for b in 0..9 {
|
||||
eng.render(&s, b * 512, 512, &mut out);
|
||||
}
|
||||
assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay");
|
||||
eng.reset();
|
||||
eng.render(&s, 1_000_000, 512, &mut out);
|
||||
assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset");
|
||||
}
|
||||
|
||||
fn impulse_at(ch: &[f32]) -> Vec<usize> {
|
||||
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
|
||||
}
|
||||
|
|
@ -2440,6 +2868,81 @@ mod tests {
|
|||
assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_renders_line_up_with_the_timeline_in_any_block_size() {
|
||||
let mut s = Session::default();
|
||||
let mut imp = vec![0.0f32; 48_000];
|
||||
imp[1000] = 1.0;
|
||||
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![imp] })));
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
let bus = s.add_bus("Stem", ChannelFormat::Stereo);
|
||||
let cid = s.new_clip_id();
|
||||
let tr = s.track_mut(t).unwrap();
|
||||
tr.playlist_mut().unwrap().clips.push(Clip::audio(cid, "i", SourceId(900), 0, 0, 48_000));
|
||||
tr.mixer.inserts[0] = Some(Insert::new("maximizer"));
|
||||
let mut send = soundcraft_model::SendSlot::new(Route::Bus(bus));
|
||||
send.level_db = 0.0;
|
||||
tr.mixer.sends[0] = Some(send);
|
||||
let latency = soundcraft_dsp::create("maximizer").unwrap().latency();
|
||||
assert!(latency > 16);
|
||||
for source in [None, Some(bus)] {
|
||||
for block in [16, 100, 512] {
|
||||
let mut got = Vec::new();
|
||||
let mut skipped = 0;
|
||||
let mut render = RangeRender::new(&s, source, Range::new(900, 2900), block);
|
||||
while let Some((chunk, n)) = render.next_block(&s) {
|
||||
assert!(n <= block);
|
||||
skipped += usize::from(n == 0);
|
||||
got.extend_from_slice(&chunk[0][..n]);
|
||||
}
|
||||
assert_eq!(got.len(), 2000, "{source:?} block {block}");
|
||||
assert_eq!(skipped, latency / block, "{source:?} block {block}");
|
||||
let peak = got.iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).map(|(i, _)| i);
|
||||
assert_eq!(peak, Some(100), "{source:?} block {block}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugins_bogus_latency_is_capped() {
|
||||
struct Latent(Box<dyn Plugin>);
|
||||
impl Plugin for Latent {
|
||||
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
|
||||
self.0.info()
|
||||
}
|
||||
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
|
||||
self.0.prepare(sr, mb, ch);
|
||||
}
|
||||
fn reset(&mut self) {
|
||||
self.0.reset();
|
||||
}
|
||||
fn set_param(&mut self, id: &str, v: f32) -> bool {
|
||||
self.0.set_param(id, v)
|
||||
}
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
self.0.param(id)
|
||||
}
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
self.0.process(io, frames);
|
||||
}
|
||||
fn latency(&self) -> usize {
|
||||
u32::MAX as usize
|
||||
}
|
||||
}
|
||||
let (mut s, t) = session_with_dc(0.5, 48_000);
|
||||
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Mono, None);
|
||||
let mut eng = MixEngine::new(48_000.0, 512);
|
||||
eng.set_external_instances(true);
|
||||
for track in [t, aux] {
|
||||
s.track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.latent"));
|
||||
let mut gain = soundcraft_dsp::create("gain").unwrap();
|
||||
gain.prepare(48_000.0, 512, 1);
|
||||
eng.adopt(vec![PreparedInstance { track, slot: 0, id: "clap:test.latent".into(), ch: 1, plugin: Box::new(Latent(gain)) }]);
|
||||
}
|
||||
render_block(&mut eng, &s, 0);
|
||||
assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_monitoring_replaces_clips() {
|
||||
let (mut s, t) = session_with_dc(0.5, 4800);
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
|
|
|||
|
|
@ -105,6 +105,10 @@ pub struct Clip {
|
|||
/// Reversed playback (AudioSuite Reverse renders into a new source; this is for previews).
|
||||
#[serde(default)]
|
||||
pub group: Option<u64>,
|
||||
/// Marks the clips made by `clip.loop` (the origin and its copies share one id), so
|
||||
/// `clip.unloop` still recognises the loop after the source's content is edited.
|
||||
#[serde(default)]
|
||||
pub loop_id: Option<u64>,
|
||||
}
|
||||
|
||||
fn one() -> f64 {
|
||||
|
|
@ -131,6 +135,7 @@ impl Clip {
|
|||
color: None,
|
||||
stretch: 1.0,
|
||||
group: None,
|
||||
loop_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,14 +219,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 +245,13 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 {
|
||||
|
|
@ -282,6 +304,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);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ pub use markers::{Group, MarkerKind, MemoryLocation};
|
|||
pub use mixer::{
|
||||
Bus, ChannelFormat, INSERT_SLOTS, Insert, Mixer, OutputPath, Route, SEND_SLOTS, SendSlot, Speaker, SurroundPan, fader_db_to_pos, fader_pos_to_db,
|
||||
};
|
||||
pub use session::{BitDepthSetting, EditMode, EditState, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState};
|
||||
pub use session::{
|
||||
BitDepthSetting, EditMode, EditState, FEET_FRAMES_START, SESSION_EXTENSION, Session, SessionError, Tool, TrackView, ZoomState, ZoomToggle,
|
||||
};
|
||||
pub use source::{Source, SourceAudio, SourcePool, VideoSource};
|
||||
pub use track::{Playlist, TRACK_COLORS, Track, TrackHeight, TrackKind};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap
|
|||
/// Native session file extension.
|
||||
pub const SESSION_EXTENSION: &str = "scraft";
|
||||
|
||||
/// `EditState::values` key holding the feet+frames reading of sample 0, in frames.
|
||||
pub const FEET_FRAMES_START: &str = "feet_frames.start";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum SessionError {
|
||||
#[error("not a SoundCraft session: {0}")]
|
||||
|
|
@ -148,6 +151,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 +183,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 +258,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 +435,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])
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,10 @@
|
|||
//! 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 editors are
|
||||
//! driven, and plugin states for saving read ([`Player::capture_states`]), 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)]
|
||||
|
||||
|
|
@ -29,11 +30,14 @@ use soundcraft_model::{Session, TrackId};
|
|||
use soundcraft_time::{Range, Samples};
|
||||
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};
|
||||
|
|
@ -54,6 +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>),
|
||||
/// 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 {
|
||||
|
|
@ -73,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>,
|
||||
|
|
@ -94,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 {
|
||||
|
|
@ -109,6 +183,7 @@ impl AudioState {
|
|||
let nch = main_channels(&session);
|
||||
AudioState {
|
||||
garbage,
|
||||
old_sessions: None,
|
||||
fold: fold_gains(&session),
|
||||
rx,
|
||||
shared,
|
||||
|
|
@ -148,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;
|
||||
|
|
@ -169,6 +248,7 @@ impl AudioState {
|
|||
self.pos = at;
|
||||
self.pending_len = 0;
|
||||
self.read = 0.0;
|
||||
self.mix.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -324,18 +404,32 @@ struct Side {
|
|||
/// is opened.
|
||||
refused: HashSet<(TrackId, usize)>,
|
||||
garbage: Receiver<Vec<Box<dyn Plugin>>>,
|
||||
old_sessions: Option<Receiver<Arc<Session>>>,
|
||||
}
|
||||
|
||||
impl Side {
|
||||
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 }
|
||||
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).
|
||||
/// 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)) {
|
||||
|
|
@ -351,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"))]
|
||||
|
|
@ -369,11 +467,25 @@ 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)
|
||||
}
|
||||
|
|
@ -395,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};
|
||||
|
|
@ -406,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,
|
||||
|
|
@ -418,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),
|
||||
|
|
@ -469,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();
|
||||
|
|
@ -479,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;
|
||||
|
|
@ -491,6 +615,8 @@ impl Player {
|
|||
}
|
||||
Player {
|
||||
shared,
|
||||
fault,
|
||||
watch: Mutex::new(None),
|
||||
tx,
|
||||
side,
|
||||
_stream: None,
|
||||
|
|
@ -512,6 +638,7 @@ impl Player {
|
|||
) -> Player {
|
||||
Player {
|
||||
shared,
|
||||
fault: Arc::default(),
|
||||
tx,
|
||||
side,
|
||||
_stream: None,
|
||||
|
|
@ -680,6 +807,7 @@ impl Side {
|
|||
}
|
||||
self.forget(key);
|
||||
let made = soundcraft_mix::create_instance(&spec.id, spec.state.as_deref(), sr, BLOCK, spec.ch);
|
||||
log::debug!("plugin instance for {:?} slot {}: {} ({} ch, created: {})", spec.track, spec.slot, spec.id, spec.ch, made.is_some());
|
||||
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
|
||||
if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {
|
||||
if let Some(e) = plugin.editor() {
|
||||
|
|
@ -770,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());
|
||||
|
|
@ -939,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 { shadow: HashMap::new(), rate: 48_000.0, editors: HashMap::new(), garbage: grx, old_sessions: None };
|
||||
side.return_old_sessions(&tx);
|
||||
let mut out = vec![0.0f32; 256];
|
||||
let mut watched = Vec::new();
|
||||
for _ in 0..3 * OLD_SESSIONS {
|
||||
let s = Arc::new(soundcraft_model::Session::default());
|
||||
watched.push(Arc::downgrade(&s));
|
||||
side.collect_garbage();
|
||||
tx.send(Cmd::Session(s, Vec::new())).unwrap();
|
||||
st.fill(&mut out, 2);
|
||||
let replaced = watched.len().checked_sub(2).and_then(|i| watched.get(i));
|
||||
assert!(replaced.is_none_or(|w| w.strong_count() == 1), "freed on the audio thread");
|
||||
}
|
||||
side.collect_garbage();
|
||||
assert_eq!(watched.iter().filter(|w| w.strong_count() > 0).count(), 1, "only the live snapshot is left");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looping_wraps() {
|
||||
let s = Arc::new(soundcraft_model::Session::default());
|
||||
|
|
@ -958,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};
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,13 +96,31 @@ impl TempoMap {
|
|||
}
|
||||
|
||||
/// Insert or replace the meter at `tick` (snapped to the bar line at or before it).
|
||||
/// Later meter changes keep their bar numbers, so they stay on bar lines.
|
||||
pub fn set_meter(&mut self, tick: i64, numerator: u32, denominator: u32) -> Result<(), TimeError> {
|
||||
if !(1..=64).contains(&numerator) || ![1, 2, 4, 8, 16, 32, 64].contains(&denominator) {
|
||||
return Err(TimeError::Meter(numerator, denominator));
|
||||
}
|
||||
let tick = if tick <= 0 { 0 } else { self.bar_start_tick(self.bar_beat_at_tick(tick).bar) };
|
||||
let ev = MeterEvent { tick, numerator, denominator };
|
||||
match self.meters.binary_search_by(|e| e.tick.cmp(&tick)) {
|
||||
let later = self.take_meters_after(tick);
|
||||
self.put_meter(MeterEvent { tick, numerator, denominator });
|
||||
self.restore_meters_at_bars(later);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a meter event (the one at tick 0 is kept). Later meter changes keep their bar numbers.
|
||||
pub fn remove_meter(&mut self, tick: i64) -> bool {
|
||||
if tick == 0 || !self.meters.iter().any(|e| e.tick == tick) {
|
||||
return false;
|
||||
}
|
||||
let later = self.take_meters_after(tick);
|
||||
self.meters.retain(|e| e.tick != tick);
|
||||
self.restore_meters_at_bars(later);
|
||||
true
|
||||
}
|
||||
|
||||
fn put_meter(&mut self, ev: MeterEvent) {
|
||||
match self.meters.binary_search_by(|e| e.tick.cmp(&ev.tick)) {
|
||||
Ok(i) => {
|
||||
if let Some(e) = self.meters.get_mut(i) {
|
||||
*e = ev;
|
||||
|
|
@ -110,16 +128,21 @@ impl TempoMap {
|
|||
}
|
||||
Err(i) => self.meters.insert(i, ev),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_meter(&mut self, tick: i64) -> bool {
|
||||
if tick == 0 {
|
||||
return false;
|
||||
/// Remove the meters after `tick`, returning each with the bar it starts.
|
||||
fn take_meters_after(&mut self, tick: i64) -> Vec<(i64, MeterEvent)> {
|
||||
let later: Vec<_> = self.meters.iter().filter(|e| e.tick > tick).map(|e| (self.bar_beat_at_tick(e.tick).bar, *e)).collect();
|
||||
self.meters.retain(|e| e.tick <= tick);
|
||||
later
|
||||
}
|
||||
|
||||
/// Put meters back at the start of their bars under the current earlier meters, in bar order.
|
||||
fn restore_meters_at_bars(&mut self, later: Vec<(i64, MeterEvent)>) {
|
||||
for (bar, ev) in later {
|
||||
let tick = self.bar_start_tick(bar);
|
||||
self.put_meter(MeterEvent { tick, ..ev });
|
||||
}
|
||||
let before = self.meters.len();
|
||||
self.meters.retain(|e| e.tick != tick);
|
||||
before != self.meters.len()
|
||||
}
|
||||
|
||||
/// Tempo in effect at `tick`.
|
||||
|
|
@ -200,7 +223,7 @@ impl TempoMap {
|
|||
let mut cur_tick = 0i64;
|
||||
let first = self.meter_at_tick(0);
|
||||
if bar < 1 {
|
||||
return (bar - 1).saturating_mul(first.ticks_per_bar());
|
||||
return bar.saturating_sub(1).saturating_mul(first.ticks_per_bar());
|
||||
}
|
||||
for (i, m) in self.meters.iter().enumerate() {
|
||||
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
|
||||
|
|
@ -288,6 +311,13 @@ mod tests {
|
|||
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimum_bar_saturates_instead_of_overflowing() {
|
||||
let m = TempoMap::default();
|
||||
assert_eq!(m.bar_start_tick(i64::MIN), i64::MIN);
|
||||
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tempo_change_is_integrated() {
|
||||
let mut m = TempoMap::default();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
//! Bounce Mix renders a slice per frame behind a progress window, so the app keeps drawing and the
|
||||
//! bounce can be cancelled.
|
||||
|
||||
use crate::SoundApp;
|
||||
use crate::theme::{Tokens, bold};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Starts the bounce `file.bounce_mix` runs for `params`; [`step`] renders it.
|
||||
pub fn start(app: &mut SoundApp, params: &Value) {
|
||||
if app.bounce.is_some() {
|
||||
app.ui.status = "A bounce is already running".into();
|
||||
return;
|
||||
}
|
||||
// Third-party plugins keep state the session can't see: store it first.
|
||||
app.capture_plugin_states();
|
||||
match soundcraft_engine::cmd::bounce_job(&app.engine, params) {
|
||||
Ok(job) => app.bounce = Some(job),
|
||||
Err(e) => app.ui.status = e.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the running bounce for a moment. Called from `logic`, which eframe keeps calling while
|
||||
/// the window is hidden as long as a repaint is requested, so a bounce doesn't pause then.
|
||||
pub fn step(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let Some(job) = &mut app.bounce else { return };
|
||||
app.ui.status = match job.step(slice()) {
|
||||
Ok(None) => {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
Ok(Some(done)) => {
|
||||
format!("Bounced {} (peak {:.1} dBFS, {:.1} LUFS)", job.path().file_name().unwrap_or_default().to_string_lossy(), done.peak_db, done.lufs)
|
||||
}
|
||||
Err(e) => e.to_string(),
|
||||
};
|
||||
app.bounce = None;
|
||||
}
|
||||
|
||||
/// Refuses to replace the session while a bounce holds its own copy: two large sessions could run
|
||||
/// out of memory. True when refused; the status line says why.
|
||||
pub fn refuses_session_switch(app: &mut SoundApp) -> bool {
|
||||
if app.bounce.is_some() {
|
||||
app.ui.status = "Finish or cancel the bounce first".into();
|
||||
}
|
||||
app.bounce.is_some()
|
||||
}
|
||||
|
||||
/// Whether to render another block in this call: for 30 ms.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn slice() -> impl FnMut() -> bool {
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_millis(30);
|
||||
move || std::time::Instant::now() < until
|
||||
}
|
||||
|
||||
/// The browser has no `Instant` (it panics there), so a call renders 32 blocks.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn slice() -> impl FnMut() -> bool {
|
||||
let mut blocks = 32;
|
||||
move || {
|
||||
blocks -= 1;
|
||||
blocks > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows how far the running bounce got, with Cancel (or Esc).
|
||||
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let Some(job) = &app.bounce else { return };
|
||||
let mut cancel = ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
egui::Modal::new(egui::Id::new("bounce_progress")).show(ctx, |ui| {
|
||||
ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0);
|
||||
ui.set_width(320.0);
|
||||
let name = job.path().file_name().unwrap_or_default().to_string_lossy();
|
||||
ui.label(egui::RichText::new(format!("Bouncing {name}")).font(bold(13.0)));
|
||||
ui.add(egui::ProgressBar::new(job.progress()));
|
||||
ui.horizontal(|ui| {
|
||||
cancel |= ui.button("Cancel").clicked();
|
||||
ui.label(egui::RichText::new(format!("{:.0}%", job.progress() * 100.0)).color(Tokens::current().text_dim));
|
||||
});
|
||||
});
|
||||
if cancel {
|
||||
app.bounce = None;
|
||||
app.ui.status = "Bounce cancelled".into();
|
||||
}
|
||||
}
|
||||
|
|
@ -108,8 +108,13 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
|
|||
Ok(v) => v,
|
||||
Err(e) => return Some(err(e)),
|
||||
};
|
||||
// Same predicate as the engine's `engine.commands`: id or label substring, ASCII case-insensitive.
|
||||
let filter = p.get("filter").and_then(Value::as_str).map(str::to_ascii_lowercase);
|
||||
let matches = |id: &str, label: &str| {
|
||||
filter.as_deref().is_none_or(|f| id.to_ascii_lowercase().contains(f) || label.to_ascii_lowercase().contains(f))
|
||||
};
|
||||
if let Some(arr) = list.as_array_mut() {
|
||||
for (id, label, path, sc) in crate::menus::UI_COMMANDS {
|
||||
for (id, label, path, sc) in crate::menus::UI_COMMANDS.iter().filter(|c| matches(c.0, c.1)) {
|
||||
arr.push(json!({"id": id, "label": label, "menu": path, "shortcut": sc, "enabled": true, "params": "{}"}));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ pub enum Dialog {
|
|||
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
|
||||
RenameTrack { id: TrackId, name: String },
|
||||
RenameClip { id: ClipId, name: String },
|
||||
RenameMarker { id: soundcraft_model::MarkerId, name: String },
|
||||
ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },
|
||||
PathPrompt { cmd: String, title: String, path: String, key: String },
|
||||
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
|
||||
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
|
||||
Spot(crate::spot::Spot),
|
||||
Fades { shape: String },
|
||||
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
|
||||
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
|
||||
|
|
@ -42,10 +44,12 @@ impl Dialogs {
|
|||
Dialog::Bounce { .. } => "bounce",
|
||||
Dialog::RenameTrack { .. } => "rename_track",
|
||||
Dialog::RenameClip { .. } => "rename_clip",
|
||||
Dialog::RenameMarker { .. } => "rename_marker",
|
||||
Dialog::ClearClips { .. } => "clear_clips",
|
||||
Dialog::PathPrompt { .. } => "path",
|
||||
Dialog::Number { .. } => "number",
|
||||
Dialog::TempoChange { .. } => "tempo_change",
|
||||
Dialog::Spot(_) => "spot",
|
||||
Dialog::Fades { .. } => "fades",
|
||||
Dialog::StripSilence { .. } => "strip_silence",
|
||||
Dialog::Group { .. } => "group",
|
||||
|
|
@ -63,6 +67,10 @@ impl Dialogs {
|
|||
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
|
||||
}
|
||||
|
||||
pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) {
|
||||
self.open = Some(Dialog::RenameMarker { id, name: name.to_string() });
|
||||
}
|
||||
|
||||
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
|
||||
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
|
||||
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
|
||||
|
|
@ -85,7 +93,13 @@ impl Dialogs {
|
|||
}
|
||||
"session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false },
|
||||
"file.bounce_mix" => {
|
||||
Dialog::Bounce { path: format!("{home}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false }
|
||||
let dir = e
|
||||
.path
|
||||
.as_deref()
|
||||
.and_then(|p| std::path::Path::new(p).parent())
|
||||
.filter(|d| !d.as_os_str().is_empty())
|
||||
.map_or(home.clone(), |d| d.display().to_string());
|
||||
Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false }
|
||||
}
|
||||
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
|
||||
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
|
||||
|
|
@ -250,6 +264,7 @@ fn default_dir() -> String {
|
|||
/// Show the open dialog, if any.
|
||||
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
audiosuite_window(app, ctx);
|
||||
crate::bounce::show(app, ctx);
|
||||
let Some(mut d) = app.dialogs.open.take() else { return };
|
||||
let mut keep = true;
|
||||
let mut action: Option<(String, Value)> = None;
|
||||
|
|
@ -261,9 +276,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
Dialog::Bounce { .. } => "Bounce Mix",
|
||||
Dialog::RenameTrack { .. } => "Rename Track",
|
||||
Dialog::RenameClip { .. } => "Rename Clip",
|
||||
Dialog::RenameMarker { .. } => "Rename Marker",
|
||||
Dialog::ClearClips { .. } => "Clear",
|
||||
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
|
||||
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
|
||||
Dialog::Spot(_) => "Spot",
|
||||
Dialog::Fades { .. } => "Fades",
|
||||
Dialog::StripSilence { .. } => "Strip Silence",
|
||||
Dialog::Group { .. } => "Create Group",
|
||||
|
|
@ -339,7 +356,19 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
Dialog::Bounce { path, format, bit_depth, normalize } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("File");
|
||||
ui.add(egui::TextEdit::singleline(path).desired_width(360.0));
|
||||
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
|
||||
if let Some(pick) = &app.services.pick_save
|
||||
&& ui.button("Browse…").clicked()
|
||||
&& let Some(p) = pick("file.bounce_mix", path)
|
||||
{
|
||||
*path = p;
|
||||
match std::path::Path::new(path.as_str()).extension().map(|x| x.to_string_lossy().to_lowercase()).as_deref() {
|
||||
Some("wav") => *format = "wav".into(),
|
||||
Some("aif" | "aiff") => *format = "aiff".into(),
|
||||
Some("flac") => *format = "flac".into(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
egui::ComboBox::from_label("Format").selected_text(format.to_uppercase()).show_ui(ui, |ui| {
|
||||
|
|
@ -361,10 +390,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
);
|
||||
if buttons(ui, "Bounce", enter) {
|
||||
let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() });
|
||||
action = Some((
|
||||
"file.bounce_mix".into(),
|
||||
json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}),
|
||||
));
|
||||
crate::bounce::start(
|
||||
app,
|
||||
&json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}),
|
||||
);
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
Dialog::RenameTrack { id, name } => {
|
||||
|
|
@ -379,6 +409,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
|
||||
}
|
||||
}
|
||||
Dialog::RenameMarker { id, name } => {
|
||||
name_field(ui, name);
|
||||
if buttons(ui, "OK", enter) {
|
||||
action = Some(("markers.edit".into(), json!({"id": id.0, "rename": name})));
|
||||
}
|
||||
}
|
||||
Dialog::ClearClips { clips, sources, message } => {
|
||||
ui.set_max_width(340.0);
|
||||
ui.label(message.as_str());
|
||||
|
|
@ -445,6 +481,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
action = Some(tempo_change_action(*at, *bpm));
|
||||
}
|
||||
}
|
||||
Dialog::Spot(d) => {
|
||||
crate::spot::fields(ui, app.engine.session(), d);
|
||||
if buttons(ui, "OK", enter) {
|
||||
keep = false;
|
||||
action = d.command(app.engine.session());
|
||||
}
|
||||
}
|
||||
Dialog::Fades { shape } => {
|
||||
for s in soundcraft_model::FadeShape::ALL {
|
||||
ui.radio_value(shape, s.label().to_lowercase(), s.label());
|
||||
|
|
@ -657,7 +700,12 @@ fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
}
|
||||
}
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(key, params.clone()));
|
||||
ui.label(egui::RichText::new("Processes the selected clips and replaces them with rendered audio.").small());
|
||||
let hint = if soundcraft_dsp::is_generator(&process) {
|
||||
"Fills the selection on the selected tracks with a new clip."
|
||||
} else {
|
||||
"Processes the selected clips and replaces them with rendered audio."
|
||||
};
|
||||
ui.label(egui::RichText::new(hint).small());
|
||||
if ui.button(egui::RichText::new("Render").strong()).clicked() {
|
||||
let p: serde_json::Map<String, Value> = params.iter().map(|(k, v)| (k.clone(), json!(v))).collect();
|
||||
let _ = app.run("audiosuite.process", json!({"process": process, "params": p}));
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -47,6 +47,12 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
|
|||
painter.circle_stroke(p(r, 0.42, 0.4), r.width() * 0.2, s);
|
||||
painter.line_segment([p(r, 0.56, 0.55), p(r, 0.8, 0.8)], Stroke::new(s.width * 1.6, c));
|
||||
}
|
||||
"zoom_toggle" => {
|
||||
for (x, y, dx, dy) in [(0.18, 0.24, 0.16, 0.14), (0.82, 0.24, -0.16, 0.14), (0.18, 0.76, 0.16, -0.14), (0.82, 0.76, -0.16, -0.14)] {
|
||||
painter.add(Shape::line(vec![p(r, x + dx, y), p(r, x, y), p(r, x, y + dy)], s));
|
||||
}
|
||||
painter.rect_filled(Rect::from_min_max(p(r, 0.34, 0.41), p(r, 0.66, 0.59)), 1.0, c);
|
||||
}
|
||||
"trim" => {
|
||||
painter.line_segment([p(r, 0.5, 0.2), p(r, 0.5, 0.8)], s);
|
||||
painter.line_segment([p(r, 0.15, 0.5), p(r, 0.85, 0.5)], s);
|
||||
|
|
@ -123,9 +129,6 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
|
|||
"triangle_down" => {
|
||||
painter.add(Shape::convex_polygon(vec![p(r, 0.25, 0.35), p(r, 0.75, 0.35), p(r, 0.5, 0.68)], c, Stroke::NONE));
|
||||
}
|
||||
"triangle_right" => {
|
||||
painter.add(Shape::convex_polygon(vec![p(r, 0.35, 0.25), p(r, 0.68, 0.5), p(r, 0.35, 0.75)], c, Stroke::NONE));
|
||||
}
|
||||
"note" => {
|
||||
painter.circle_filled(p(r, 0.38, 0.72), r.width() * 0.13, c);
|
||||
painter.line_segment([p(r, 0.5, 0.72), p(r, 0.5, 0.2)], s);
|
||||
|
|
@ -140,6 +143,13 @@ pub fn draw(painter: &Painter, r: Rect, name: &str, c: Color32) {
|
|||
painter.line_segment([c0, c1], Stroke::new(s.width * 1.4, c));
|
||||
}
|
||||
}
|
||||
"lock" => {
|
||||
painter.rect_filled(Rect::from_min_max(p(r, 0.22, 0.46), p(r, 0.78, 0.88)), r.width() * 0.08, c);
|
||||
let (x, y, w) = (r.center().x, r.min.y + r.height() * 0.46, r.width() * 0.19);
|
||||
let arc: Vec<Pos2> =
|
||||
(0..=12).map(|i| std::f32::consts::PI * i as f32 / 12.0).map(|a| pos2(x - w * a.cos(), y - w * 1.4 * a.sin())).collect();
|
||||
painter.add(Shape::line(arc, s));
|
||||
}
|
||||
"wave" => {
|
||||
let n = 24;
|
||||
let pts: Vec<Pos2> = (0..=n)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
//! executing commands. It can be replaced without touching anything below it.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod bounce;
|
||||
pub mod control;
|
||||
pub mod credits;
|
||||
pub mod dialogs;
|
||||
|
|
@ -18,19 +19,23 @@ pub mod mix_window;
|
|||
pub mod ops_windows;
|
||||
pub mod palette;
|
||||
pub mod panels;
|
||||
pub mod recovery;
|
||||
pub mod score_editor;
|
||||
pub mod shortcuts;
|
||||
pub mod spot;
|
||||
pub mod theme;
|
||||
pub mod toolbar;
|
||||
pub mod video_track;
|
||||
pub mod video_window;
|
||||
pub mod waveform;
|
||||
pub mod widgets;
|
||||
pub mod windows;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_engine::{Engine, TransportRequest};
|
||||
use soundcraft_model::TrackId;
|
||||
use soundcraft_playback::Player;
|
||||
use soundcraft_playback::record::Trouble;
|
||||
use soundcraft_playback::{OutputTrouble, Player};
|
||||
use soundcraft_time::{Range, Samples};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::Receiver;
|
||||
|
|
@ -68,8 +73,13 @@ pub struct UiState {
|
|||
pub mix_views: Vec<String>,
|
||||
/// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color.
|
||||
pub edit_views: Vec<String>,
|
||||
/// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip).
|
||||
pub track_tint: f32,
|
||||
/// Open plugin windows (track, slot).
|
||||
pub plugin_windows: Vec<(TrackId, usize)>,
|
||||
/// The send in the Send window (track, slot).
|
||||
#[serde(skip)]
|
||||
pub send_window: Option<(TrackId, usize)>,
|
||||
pub audiosuite: Option<String>,
|
||||
pub status: String,
|
||||
pub show_automation: bool,
|
||||
|
|
@ -128,7 +138,9 @@ impl Default for UiState {
|
|||
narrow_mix: false,
|
||||
mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()],
|
||||
edit_views: vec!["color".into()],
|
||||
track_tint: 0.5,
|
||||
plugin_windows: Vec::new(),
|
||||
send_window: None,
|
||||
audiosuite: None,
|
||||
status: String::new(),
|
||||
show_automation: false,
|
||||
|
|
@ -227,6 +239,10 @@ pub struct SoundApp {
|
|||
/// Punch range to trim the take to (pre/post-roll recordings).
|
||||
punch: Option<Range>,
|
||||
recorder_failed: bool,
|
||||
/// Input this take lost to a slow disk, as last reported (ms).
|
||||
lost_shown: u64,
|
||||
/// Where the takes being recorded go, autosaved with the session so a crash can't lose them.
|
||||
take_plan: Option<soundcraft_engine::io::Recording>,
|
||||
pub ui: UiState,
|
||||
pub audio_health_report: Option<Result<Value, String>>,
|
||||
pub services: Services,
|
||||
|
|
@ -234,6 +250,14 @@ pub struct SoundApp {
|
|||
pub native_menu_bar: bool,
|
||||
/// Folder for crash-recovery autosaves (native apps set it).
|
||||
pub autosave_dir: Option<std::path::PathBuf>,
|
||||
/// Writes the open session's autosaves in the background, once there is something to write.
|
||||
autosaver: Option<soundcraft_engine::autosave::Autosaver>,
|
||||
/// The document revision last handed to the autosaver (0 once that autosave is stale).
|
||||
autosaved_rev: u64,
|
||||
/// Unsaved work that earlier runs left in the autosave folder, offered for recovery.
|
||||
pub leftovers: Vec<soundcraft_engine::autosave::Leftover>,
|
||||
/// The document revision last autosaved after a caught panic.
|
||||
panic_rev: u64,
|
||||
/// Folder for user plugin presets (native apps set it).
|
||||
pub preset_dir: Option<std::path::PathBuf>,
|
||||
last_autosave: Option<std::time::Instant>,
|
||||
|
|
@ -261,17 +285,26 @@ pub struct SoundApp {
|
|||
pub(crate) docking_context: Option<egui::Context>,
|
||||
/// Transport simulation when there is no player (tests, offscreen renders).
|
||||
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
|
||||
/// When Record last found nothing to record on, and why: the Record buttons show it for a moment.
|
||||
record_hint: Option<(f64, &'static str)>,
|
||||
pub quit_requested: bool,
|
||||
/// Reset floating-window positions next frame (Window › Arrange).
|
||||
pub arrange_request: bool,
|
||||
/// Tracks whose automation was touched during this pass (Touch/Latch writing).
|
||||
touched: std::collections::HashSet<TrackId>,
|
||||
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
|
||||
last_write_at: Samples,
|
||||
pub frame_ms: f32,
|
||||
last_frame: Option<f64>,
|
||||
last_meter_poll: f64,
|
||||
/// Movies for the Video window and Video track (decoded in the background).
|
||||
pub video: video_track::VideoPool,
|
||||
/// The Bounce Mix in progress, rendered a slice per frame.
|
||||
pub bounce: Option<soundcraft_engine::io::Bounce>,
|
||||
/// Test-only: owns the process-wide palette lock while this app exists, so frames painted
|
||||
/// and colour assertions in one test can never straddle another test's palette change.
|
||||
#[cfg(test)]
|
||||
_palette_lock: theme::TestPaletteGuard,
|
||||
}
|
||||
|
||||
/// Whether another automation-write pass is due: the transport moved at least
|
||||
|
|
@ -293,11 +326,17 @@ impl SoundApp {
|
|||
record_start: 0,
|
||||
punch: None,
|
||||
recorder_failed: false,
|
||||
lost_shown: 0,
|
||||
take_plan: None,
|
||||
ui: UiState::default(),
|
||||
audio_health_report: None,
|
||||
services,
|
||||
native_menu_bar: false,
|
||||
autosave_dir: None,
|
||||
autosaver: None,
|
||||
autosaved_rev: 0,
|
||||
leftovers: Vec::new(),
|
||||
panic_rev: 0,
|
||||
preset_dir: None,
|
||||
last_autosave: None,
|
||||
dialogs: dialogs::Dialogs::default(),
|
||||
|
|
@ -320,6 +359,7 @@ impl SoundApp {
|
|||
theme_applied: None,
|
||||
docking_context: None,
|
||||
sim: None,
|
||||
record_hint: None,
|
||||
quit_requested: false,
|
||||
arrange_request: false,
|
||||
touched: std::collections::HashSet::new(),
|
||||
|
|
@ -328,6 +368,9 @@ impl SoundApp {
|
|||
last_frame: None,
|
||||
last_meter_poll: f64::NEG_INFINITY,
|
||||
video: video_track::VideoPool::default(),
|
||||
bounce: None,
|
||||
#[cfg(test)]
|
||||
_palette_lock: theme::lock_test_palette(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -338,6 +381,9 @@ impl SoundApp {
|
|||
|
||||
/// Run a command; errors go to the status line. Returns the result.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
if matches!(id, "session.new" | "session.open" | "session.close" | "session.revert") && bounce::refuses_session_switch(self) {
|
||||
return Err(self.ui.status.clone());
|
||||
}
|
||||
if let Some(r) = menus::run_ui_command(self, id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
|
|
@ -345,8 +391,12 @@ impl SoundApp {
|
|||
// Third-party plugins keep state the session can't see: store it first.
|
||||
self.capture_plugin_states();
|
||||
}
|
||||
let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
|
||||
match self.engine.execute(id, ¶ms) {
|
||||
Ok(v) => {
|
||||
if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) {
|
||||
self.release_autosave(false);
|
||||
}
|
||||
if id == "app.quit" {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
|
|
@ -371,6 +421,13 @@ impl SoundApp {
|
|||
self.engine.transport.playing
|
||||
}
|
||||
|
||||
/// Why the last Record press did nothing, for a few seconds and while it still holds.
|
||||
pub(crate) fn record_hint(&self) -> Option<&'static str> {
|
||||
let (at, why) = self.record_hint?;
|
||||
let fresh = self.last_frame.unwrap_or_default() - at < 4.0;
|
||||
(fresh && !self.engine.session().tracks.iter().any(|t| t.mixer.record_arm)).then_some(why)
|
||||
}
|
||||
|
||||
/// Current transport position (playhead while playing, else the edit insertion).
|
||||
pub fn position(&self) -> Samples {
|
||||
if self.engine.transport.playing { self.engine.transport.position } else { self.engine.session().edit.selection.start }
|
||||
|
|
@ -385,6 +442,7 @@ impl SoundApp {
|
|||
}
|
||||
self.engine.transport.playing = true;
|
||||
self.engine.transport.position = from;
|
||||
self.last_write_at = i64::MIN;
|
||||
// A fresh start resumes the playhead follow: clear a manual hold and
|
||||
// re-sync the follow tracker so the current view is not mistaken for
|
||||
// an outside move on the first frame.
|
||||
|
|
@ -437,14 +495,24 @@ impl SoundApp {
|
|||
fn start_recording(&mut self) {
|
||||
let armed = self.engine.session().tracks.iter().any(|t| t.mixer.record_arm);
|
||||
if !armed {
|
||||
self.ui.status = "Record-enable a track first (the red button in its header).".into();
|
||||
let why = if self.engine.session().tracks.is_empty() {
|
||||
"Nothing to record on yet: add a track with Track › New…, then click its R button."
|
||||
} else {
|
||||
"No track is record-enabled: click the R button on the track to record."
|
||||
};
|
||||
self.ui.status = why.into();
|
||||
self.record_hint = Some((self.last_frame.unwrap_or_default(), why));
|
||||
return;
|
||||
}
|
||||
self.ensure_input();
|
||||
if let Err(err) = self.arm_recorder() {
|
||||
self.ui.status = format!("Cannot record: {err}");
|
||||
return;
|
||||
}
|
||||
let takes = match self.arm_recorder() {
|
||||
Ok(takes) => takes,
|
||||
Err(err) => {
|
||||
self.ui.status = format!("Cannot record: {err}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.lost_shown = 0;
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(true);
|
||||
}
|
||||
|
|
@ -454,6 +522,7 @@ impl SoundApp {
|
|||
// Punch in on the fly at the current playhead.
|
||||
self.record_start = self.position();
|
||||
self.punch = None;
|
||||
self.plan_recording(takes, None);
|
||||
return;
|
||||
}
|
||||
let e = self.engine.session().edit.clone();
|
||||
|
|
@ -462,6 +531,7 @@ impl SoundApp {
|
|||
self.record_start = sel.start;
|
||||
self.punch = None;
|
||||
self.start_play(sel.start, None, Some(sel));
|
||||
self.plan_recording(takes, Some(sel.len()));
|
||||
} else {
|
||||
// Record into the selection when there is one (punch), with pre/post-roll if enabled;
|
||||
// the take is trimmed back to the punch range afterwards.
|
||||
|
|
@ -471,12 +541,23 @@ impl SoundApp {
|
|||
self.punch = (!sel.is_empty()).then_some(sel);
|
||||
let end = (!sel.is_empty()).then_some(sel.end + post);
|
||||
self.start_play(from, end, None);
|
||||
self.plan_recording(takes, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember where the armed `takes` go (split into passes of `pass` when loop recording) and
|
||||
/// autosave that plan with the session.
|
||||
fn plan_recording(&mut self, takes: Vec<soundcraft_engine::io::Take>, pass: Option<Samples>) {
|
||||
let rate = self.recorder.as_ref().map_or(0, |r| r.sample_rate);
|
||||
self.take_plan = Some(soundcraft_engine::io::Recording { start: self.record_start, pass, punch: self.punch, rate, takes });
|
||||
if let Some(root) = self.autosave_dir.clone() {
|
||||
self.queue_autosave(root);
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm the input for the record-enabled tracks; a session never saved records into its
|
||||
/// autosave folder.
|
||||
fn arm_recorder(&mut self) -> Result<(), String> {
|
||||
/// autosave folder. Returns the takes it records.
|
||||
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
|
||||
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
|
||||
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
|
||||
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
|
||||
|
|
@ -484,7 +565,12 @@ impl SoundApp {
|
|||
if plan.is_empty() {
|
||||
return Err("no audio track is record-enabled".into());
|
||||
}
|
||||
r.arm(plan)
|
||||
let takes = plan
|
||||
.iter()
|
||||
.map(|(track, inputs, path)| soundcraft_engine::io::Take { track: *track, path: path.clone(), channels: inputs.len() })
|
||||
.collect();
|
||||
r.arm(plan)?;
|
||||
Ok(takes)
|
||||
}
|
||||
|
||||
/// Reads every live third-party plugin's state and stores it on its insert
|
||||
|
|
@ -522,14 +608,108 @@ impl SoundApp {
|
|||
}
|
||||
self.last_autosave = Some(std::time::Instant::now());
|
||||
self.capture_plugin_states();
|
||||
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
|
||||
let path = dir.join(&name).join(format!("{name}.scraft"));
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
self.autosave_to(&dir);
|
||||
}
|
||||
|
||||
/// Autosave a modified session within a second of every change, also while recording. The
|
||||
/// plugins' own state reaches the session every two minutes (`autosave`).
|
||||
fn autosave_changes(&mut self) {
|
||||
if let Some(e) = self.autosaver.as_ref().and_then(soundcraft_engine::autosave::Autosaver::failure) {
|
||||
self.ui.status = format!("Autosave failed: {e}");
|
||||
}
|
||||
match soundcraft_engine::io::save_copy(&self.engine, &path.to_string_lossy()) {
|
||||
Ok(_) => log::info!("autosaved to {}", path.display()),
|
||||
Err(e) => log::warn!("autosave failed: {e}"),
|
||||
if self.autosave_dir.is_none() || self.engine.revision == self.autosaved_rev {
|
||||
return;
|
||||
}
|
||||
self.autosaved_rev = self.engine.revision;
|
||||
if !self.engine.is_dirty() {
|
||||
self.release_autosave(false);
|
||||
} else if let Some(root) = self.autosave_dir.clone() {
|
||||
self.queue_autosave(root);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand the session to the autosaver for its folder under `root`.
|
||||
fn queue_autosave(&mut self, root: std::path::PathBuf) {
|
||||
self.autosaved_rev = self.engine.revision;
|
||||
let w = self.autosaver.get_or_insert_with(|| soundcraft_engine::autosave::Autosaver::new(root));
|
||||
w.write(self.engine.session_arc(), self.engine.path.clone(), self.take_plan.clone());
|
||||
}
|
||||
|
||||
/// Write the session to its own folder under `root` and wait for it; false when that failed.
|
||||
fn autosave_to(&mut self, root: &std::path::Path) -> bool {
|
||||
self.queue_autosave(root.to_path_buf());
|
||||
let Some(w) = &self.autosaver else { return false };
|
||||
let written = w.flush();
|
||||
if let Err(e) = &written {
|
||||
self.ui.status = format!("Autosave failed: {e}");
|
||||
}
|
||||
written.is_ok()
|
||||
}
|
||||
|
||||
/// Autosave now, if the app has an autosave folder.
|
||||
fn write_autosave(&mut self) -> bool {
|
||||
self.autosave_dir.clone().is_some_and(|root| self.autosave_to(&root))
|
||||
}
|
||||
|
||||
/// Done with this session's autosave folder: keep it for the next launch, or delete it.
|
||||
fn release_autosave(&mut self, keep: bool) {
|
||||
if let Some(w) = &self.autosaver {
|
||||
w.close(keep);
|
||||
}
|
||||
}
|
||||
|
||||
/// Done with the open session. Unsaved changes stay in its autosave folder for the next
|
||||
/// launch, unless the user quit with SoundCraft's Quit; a take still recording is finished
|
||||
/// and always stays.
|
||||
pub fn close_autosave(&mut self) {
|
||||
let recording = self.engine.transport.recording;
|
||||
if recording {
|
||||
self.stop_play();
|
||||
}
|
||||
let keep = self.engine.is_dirty() && (recording || !self.quit_requested);
|
||||
if keep {
|
||||
self.capture_plugin_states();
|
||||
self.write_autosave();
|
||||
}
|
||||
self.release_autosave(keep);
|
||||
if let Some(w) = &self.autosaver {
|
||||
let _ = w.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// Run part of a frame. If it panics, the session is autosaved (once per change) and the app
|
||||
/// carries on instead of quitting.
|
||||
pub fn guarded(&mut self, f: impl FnOnce(&mut SoundApp)) {
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))).is_ok() || self.panic_rev == self.engine.revision {
|
||||
return;
|
||||
}
|
||||
self.panic_rev = self.engine.revision;
|
||||
let saved = self.engine.is_dirty() && self.write_autosave();
|
||||
self.ui.status = if saved {
|
||||
"SoundCraft hit an internal error (see the log). Your changes were autosaved: save them, then restart SoundCraft.".into()
|
||||
} else {
|
||||
"SoundCraft hit an internal error (see the log). Restart SoundCraft to be safe.".into()
|
||||
};
|
||||
}
|
||||
|
||||
/// Replace the open session with unsaved work an earlier run left behind.
|
||||
pub fn recover(&mut self, left: &soundcraft_engine::autosave::Leftover) {
|
||||
if self.engine.transport.recording {
|
||||
self.ui.status = "Stop recording before recovering other work.".into();
|
||||
return;
|
||||
}
|
||||
self.close_autosave();
|
||||
match soundcraft_engine::autosave::recover(&mut self.engine, left) {
|
||||
Ok(missing) => {
|
||||
if self.write_autosave() {
|
||||
soundcraft_engine::autosave::discard(left);
|
||||
}
|
||||
self.ui.status = match missing.len() {
|
||||
0 => format!("Recovered “{}”. Save it to keep the changes.", left.name),
|
||||
n => format!("Recovered “{}”, but {n} audio file(s) are missing. Save it to keep the changes.", left.name),
|
||||
};
|
||||
}
|
||||
Err(e) => self.ui.status = format!("Couldn't recover “{}”: {e}", left.name),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -576,28 +756,96 @@ impl SoundApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Stop the transport, keeping a take in progress, when the audio output fails.
|
||||
fn watch_output(&mut self) {
|
||||
let Some(p) = &self.player else { return };
|
||||
let Some(trouble) = p.trouble() else { return };
|
||||
let name = p.device_name.clone();
|
||||
let (cause, then) = match trouble {
|
||||
OutputTrouble::Bug => (
|
||||
"the audio engine hit an internal error (see the log)".to_string(),
|
||||
"Use Reconnect audio device in Setup ▸ Playback Engine, or restart SoundCraft.",
|
||||
),
|
||||
OutputTrouble::Gone => {
|
||||
(format!("“{name}” was disconnected"), "Plug it back in, then use Reconnect audio device in Setup ▸ Playback Engine.")
|
||||
}
|
||||
OutputTrouble::Stalled => (format!("“{name}” stopped asking for audio"), "Use Reconnect audio device in Setup ▸ Playback Engine."),
|
||||
};
|
||||
let (recording, playing) = (self.engine.transport.recording, self.engine.transport.playing);
|
||||
if recording || playing {
|
||||
self.stop_play();
|
||||
}
|
||||
self.ui.status = if recording {
|
||||
format!("Recording stopped: {cause}. The take is kept up to there. {then}")
|
||||
} else if playing {
|
||||
format!("Playback stopped: {cause}. {then}")
|
||||
} else {
|
||||
format!("Audio output: {cause}. {then}")
|
||||
};
|
||||
}
|
||||
|
||||
/// Stop recording, keeping the take, when the input device or the disk fails, and say as soon
|
||||
/// as a slow disk loses input.
|
||||
fn watch_input(&mut self) {
|
||||
let recording = self.engine.transport.recording;
|
||||
let Some(r) = &mut self.recorder else { return };
|
||||
let Some(trouble) = r.trouble() else {
|
||||
let lost = r.lost_ms();
|
||||
if recording && lost > self.lost_shown {
|
||||
self.lost_shown = lost;
|
||||
self.ui.status = format!("The disk is too slow: this take has lost {lost} ms of input so far.");
|
||||
}
|
||||
return;
|
||||
};
|
||||
if recording {
|
||||
self.stop_play();
|
||||
}
|
||||
let (what, then) = match trouble {
|
||||
Trouble::Device(what) => {
|
||||
self.recorder = None;
|
||||
self.recorder_failed = true;
|
||||
(what, "Reconnect it, then use Reconnect audio device in Setup ▸ Playback Engine.")
|
||||
}
|
||||
Trouble::Disk(what) => (what, "Check the disk, then record again."),
|
||||
};
|
||||
self.ui.status = if recording {
|
||||
format!("Recording stopped: {what}. The take is kept up to there. {then}")
|
||||
} else {
|
||||
format!("Audio input: {what}. {then}")
|
||||
};
|
||||
}
|
||||
|
||||
/// Reopen the audio devices, keeping a take in progress.
|
||||
pub fn reconnect_audio(&mut self) {
|
||||
if self.is_playing() || self.engine.transport.recording {
|
||||
self.stop_play();
|
||||
}
|
||||
self.recorder = None;
|
||||
self.recorder_failed = false;
|
||||
self.player = Some(Player::new(self.engine.session_arc()));
|
||||
}
|
||||
|
||||
fn finish_recording(&mut self) {
|
||||
self.engine.transport.recording = false;
|
||||
if let Some(p) = &self.player {
|
||||
p.set_recording(false);
|
||||
}
|
||||
let plan = self.take_plan.take();
|
||||
let Some(r) = &mut self.recorder else { return };
|
||||
let (takes, problem) = r.finish();
|
||||
let tracks = takes.len().max(1);
|
||||
let sel = self.engine.session().edit.selection;
|
||||
let looped = self.engine.session().edit.loop_record && !sel.is_empty();
|
||||
let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) };
|
||||
let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass);
|
||||
if let Some(p) = self.punch.take() {
|
||||
// Keep only the punch range of each new take (merged into the Record undo step).
|
||||
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
|
||||
let _ = self.engine.execute_merged(
|
||||
"edit.trim_to_fill_selection",
|
||||
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
|
||||
"record",
|
||||
);
|
||||
}
|
||||
self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) };
|
||||
let plan = plan.unwrap_or_else(|| soundcraft_engine::io::Recording {
|
||||
start: self.record_start,
|
||||
pass: None,
|
||||
punch: self.punch,
|
||||
rate: 0,
|
||||
takes: Vec::new(),
|
||||
});
|
||||
self.punch = None;
|
||||
self.autosaved_rev = 0;
|
||||
let ids = soundcraft_engine::io::place_recording(&mut self.engine, &plan, takes);
|
||||
self.ui.status =
|
||||
if plan.pass.is_some() { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) };
|
||||
if let Some(p) = problem {
|
||||
log::warn!("recording: {p}");
|
||||
self.ui.status = format!("{} ({p})", self.ui.status);
|
||||
|
|
@ -605,23 +853,26 @@ impl SoundApp {
|
|||
}
|
||||
|
||||
/// While playing, record volume automation from fader positions for tracks in Write mode, and
|
||||
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. One undo step per pass.
|
||||
/// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the
|
||||
/// automation since the previous one, and Stop writes a last point. A pass and whatever is
|
||||
/// dragged during it, its fader included, are one undo step.
|
||||
fn write_automation(&mut self, ctx: &egui::Context) {
|
||||
use soundcraft_model::AutomationMode as M;
|
||||
if !self.is_playing() {
|
||||
if !self.touched.is_empty() {
|
||||
self.touched.clear();
|
||||
self.engine.end_merge();
|
||||
}
|
||||
const PASS: &str = "automation_pass";
|
||||
let playing = self.is_playing();
|
||||
let may_write =
|
||||
playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch));
|
||||
self.engine.hold_merge(may_write.then_some(PASS));
|
||||
if !playing && self.touched.is_empty() {
|
||||
self.last_write_at = i64::MIN;
|
||||
return;
|
||||
}
|
||||
let pos = self.position();
|
||||
let pos = self.engine.transport.position;
|
||||
let step = self.engine.session().sample_rate.samples(0.02);
|
||||
if !automation_step_due(pos, self.last_write_at, step) {
|
||||
if playing && !automation_step_due(pos, self.last_write_at, step) {
|
||||
return;
|
||||
}
|
||||
self.last_write_at = pos;
|
||||
let from = std::mem::replace(&mut self.last_write_at, if playing { pos } else { i64::MIN });
|
||||
let dragging = ctx.input(|i| i.pointer.any_down());
|
||||
let fader_track = match &self.gesture {
|
||||
Some(Gesture::Fader { track, .. }) => Some(*track),
|
||||
|
|
@ -646,11 +897,15 @@ impl SoundApp {
|
|||
.collect();
|
||||
for (t, v) in writes {
|
||||
self.touched.insert(t);
|
||||
let _ = self.engine.execute_merged(
|
||||
"automation.set_point",
|
||||
&serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}),
|
||||
"automation_pass",
|
||||
);
|
||||
let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v});
|
||||
if from != i64::MIN {
|
||||
point["from"] = from.into();
|
||||
}
|
||||
let _ = self.engine.execute_merged("automation.set_point", &point, PASS);
|
||||
}
|
||||
if !playing {
|
||||
self.touched.clear();
|
||||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -720,6 +975,7 @@ impl SoundApp {
|
|||
if let Some(sim) = &mut self.sim {
|
||||
sim.0 = at;
|
||||
}
|
||||
self.last_write_at = i64::MIN;
|
||||
}
|
||||
self.engine.transport.position = at;
|
||||
}
|
||||
|
|
@ -816,6 +1072,7 @@ impl SoundApp {
|
|||
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
|
||||
control::drain(self, ctx);
|
||||
self.apply_editor_edits();
|
||||
bounce::step(self, ctx);
|
||||
if !ctx.input(|i| i.pointer.any_down()) {
|
||||
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
|
||||
self.gesture = None;
|
||||
|
|
@ -825,6 +1082,8 @@ impl SoundApp {
|
|||
self.engine.end_merge();
|
||||
}
|
||||
}
|
||||
self.watch_output();
|
||||
self.watch_input();
|
||||
// Stop the old playback before sending a replacement session to the audio thread.
|
||||
self.handle_transport(dt);
|
||||
if self.engine.revision != self.last_rev {
|
||||
|
|
@ -833,6 +1092,7 @@ impl SoundApp {
|
|||
p.update_session(self.engine.session_arc());
|
||||
}
|
||||
}
|
||||
self.autosave_changes();
|
||||
self.write_automation(ctx);
|
||||
self.autosave();
|
||||
if self.recorder.is_none()
|
||||
|
|
@ -907,6 +1167,7 @@ impl SoundApp {
|
|||
video_window::show(self, &ctx);
|
||||
ops_windows::show(self, &ctx);
|
||||
extra_windows::show(self, &ctx);
|
||||
recovery::show(self, &ctx);
|
||||
score_editor::show(self, &ctx);
|
||||
palette::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
|
|
@ -1027,6 +1288,29 @@ mod tests {
|
|||
assert_eq!(app.engine.transport.position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_with_nothing_armed_says_what_to_do() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
app.run("transport.record", json!({})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(!app.is_playing() && !app.engine.transport.recording);
|
||||
assert!(app.record_hint().is_some_and(|why| why.contains("Track › New")), "{:?}", app.record_hint());
|
||||
|
||||
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
|
||||
app.run("transport.record", json!({})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
assert!(app.record_hint().is_some_and(|why| why.contains("R button")), "{:?}", app.record_hint());
|
||||
assert_eq!(Some(app.ui.status.as_str()), app.record_hint());
|
||||
app.last_frame = Some(5.0);
|
||||
assert_eq!(app.record_hint(), None, "the hint shows for a few seconds");
|
||||
|
||||
app.last_frame = None;
|
||||
app.run("transport.record", json!({})).unwrap();
|
||||
app.handle_transport(0.25);
|
||||
app.run("mix.record_arm", json!({})).unwrap();
|
||||
assert_eq!(app.record_hint(), None, "arming a track answers the hint");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_hidden_window_still_autosaves() {
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id()));
|
||||
|
|
@ -1089,6 +1373,67 @@ mod tests {
|
|||
assert!(automation_step_due(0, 1_000_000, 960));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_write_pass_replaces_the_automation_it_plays_over() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let fader = app.engine.session().track_by_name("Kick").unwrap().mixer.volume_db;
|
||||
let q = i64::from(app.engine.session().sample_rate.hz()) / 4;
|
||||
// The old ride: a point every 10 ms for 3 s.
|
||||
for at in (0..12 * q).step_by(usize::try_from(q / 25).unwrap()) {
|
||||
app.run("automation.set_point", json!({"track": "Kick", "at": at, "value": -30.0})).unwrap();
|
||||
}
|
||||
app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap();
|
||||
app.run("transport.locate", json!({"at": 0})).unwrap();
|
||||
app.run("transport.play", json!({})).unwrap();
|
||||
// Passes a quarter second apart, sparse like a covered window's.
|
||||
let ctx = egui::Context::default();
|
||||
let pass = |app: &mut SoundApp| {
|
||||
app.handle_transport(0.25);
|
||||
app.write_automation(&ctx);
|
||||
};
|
||||
pass(&mut app);
|
||||
pass(&mut app);
|
||||
// Two jumps: the stretches they skip keep the old ride.
|
||||
app.run("transport.locate", json!({"at": 4 * q})).unwrap();
|
||||
pass(&mut app);
|
||||
app.run("edit.select", json!({"tracks": ["Kick"], "start": 7 * q, "end": 11 * q})).unwrap();
|
||||
app.run("transport.play_selection", json!({})).unwrap();
|
||||
pass(&mut app);
|
||||
app.handle_transport(0.25);
|
||||
app.run("transport.stop", json!({})).unwrap();
|
||||
app.handle_transport(0.0);
|
||||
app.write_automation(&ctx);
|
||||
let lane = app.engine.session().track_by_name("Kick").unwrap().lane(&soundcraft_model::AutoParam::Volume).unwrap();
|
||||
let values = |from, to| lane.points.iter().filter(|p| (from..=to).contains(&p.at)).map(|p| p.value).collect::<Vec<_>>();
|
||||
assert_eq!(values(q, 2 * q), [fader; 2]);
|
||||
assert_eq!(lane.value_at(3 * q, 0.0), -30.0);
|
||||
assert_eq!(lane.value_at(6 * q, 0.0), -30.0);
|
||||
// Stop writes the last point, at 9q.
|
||||
assert_eq!(values(8 * q, 9 * q), [fader; 2]);
|
||||
assert_eq!(lane.value_at(10 * q, 0.0), -30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() {
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
|
||||
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
|
||||
app.run("mix.automation_mode", json!({"tracks": ["Kick"], "mode": "write"})).unwrap();
|
||||
app.run("transport.play", json!({})).unwrap();
|
||||
let steps = app.engine.undo_history().len();
|
||||
let ctx = egui::Context::default();
|
||||
for frame in 0..60 {
|
||||
app.handle_transport(1.0 / 60.0);
|
||||
app.write_automation(&ctx);
|
||||
// What the Mix window's fader sends while it is dragged.
|
||||
let db = -10.0 + frame as f32 / 10.0;
|
||||
app.engine.execute_merged("mix.volume", &json!({"track": kick.0, "db": db}), &format!("fader:{}", kick.0)).unwrap();
|
||||
}
|
||||
app.run("transport.stop", json!({})).unwrap();
|
||||
app.handle_transport(0.0);
|
||||
app.write_automation(&ctx);
|
||||
assert_eq!(app.engine.undo_history().len(), steps + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
|
||||
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
|
||||
|
|
|
|||
|
|
@ -26,23 +26,23 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
|||
("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")),
|
||||
("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")),
|
||||
("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None),
|
||||
("window.universe", "Universe", "View > Other Displays > Universe", None),
|
||||
("window.universe", "Universe", "View > Other Displays > Universe", Some("Alt+7")),
|
||||
("window.video", "Video", "Window > Video", Some("Cmd+9")),
|
||||
("window.video_universe", "Video Universe", "Window > Video Universe", None),
|
||||
("window.automation", "Automation", "Window > Automation", Some("Cmd+4")),
|
||||
("window.color_palette", "Color Palette", "Window > Color Palette", None),
|
||||
("window.disk_usage", "Disk Usage", "Window > Disk Usage", None),
|
||||
("window.system_usage", "System Usage", "Window > System Usage", None),
|
||||
("window.task_manager", "Task Manager", "Window > Task Manager", None),
|
||||
("window.task_manager", "Task Manager", "Window > Task Manager", Some("Alt+'")),
|
||||
("window.metadata", "Metadata Inspector", "Window > Metadata Inspector", None),
|
||||
("window.event_list", "MIDI Event List", "Window > MIDI Event List", None),
|
||||
("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", None),
|
||||
("window.workspace", "Workspace", "Window > New Workspace > Default", None),
|
||||
("window.event_list", "MIDI Event List", "Window > MIDI Event List", Some("Alt+=")),
|
||||
("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", Some("Shift+K")),
|
||||
("window.workspace", "Workspace", "Window > New Workspace > Default", Some("Alt+I")),
|
||||
("window.workspace_presets", "Workspace", "Window > New Workspace > Track Presets", None),
|
||||
("window.workspace_sounds", "Workspace", "Window > New Workspace > Soundbase", None),
|
||||
("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", None),
|
||||
("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", Some("Alt+J")),
|
||||
("window.workspace_close", "Close All Workspaces", "Window > Workspaces > Close All Workspaces", None),
|
||||
("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", None),
|
||||
("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", Some("Cmd+Alt+J")),
|
||||
("window.config_new", "New Configuration", "Window > Configurations > New Configuration...", None),
|
||||
("window.config_update", "Update Active Configuration", "Window > Configurations > Update Active Configuration", None),
|
||||
("window.config_auto_update", "Auto-Update Active Configuration", "Window > Configurations > Auto-Update Active Configuration", None),
|
||||
|
|
@ -55,7 +55,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
|
|||
("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None),
|
||||
("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None),
|
||||
("window.clip_effects", "Clip Effects", "Window > Clip Effects", None),
|
||||
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", None),
|
||||
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")),
|
||||
("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")),
|
||||
("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")),
|
||||
("window.beat_detective", "Beat Detective", "", None),
|
||||
|
|
@ -200,6 +200,12 @@ pub fn tree() -> Vec<MenuNode> {
|
|||
insert_path(&mut roots, line);
|
||||
}
|
||||
insert_path(&mut roots, "Window > Reset Panel Layout");
|
||||
// Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282).
|
||||
// It creates a clip rather than acting on the selected ones, so it leads the Clip menu.
|
||||
insert_path(&mut roots, "Clip > New MIDI Clip");
|
||||
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
|
||||
clip.children.rotate_right(1);
|
||||
}
|
||||
for (path, _) in MENU_WINDOWS {
|
||||
insert_path(&mut roots, path);
|
||||
}
|
||||
|
|
@ -289,7 +295,7 @@ pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool)
|
|||
{
|
||||
true
|
||||
}
|
||||
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok()),
|
||||
Some(i) => soundcraft_engine::find_command(i).is_some_and(|c| (c.enabled)(&app.engine).is_ok() && !app.engine.blocked_while_recording(i)),
|
||||
None => false,
|
||||
};
|
||||
(enabled, checked_state(app, path, id))
|
||||
|
|
@ -490,7 +496,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
|||
toggle(&mut app.ui.show_audio_health)
|
||||
}
|
||||
"window.about" => toggle(&mut app.ui.show_about),
|
||||
"window.midi_editor" | "window.midi_front" => toggle(&mut app.ui.show_midi_editor),
|
||||
"window.midi_editor" => toggle(&mut app.ui.show_midi_editor),
|
||||
"window.midi_front" => {
|
||||
app.ui.show_midi_editor = true;
|
||||
json!({"value": true})
|
||||
}
|
||||
"window.midi_back" => {
|
||||
app.ui.show_midi_editor = false;
|
||||
json!({})
|
||||
|
|
@ -557,6 +567,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
|
|||
app.ui.show_undo_history = false;
|
||||
app.ui.show_session_info = false;
|
||||
app.ui.show_audio_health = false;
|
||||
app.ui.show_automation = false;
|
||||
app.ui.plugin_windows.clear();
|
||||
app.ui.audiosuite = None;
|
||||
json!({})
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ pub fn target_clip(app: &SoundApp) -> Option<ClipId> {
|
|||
let s = app.engine.session();
|
||||
for id in &s.edit.selected_clips {
|
||||
if let Some((_, c)) = s.find_clip(*id)
|
||||
&& !c.is_audio()
|
||||
&& matches!(c.content, ClipContent::Midi { .. })
|
||||
{
|
||||
return Some(*id);
|
||||
}
|
||||
|
|
@ -84,6 +84,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
regular(11.0),
|
||||
t.text_dim,
|
||||
);
|
||||
let grid_ticks = match app.engine.session().edit.grid {
|
||||
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
|
||||
let mut g = value.ticks();
|
||||
if dotted {
|
||||
g = g * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
g = g * 2 / 3;
|
||||
}
|
||||
g.max(1)
|
||||
}
|
||||
_ => TICKS_PER_QUARTER / 4,
|
||||
};
|
||||
// Header toolbar: whole-clip MIDI operations.
|
||||
let bar = Rect::from_min_max(pos2(header.max.x - 330.0, header.min.y + 1.0), pos2(header.max.x - 6.0, header.max.y - 1.0));
|
||||
let mut tb = ui.new_child(egui::UiBuilder::new().max_rect(bar).layout(egui::Layout::right_to_left(egui::Align::Center)));
|
||||
|
|
@ -99,29 +112,16 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
op = Some(("event.change_velocity", json!({"add": 10})));
|
||||
}
|
||||
if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() {
|
||||
op = Some(("event.quantize", json!({"grid": "1/16", "strength": 100})));
|
||||
op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100})));
|
||||
}
|
||||
if let Some((id, mut p)) = op {
|
||||
p["clips"] = json!([cid.0]);
|
||||
let _ = app.run(id, p);
|
||||
}
|
||||
let s = app.engine.session().clone();
|
||||
let s = app.engine.session_arc();
|
||||
let sr = s.sample_rate;
|
||||
let base_tick = s.tempo.samples_to_ticks(clip.start, sr);
|
||||
let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1);
|
||||
let grid_ticks = match s.edit.grid {
|
||||
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
|
||||
let mut g = value.ticks();
|
||||
if dotted {
|
||||
g = g * 3 / 2;
|
||||
}
|
||||
if triplet {
|
||||
g = g * 2 / 3;
|
||||
}
|
||||
g.max(1)
|
||||
}
|
||||
_ => TICKS_PER_QUARTER / 4,
|
||||
};
|
||||
let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0));
|
||||
let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y));
|
||||
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
|
||||
|
|
@ -300,12 +300,13 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
// Keyboard: Cmd+A select all; arrows transpose (Shift = octave) and move by the grid.
|
||||
if ui.rect_contains_pointer(roll) && !ui.ctx().egui_wants_keyboard_input() {
|
||||
let (all, up, down, left, right, shift) = ui.input(|i| {
|
||||
let arrows = !i.modifiers.alt && !i.modifiers.command && !i.modifiers.ctrl;
|
||||
(
|
||||
i.modifiers.command && i.key_pressed(egui::Key::A),
|
||||
i.key_pressed(egui::Key::ArrowUp),
|
||||
i.key_pressed(egui::Key::ArrowDown),
|
||||
i.key_pressed(egui::Key::ArrowLeft),
|
||||
i.key_pressed(egui::Key::ArrowRight),
|
||||
arrows && i.key_pressed(egui::Key::ArrowUp),
|
||||
arrows && i.key_pressed(egui::Key::ArrowDown),
|
||||
arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowLeft),
|
||||
arrows && !i.modifiers.shift && i.key_pressed(egui::Key::ArrowRight),
|
||||
i.modifiers.shift,
|
||||
)
|
||||
});
|
||||
|
|
@ -330,7 +331,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
|
|||
let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
|
||||
}
|
||||
}
|
||||
if !app.midi.selected.is_empty() && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
|
||||
if !app.midi.selected.is_empty()
|
||||
&& !ui.ctx().egui_wants_keyboard_input()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
|
||||
{
|
||||
let _ = app.run("midi.note_delete", json!({"clip": cid.0, "indices": app.midi.selected}));
|
||||
app.midi.selected.clear();
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue