Merge remote-tracking branch 'origin/main' into zoom-toggle

# Conflicts:
#	crates/model/src/lib.rs
This commit is contained in:
grovebit 2026-10-13 10:51:29 +01:00
commit 338411b99a
142 changed files with 12551 additions and 1675 deletions

72
Cargo.lock generated
View File

@ -2142,6 +2142,18 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@ -3412,7 +3424,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
@ -3430,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"serde",
@ -3440,7 +3452,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3452,7 +3464,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"serde_json",
@ -3462,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"clap-sys",
"libloading",
@ -3474,7 +3486,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@ -3485,7 +3497,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3496,7 +3508,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@ -3513,11 +3525,12 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3527,7 +3540,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"rayon",
@ -3543,7 +3556,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3556,7 +3569,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"cpal",
"log",
@ -3569,7 +3582,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@ -3579,7 +3592,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
@ -3605,7 +3618,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"image",
"proptest",
@ -3616,7 +3629,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"libloading",
"log",
@ -3631,12 +3644,16 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"soundcraft-engine",
"soundcraft-model",
"soundcraft-playback",
"soundcraft-ui-egui",
"wasm-bindgen",
@ -4096,6 +4113,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "uds_windows"
version = "1.2.1"
@ -5178,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde_json",
]
@ -5386,6 +5409,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [
"crc32fast",
"indexmap",
"memchr",
"nix",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"

View File

@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@ -38,6 +38,8 @@ proptest = "1"
# audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
cpal = "0.16"
# ui

View File

@ -107,7 +107,8 @@ where things are.
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
effects, and automatic plugin delay compensation.
@ -116,7 +117,8 @@ where things are.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
@ -202,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).
@ -250,7 +255,7 @@ very welcome, in the issues or on Discord.
## Downloads
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
**Download SoundCraft** from GitHub: the [latest release](https://github.com/storytold/soundcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/soundcraft/releases) have earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
### Windows

View File

@ -122,6 +122,9 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-12:** `soundcraft-cli convert` rejects an unparsable or zero `--rate` and an unknown `--bit-depth` (exit 1, nothing written) instead of converting with a default (#256)
- **2026-10-12:** Timecode labels with huge fields no longer overflow: frame counts are computed in 128 bits and clamp, so `setup.current_timecode` and `session.format_time` saturate instead of failing (#248)
- **2026-10-12:** `session.format_time` with the smallest bar number no longer fails with an internal error: the bar offset saturates (#249)
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
@ -143,6 +146,9 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | trivial | Progress log: convert option validation (#256) |
| 2026-10-12 | trivial | Progress log: timecode overflow (#248) |
| 2026-10-12 | trivial | Progress log: bar offset saturates (#249) |
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |

View File

@ -0,0 +1,90 @@
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
//! flag must not silently remove a root and leave the server unconfined.
use std::path::PathBuf;
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
if value.starts_with('-') {
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
}
value
}
};
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
Ok(true)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--demo", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(args.next().as_deref(), Some("song.scraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
}
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
}
}

View File

@ -8,15 +8,23 @@
//! soundcraft-cli commands [FILTER] list commands
//! soundcraft-cli describe ID one command's details
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
//! soundcraft-cli mcp [--connect PORT] [--demo] MCP server on stdio
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR]
//! MCP server on stdio. The two roots confine every file the
//! agent's commands read (open, import, presets) and write
//! (save, bounce, export) to those folders, links followed;
//! a root left out grants none of its access. They run the
//! in-process server, so they conflict with --connect.
//! soundcraft-cli parity [--write PATH] parity report (markdown)
//! soundcraft-cli plugins list built-in plugins
//! ```
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod automation_args;
use serde_json::{Value, json};
use soundcraft_automation::{Backend, Headless, Remote, mcp::Server};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::Write;
use std::process::ExitCode;
@ -114,6 +122,14 @@ fn info(args: &[String]) -> ExitCode {
fn convert(args: &[String]) -> ExitCode {
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
Ok(r) => r,
Err(e) => return fail(e),
};
let bit_depth = match parse_bit_depth(arg_value(args, "--bit-depth").as_deref()) {
Ok(b) => b,
Err(e) => return fail(e),
};
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")),
@ -123,7 +139,7 @@ fn convert(args: &[String]) -> ExitCode {
Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")),
};
if let Some(rate) = arg_value(args, "--rate").and_then(|r| r.parse::<u32>().ok()) {
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
}
@ -134,11 +150,6 @@ fn convert(args: &[String]) -> ExitCode {
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
let bit_depth = match arg_value(args, "--bit-depth").as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
Ok(()) => {
@ -149,6 +160,25 @@ fn convert(args: &[String]) -> ExitCode {
}
}
fn parse_rate(v: Option<&str>) -> Result<Option<u32>, String> {
match v {
None => Ok(None),
Some(r) => match r.parse::<u32>() {
Ok(hz) if hz > 0 => Ok(Some(hz)),
_ => Err(format!("--rate: expected a sample rate in Hz, got `{r}`")),
},
}
}
fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, String> {
match v {
None | Some("24") => Ok(soundcraft_audio_io::BitDepth::Int24),
Some("16") => Ok(soundcraft_audio_io::BitDepth::Int16),
Some("32") | Some("32f") => Ok(soundcraft_audio_io::BitDepth::Float32),
Some(b) => Err(format!("--bit-depth: expected 16, 24 or 32f, got `{b}`")),
}
}
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
fn missing_warnings(missing: &[String]) -> Vec<String> {
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
@ -188,7 +218,7 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
}
}
if let Some(path) = arg_value(args, "--save") {
match e.execute("session.save", &json!({"path": path})) {
match e.execute("session.save", &json!({"path": path, "replace": true})) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
@ -356,15 +386,62 @@ fn app(args: &[String]) -> ExitCode {
}
}
/// What `mcp` was asked for.
struct McpArgs {
connect: Option<String>,
demo: bool,
roots: Option<std::sync::Arc<AutomationRoots>>,
}
/// Parse the `mcp` options up front: a malformed or misspelled root flag must not leave the
/// server running unconfined.
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
let mut connect = None;
let mut demo = false;
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
continue;
}
match a.as_str() {
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
"--demo" => demo = true,
other if other.starts_with("--automation-") => return Err(format!("unknown mcp option `{other}`")),
_ => {}
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
.into());
}
Ok(McpArgs { connect, demo, roots })
}
fn mcp(args: &[String]) -> ExitCode {
let McpArgs { connect, demo, roots } = match parse_mcp_args(args) {
Ok(a) => a,
Err(err) => return fail(err),
};
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let r = if let Some(port) = arg_value(args, "--connect") {
let r = if let Some(port) = connect {
let mut s = Server::new(Remote::new(&port));
s.serve(stdin.lock(), stdout.lock())
} else {
let e = if args.iter().any(|a| a == "--demo") { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut s = Server::new(Headless::new(e));
// `--demo` is the operator's own choice, made before the roots come into force.
let e = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let mut h = Headless::new(e);
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
h = h.with_automation_roots(Some(roots));
}
let mut s = Server::new(h);
s.serve(stdin.lock(), stdout.lock())
};
match r {
@ -422,6 +499,18 @@ mod tests {
}
}
#[test]
fn convert_rejects_invalid_rate_and_bit_depth() {
assert_eq!(parse_rate(None), Ok(None));
assert_eq!(parse_rate(Some("44100")), Ok(Some(44_100)));
assert!(parse_rate(Some("4410x")).is_err());
assert!(parse_rate(Some("0")).is_err());
assert!(matches!(parse_bit_depth(None), Ok(soundcraft_audio_io::BitDepth::Int24)));
assert!(matches!(parse_bit_depth(Some("16")), Ok(soundcraft_audio_io::BitDepth::Int16)));
assert!(matches!(parse_bit_depth(Some("32f")), Ok(soundcraft_audio_io::BitDepth::Float32)));
assert!(parse_bit_depth(Some("8")).is_err());
}
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
@ -446,6 +535,7 @@ mod tests {
let help = help_text();
assert!(help.starts_with("soundcraft-cli info FILE"));
assert!(help.ends_with("soundcraft-cli plugins list built-in plugins"));
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"));
assert!(!help.contains("```"));
assert!(!help.contains("#![") && !help.contains("use serde_json"));
}

View File

@ -0,0 +1,140 @@
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Output, Stdio};
use serde_json::Value;
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
fn run(args: &[&str]) -> Output {
bin().args(args).output().unwrap()
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-roots-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
for (i, (name, arguments)) in calls.iter().enumerate() {
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
writeln!(stdin, "{call}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
}
fn text(reply: &Value) -> &str {
reply["result"]["content"][0]["text"].as_str().unwrap()
}
/// A short WAV made by the binary itself.
fn make_wav(path: &std::path::Path) {
let o = run(&["run", "--demo", "--bounce", path.to_str().unwrap(), "--start", "0", "--end", "0.1"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}
#[test]
fn mcp_automation_roots_confine_the_server() {
let base = tmpdir("roots");
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
std::fs::create_dir_all(&work).unwrap();
std::fs::create_dir_all(&elsewhere).unwrap();
make_wav(&work.join("in.wav"));
make_wav(&elsewhere.join("secret.wav"));
let work_s = work.to_str().unwrap();
let secret = elsewhere.join("secret.wav").to_str().unwrap().to_string();
// Both spellings of the flags; inside works, outside is refused with a readable message.
let read_flag = format!("--automation-read-root={work_s}");
let calls = [
("import_audio", serde_json::json!({"path": work.join("in.wav").to_str().unwrap()})),
("import_audio", serde_json::json!({"path": secret})),
("save_session", serde_json::json!({"path": work.join("saved.scraft").to_str().unwrap()})),
("save_session", serde_json::json!({"path": elsewhere.join("saved.scraft").to_str().unwrap()})),
("bounce_mix", serde_json::json!({"path": elsewhere.join("mix.wav").to_str().unwrap()})),
];
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
assert!(stderr.contains("files confined"), "{stderr}");
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
assert_eq!(replies[1]["result"]["isError"], true);
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert!(work.join("saved.scraft").exists());
assert_eq!(replies[3]["result"]["isError"], true);
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
assert_eq!(replies[4]["result"]["isError"], true);
assert!(text(&replies[4]).contains("outside the write root"), "{}", text(&replies[4]));
assert!(!elsewhere.join("saved.scraft").exists() && !elsewhere.join("mix.wav").exists());
// A root left out grants none of its access.
let (_, _, replies) = mcp_session(&[&read_flag], &calls[2..3]);
assert_eq!(replies[0]["result"]["isError"], true);
assert!(text(&replies[0]).contains("write authority is absent"), "{}", text(&replies[0]));
let (_, _, replies) = mcp_session(&["--automation-write-root", work_s], &calls[0..1]);
assert_eq!(replies[0]["result"]["isError"], true);
assert!(text(&replies[0]).contains("read authority is absent"), "{}", text(&replies[0]));
// No flags: nothing is confined.
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
assert!(success);
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args");
let dir = base.to_str().unwrap();
let missing = base.join("missing");
let fails = |args: &[&str], needle: &str| {
let mut full = vec!["mcp"];
full.extend_from_slice(args);
let o = run(&full);
assert!(!o.status.success(), "{args:?}");
let err = String::from_utf8_lossy(&o.stderr).into_owned();
assert!(err.contains(needle), "{args:?}: {err}");
};
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--demo"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root="], "needs a non-empty folder");
// A misspelled root flag must not start an unconfined server.
fails(&["--automation-read-roots", dir], "unknown mcp option");
// A file is not a folder.
std::fs::write(base.join("f.txt"), "x").unwrap();
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn usage_lists_the_root_flags() {
let o = run(&["--help"]);
let help = String::from_utf8_lossy(&o.stdout).into_owned();
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
}

View File

@ -17,10 +17,14 @@ path = "src/main.rs"
soundcraft-engine = { workspace = true }
soundcraft-ui-egui = { workspace = true }
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
js-sys = "0.3"
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }

View File

@ -7,7 +7,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - no native file dialogs: the demo session is loaded on start;
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
//! download (the demo session is still loaded on start);
//! - browsers only start audio after a user gesture, so the first click may be needed before
//! playback is heard.
//!

View File

@ -1,7 +1,11 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use soundcraft_engine::Engine;
use serde_json::json;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::JsCast as _;
@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
io::set_deliver(download);
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@ -36,7 +41,7 @@ pub fn start() {
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
let app = SoundApp::new(engine, Some(player), Services::default());
Ok(Box::new(WebShell(app)))
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
}),
)
.await;
@ -53,18 +58,183 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
struct WebShell(SoundApp);
/// What a file read from the browser is for.
#[derive(Debug, Clone, Copy)]
enum Purpose {
Session,
Audio,
Midi,
}
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", "m4b", "aac", "mp4", "caf", "mkv", "webm"];
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
impl Purpose {
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
fn of(name: &str) -> Self {
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
if SESSION_EXTS.contains(&ext.as_str()) {
Self::Session
} else if MIDI_EXTS.contains(&ext.as_str()) {
Self::Midi
} else {
Self::Audio
}
}
/// The command whose path prompt this replaces in the browser.
fn for_command(cmd: &str) -> Option<Self> {
match cmd {
"session.open" => Some(Self::Session),
"file.import_audio" => Some(Self::Audio),
"file.import_midi" => Some(Self::Midi),
_ => None,
}
}
fn filter(self) -> (&'static str, &'static [&'static str]) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS),
Self::Audio => ("Audio", AUDIO_EXTS),
Self::Midi => ("MIDI", MIDI_EXTS),
}
}
}
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
/// handled on the next frame. The browser has no paths: files travel as bytes.
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
struct WebShell {
app: SoundApp,
inbox: Inbox,
}
impl WebShell {
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
/// browser can't read (#63, #64). Show the browser's file picker instead.
fn take_path_prompt(&mut self, ctx: &egui::Context) {
let purpose = match &self.app.dialogs.open {
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
_ => None,
};
let Some(purpose) = purpose else { return };
self.app.dialogs.open = None;
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let (label, exts) = purpose.filter();
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
let bytes = file.read().await;
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
ctx.request_repaint();
});
}
fn take_drops(&mut self, ctx: &egui::Context) {
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {
Purpose::Session => {
let _ = self.app.run("transport.stop", json!({}));
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
})
}
// Imports are edits: one undo step each, like the import commands.
Purpose::Audio => {
let before = e.session_arc();
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
e.push_undo("Import Audio", before);
format!("Imported {name}")
})
}
Purpose::Midi => {
let before = e.session_arc();
io::import_midi_bytes(e, bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
}
};
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
}
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
self.take_drops(ctx);
self.take_path_prompt(ctx);
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
for (purpose, name, bytes) in pending {
self.handle(purpose, &name, &bytes);
}
self.app.logic(ctx);
// A prompt opened during this frame becomes a picker on the next one.
self.take_path_prompt(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
}
}
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
/// hook on the web).
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
Some("scraftz") => "application/zip",
Some("wav") => "audio/wav",
Some("aif" | "aiff") => "audio/aiff",
Some("flac") => "audio/flac",
Some("mid" | "midi") => "audio/midi",
Some("svg") => "image/svg+xml",
Some("txt") => "text/plain",
Some("json" | "scraft") => "application/json",
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
_ => "application/octet-stream",
});
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click was dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}

View File

@ -20,11 +20,20 @@ mod logging;
mod native_menu;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
use std::sync::Arc;
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
fn set_title(ctx: &egui::Context, title: String) {
let id = egui::Id::new("window_title");
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
@ -48,7 +57,16 @@ impl eframe::App for App {
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
soundcraft_ui_egui::fit_window_to_screen(ctx);
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
self.0.ui.window_maximized = maximized;
if let Some(r) = inner.filter(|_| !maximized) {
self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into();
}
self.0.ui.ui_scale = self.0.extra.ui_scale;
// With the system menu bar there is no egui bar showing the window and dirty state, so the
// title carries them; elsewhere the egui menu bar already does.
let title = if self.0.native_menu_bar {
@ -61,7 +79,7 @@ impl eframe::App for App {
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
set_title(ctx, title);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@ -84,7 +102,7 @@ impl eframe::App for App {
fn open_path(app: &mut SoundApp, p: &str) {
let lower = p.to_ascii_lowercase();
let r = if lower.ends_with(".scraft") {
app.run("session.open", serde_json::json!({"path": p}))
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
@ -144,7 +162,7 @@ fn services() -> Services {
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
_ => d.add_filter(
"Audio",
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"],
&["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "m4b", "aac", "mp4", "caf", "mkv", "webm"],
),
};
d.pick_file().map(|p| p.to_string_lossy().into_owned())
@ -157,6 +175,9 @@ fn services() -> Services {
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
})),
pick_folder: Some(Box::new(|start: &str| {
rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned())
})),
}
}
@ -213,10 +234,12 @@ fn main() -> eframe::Result {
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let prefs = load_prefs().unwrap_or_default();
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size([1600.0, 1000.0])
.with_inner_size(sane_window_size(prefs.window_size))
.with_maximized(prefs.window_maximized)
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_app_id("ai.storyteller.soundcraft"),
@ -236,9 +259,8 @@ fn main() -> eframe::Result {
let mut app = SoundApp::new(engine, player, services());
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
if let Some(ui) = load_prefs() {
app.ui = ui;
}
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
@ -267,3 +289,26 @@ fn main() -> eframe::Result {
}),
)
}
/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny).
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
}
#[cfg(test)]
mod tests {
use super::set_title;
#[test]
fn the_title_is_sent_only_when_it_changes() {
let ctx = egui::Context::default();
let titles = |title: &str| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
out.textures_delta.clear();
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
};
assert_eq!(titles("Song — SoundCraft"), 1);
assert_eq!(titles("Song — SoundCraft"), 0);
assert_eq!(titles("Song * — SoundCraft"), 1);
}
}

View File

@ -113,6 +113,11 @@ impl NativeMenu {
self.last_sync = Some(now);
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if let Some(label) = menus::live_label(app, entry.command.as_deref())
&& entry.item.text() != label
{
entry.item.set_text(label);
}
if enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;

View File

@ -40,3 +40,10 @@ sync_github_name = true
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
[people.webdevtodayjason]
display_name = "Jason Brashear"
[people.Silvertree2010]
display_name = "Netter"
sync_github_name = false

View File

@ -0,0 +1,87 @@
//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie,
//! a packet table or a packet claims, and a failed allocation aborts the process.
use crate::pcm::{be_u16, be_u32, be_u64};
use crate::{AudioError, Result};
/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes
/// than the file holds.
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) }
}
fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("CAF {what} is larger than the file"))
}
/// Walk the chunks the way symphonia 0.5 reads them: it advances by the bytes it parses, not by
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
// Whether the packet table lists each packet's size and frame count (variable per packet).
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
let (body, size) = (pos + 12, size as i64);
let used = match kind {
b"desc" if size == 32 => {
lists_sizes = be_u32(b, body + 16) == Some(0);
lists_frames = be_u32(b, body + 20) == Some(0);
described = true;
Some(32)
}
b"data" if size == -1 => Some(4),
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"desc" | b"pakt" | b"chan" => None,
_ => u64::try_from(size).ok(),
};
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
Some(next) if next < b.len() => pos = next,
_ => return Ok(()),
}
}
Ok(())
}
/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or
/// None where symphonia stops. It reserves room for every packet before reading any, so a table
/// it would refuse after that, or that runs past the file, is refused first.
fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result<Option<u64>> {
let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0);
let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) };
let mut at = body + 24;
if packets > b.len().saturating_sub(at) as u64 {
return Err(malformed("packet table"));
}
if !lists_sizes && !lists_frames {
return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) };
}
for _ in 0..packets {
if lists_sizes {
let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) };
if size > b.len() as u64 {
return Err(malformed("packet"));
}
at += len;
}
if lists_frames {
let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) };
at += len;
}
}
Ok(Some((at - body) as u64))
}
/// A CAF variable-length integer at `at` and its length in bytes.
fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
let mut v = 0u64;
for i in 0..9 {
let x = *b.get(at + i)?;
v |= u64::from(x & 0x7f);
if x & 0x80 == 0 {
return Some((v, i + 1));
}
v <<= 7;
}
None
}

View File

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

View File

@ -8,12 +8,15 @@
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
//! 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.
#![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;
@ -186,7 +189,7 @@ fn detect_by_ext(ext: &str) -> FileFormat {
"flac" => FileFormat::Flac,
"mp3" | "mp2" | "mp1" => FileFormat::Mp3,
"ogg" | "oga" => FileFormat::Ogg,
"aac" | "m4a" | "mp4" | "adts" => FileFormat::Aac,
"aac" | "m4a" | "m4b" | "mp4" | "adts" => FileFormat::Aac,
"caf" => FileFormat::Caf,
_ => FileFormat::Other,
}
@ -204,16 +207,16 @@ pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
wav::WavParse::Native(l) => Ok(l.info),
wav::WavParse::Foreign { bwf } => {
let mut info = symph::probe(bytes, Some("wav"), format)?;
let mut info = symph::probe(bytes, format)?;
info.bwf = bwf;
Ok(info)
}
},
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
aiff::AiffParse::Native(l) => Ok(l.info),
aiff::AiffParse::Foreign => symph::probe(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::probe(bytes, format),
},
_ => symph::probe(bytes, ext_hint, format),
_ => symph::probe(bytes, format),
}
}
@ -229,7 +232,7 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
Ok((info, buf))
}
wav::WavParse::Foreign { bwf } => {
let (mut info, buf) = symph::decode(bytes, Some("wav"), format)?;
let (mut info, buf) = symph::decode(bytes, format)?;
info.bwf = bwf;
Ok((info, buf))
}
@ -241,9 +244,9 @@ pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioB
info.frames = buf.frames() as u64;
Ok((info, buf))
}
aiff::AiffParse::Foreign => symph::decode(bytes, Some("aiff"), format),
aiff::AiffParse::Foreign => symph::decode(bytes, format),
},
_ => symph::decode(bytes, ext_hint, format),
_ => symph::decode(bytes, format),
}
}
@ -289,3 +292,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
wav::read_channel_mask(bytes)
}
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
/// switches to RF64 above 4 GiB.
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
if channels == 0 || sample_rate == 0 {
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
}
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
}

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

@ -0,0 +1,106 @@
//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample
//! table or a sample claims, and a failed allocation aborts the process.
use crate::pcm::{be_u32, be_u64};
use crate::{AudioError, Result};
#[derive(Clone, Copy)]
enum Level {
Top,
Moov,
Trak,
Mdia,
Minf,
Stbl,
Udta,
Meta,
Ilst,
Tag,
}
/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a
/// sample table or a sample claims more bytes than the file holds.
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
atoms(b, at, b.len(), Level::Top).map(|_| ())
}
fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("MP4 {what} is larger than the file"))
}
/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where
/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`.
fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
// A header that starts inside the container is read whole, even past its end.
while pos < end {
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) };
let head = if size == 1 { 16 } else { 8 };
// Size 0: no data, and the next atom starts at the container's end.
let len = match size {
0 => head,
1 => be_u64(b, pos + 8).unwrap_or(0),
n => u64::from(n),
};
if len < head {
return Ok(false);
}
let body = pos + head as usize;
let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n));
let next = if size == 0 { Some(end) } else { data_end };
let inner = data_end.map_or(b.len(), |n| n.min(b.len()));
let more = match (level, kind) {
(Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?,
(Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?,
(Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?,
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
}
_ => true,
};
match next {
Some(n) if more && n <= end => pos = n,
_ => return Ok(false),
}
}
Ok(true)
}
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
let Some(n) = be_u32(b, count) else { return Ok(false) };
let (first, bytes) = (count + 4, u64::from(n) * entry);
if bytes > b.len().saturating_sub(first) as u64 {
return Err(malformed("sample table"));
}
Ok(first as u64 + bytes <= end as u64)
}
/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size.
/// Sizes past the atom are never used, as symphonia stops before reading a packet.
fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result<bool> {
let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) };
let file = b.len() as u64;
if u64::from(size) > file {
return Err(malformed("sample"));
}
if size != 0 {
return Ok(true);
}
let fits = table(b, body + 8, 4, end)?;
let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>();
if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) {
return Err(malformed("sample"));
}
Ok(fits)
}

View File

@ -36,6 +36,12 @@ pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
Some(u32::from_be_bytes(s))
}
/// Big-endian `u64` at `pos`.
pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option<u64> {
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
Some(u64::from_be_bytes(s))
}
/// Four-byte tag at `pos`.
pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> {
b.get(pos..pos.checked_add(4)?)?.try_into().ok()

View File

@ -12,13 +12,17 @@ use symphonia::core::codecs::{
};
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Hint;
use symphonia::core::probe::Instantiate;
use crate::pcm::MAX_DECODE_SAMPLES;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
fn map_err(e: SymError) -> AudioError {
match e {
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
@ -32,15 +36,36 @@ fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
}
fn open(bytes: &[u8], ext_hint: Option<&str>) -> Result<Box<dyn FormatReader>> {
let mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let mut hint = Hint::new();
if let Some(ext) = ext_hint {
hint.with_extension(ext.trim_start_matches('.'));
}
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it
/// finds before its reader allocates whatever the file claims.
fn open(bytes: &[u8]) -> Result<Box<dyn FormatReader>> {
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() };
let probed = symphonia::default::get_probe().format(&hint, mss, &fmt_opts, &MetadataOptions::default()).map_err(map_err)?;
Ok(probed.format)
loop {
let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?;
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
let head = bytes.get(at..).unwrap_or_default();
match (found, head.get(..10)) {
(Instantiate::Format(reader), _) => {
match head.get(..4) {
Some(b"caff") => crate::caf::check(bytes, at)?,
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
Some(b"fLaC") => crate::flac::check_metadata(bytes, at)?,
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
_ => {}
}
return reader(mss, &fmt_opts).map_err(map_err);
}
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f));
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
(Instantiate::Metadata(tag), _) => {
tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?;
}
}
}
}
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat {
@ -115,8 +140,8 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
}
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = open(bytes, ext_hint)?;
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = open(bytes)?;
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) {
let total = n.saturating_mul(u64::from(info.channels.max(1)));
@ -130,6 +155,10 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
.find(|t| t.id == track_id)
.map(|t| t.codec_params.clone())
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
let mut decoder = symphonia::default::get_codecs().make(&params, &DecoderOptions::default()).map_err(map_err)?;
let mut planar: Vec<Vec<f32>> = Vec::new();
@ -206,18 +235,18 @@ fn run(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat, count_only: b
Ok((info, AudioBuffer { sample_rate, channels: planar }))
}
pub(crate) fn probe(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<AudioInfo> {
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let reader = open(bytes, ext_hint)?;
let reader = open(bytes)?;
let (_, info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 {
return Ok(info);
}
// Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
run(bytes, ext_hint, detected, true).map(|(i, _)| i)
run(bytes, detected, true).map(|(i, _)| i)
})
}
pub(crate) fn decode(bytes: &[u8], ext_hint: Option<&str>, detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
guarded(|| run(bytes, ext_hint, detected, false))
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
guarded(|| run(bytes, detected, false))
}

View File

@ -83,6 +83,57 @@ fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
})
}
/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry
/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are
/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length.
pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> {
if tag(b, at + 8) != Some(*b"WAVE") {
return Ok(());
}
let mut pos = at + 12;
while let Some(id) = tag(b, pos) {
let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break };
let (body, pad) = (pos + 8, len & 1);
let used = match &id {
b"data" => break,
b"fmt " => fmt_used(b, body, len),
b"LIST" if len < 4 => break,
b"LIST" => {
let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 };
// The list pads itself, then the chunk is padded again.
4 + entries.max(len - 4) + pad
}
_ => len,
};
pos = body.saturating_add(used).saturating_add(pad);
}
Ok(())
}
/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size.
fn fmt_used(b: &[u8], body: usize, len: usize) -> usize {
match le_u16(b, body) {
Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)),
Some(0xfffe) => 40,
_ => len,
}
}
/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of
/// them, with the pad it reads after an odd entry.
fn info_entries(b: &[u8], start: usize, len: usize) -> Result<usize> {
let mut used = 0;
while used + 8 <= len {
let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break };
if n > b.len().saturating_sub(start + used + 8) {
return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into()));
}
used += 8 + n;
used += used & 1;
}
Ok(used)
}
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
pub(crate) fn is_wav(b: &[u8]) -> bool {
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
@ -276,60 +327,8 @@ pub(crate) fn encode(
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&(total as u64).to_le_bytes());
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
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 mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
@ -352,3 +351,70 @@ pub(crate) fn encode(
}
Ok(out)
}
/// Everything before the sample data of a WAV holding `frames` frames.
pub(crate) fn header(
channels: usize,
sample_rate: u32,
(bits, is_float): (u16, bool),
bwf: Option<&BwfInfo>,
mask: Option<u32>,
frames: u64,
force_rf64: bool,
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&total.to_le_bytes());
ds64.extend_from_slice(&data_len.to_le_bytes());
ds64.extend_from_slice(&frames.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
Ok(head)
}

View File

@ -153,6 +153,194 @@ fn absurd_chunk_sizes() {
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
}
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
let mut out = b"caff\0\x01\0\0".to_vec();
for (kind, size, body) in chunks {
out.extend_from_slice(*kind);
out.extend_from_slice(&size.to_be_bytes());
out.extend_from_slice(body);
}
out
}
fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec<u8>) {
let mut d = 48_000f64.to_be_bytes().to_vec();
d.extend_from_slice(format);
for v in [0, bytes_per_packet, frames_per_packet, channels, bits] {
d.extend_from_slice(&v.to_be_bytes());
}
(b"desc", 32, d)
}
fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec<u8> {
[&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat()
}
#[test]
fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
let aac = || caf_desc(b"aac ", 0, 1024, 2, 0);
let huge = 1i64 << 50;
let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]);
let files = [
cookie.clone(),
[vec![0; 100], cookie].concat(),
caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]),
caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]),
caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]),
caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]),
];
for f in files {
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]);
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
}
#[test]
fn well_formed_caf_still_reads() {
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat();
let data = [vec![0; 4], pcm].concat();
let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]);
let (info, buf) = decode(&lpcm, None).unwrap();
assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480));
let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]);
let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]);
assert_eq!(probe(&aac, None).unwrap().frames, 3072);
}
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms.
fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec<u8> {
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat())
}
/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom.
fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -> Vec<u8> {
let full = |kind: &[u8; 4], fields: &[u32]| {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
swap(kind, atom(kind, &body))
};
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat());
let stbl =
[stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
}
#[test]
fn mp4_sizes_beyond_the_file_are_errors_not_aborts() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap();
assert_eq!(buf.frames(), 480);
let huge = |kind: &[u8; 4], a: Vec<u8>| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat();
// The first field after version and flags: stts's entry count, stsz's sample size.
let first_field_max = |mut a: Vec<u8>| {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
a
};
let sizes: Vec<u8> = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect();
let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat());
// A header that starts inside its container is read whole, past the container's end.
let mdia = atom(b"mdia", &1u32.to_be_bytes());
let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat();
let files = [
mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }),
[atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(),
];
for f in files {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
}
#[test]
fn atoms_running_past_their_parent_are_walked_once() {
// Each moov holds a trak that runs to the end of the file; symphonia stops after the first.
let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat());
let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat();
assert!(decode(&f, None).is_err());
}
#[test]
fn packets_longer_than_any_codec_uses_are_refused() {
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat();
let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]);
let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat();
let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames)));
let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap();
assert_eq!(buf.frames(), 480);
for entry in [lpcm(1 << 21), alac(1 << 21)] {
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
}
}
#[test]
fn flac_metadata_fields_beyond_their_block_are_refused() {
let with_block = |kind: u8, body: &[u8]| {
let f = small(FileFormat::Flac, BitDepth::Int16, 1);
let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes();
[&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat()
};
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
}
}
#[test]
fn an_id3_tag_before_the_audio_is_skipped_unread() {
let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat();
let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat();
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
}
#[test]
fn wav_info_entries_beyond_the_file_are_refused() {
let alaw = |len: u32| {
let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat();
riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])])
};
assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800);
let mut f = alaw(0x7fff_fff0);
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = f.windows(4).position(|w| w == b"LIST").unwrap();
f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat());
// A-law's extra bytes are read past its chunk; an odd list is padded twice.
let files =
[riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()];
for mut f in files {
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
}
#[test]
fn many_tiny_chunks_terminate() {
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
@ -240,6 +428,7 @@ fn detect_format_by_magic_and_hint() {
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
assert_eq!(detect_format(b"zz", None), FileFormat::Other);

View File

@ -1,5 +1,5 @@
use soundcraft_audio_io::{
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
};
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
@ -268,3 +268,19 @@ fn multichannel_wav_speaker_masks() {
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
}
#[test]
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
let take = sine(48_000, 2, 1_000);
let mut file = wav_float_header(2, 48_000, 0).unwrap();
let len = file.len();
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}

View File

@ -11,8 +11,10 @@ pub mod mcp;
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::sync::Arc;
use std::time::Duration;
/// Answers control-protocol methods.
@ -26,16 +28,22 @@ pub trait Backend {
/// An in-process engine (no UI).
pub struct Headless {
pub engine: Engine,
roots: Option<Arc<AutomationRoots>>,
}
impl Headless {
pub fn new(engine: Engine) -> Self {
Headless { engine }
Headless { engine, roots: None }
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
/// Confine every file the session's commands read and write to `roots`
/// (`soundcraft-cli mcp --automation-read-root … --automation-write-root …`).
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
self.roots = roots;
self
}
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
match method {
"engine.execute" | "command" => {
let id = params.get("command").or_else(|| params.get("id")).and_then(Value::as_str).ok_or("`command` required")?;
@ -56,6 +64,13 @@ impl Backend for Headless {
other => Err(format!("unknown method `{other}`")),
}
}
}
impl Backend for Headless {
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let roots = self.roots.clone();
file_access::confine(roots.as_ref(), || self.answer(method, params))
}
fn has_ui(&self) -> bool {
false
}

View File

@ -54,7 +54,12 @@ pub fn tool_definitions() -> Vec<Value> {
&[],
),
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
tool(
"save_session",
"Save the session (path required the first time; replace: true to save over another existing file).",
json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}),
&[],
),
tool(
"import_audio",
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",

View File

@ -0,0 +1,352 @@
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
//! stays inside the read root, every file it writes inside the write root.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::AutomationRoots;
use std::path::{Path, PathBuf};
/// A marker that must never come back from a file outside the read root.
const SECRET: &str = "TOP-SECRET-MARKER-5f3a";
struct Fixture {
base: PathBuf,
read: PathBuf,
write: PathBuf,
outside: PathBuf,
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.base);
}
}
fn s(p: impl AsRef<Path>) -> String {
p.as_ref().to_string_lossy().into_owned()
}
/// `in/` (read root), `out/` (write root) and `outside/`, each holding the same real files:
/// a session with audio, a WAV, a MIDI file, a track preset and a clip group file. `outside/`
/// also holds text files holding [`SECRET`] under the names a command might open.
fn fixture(name: &str) -> Fixture {
let base = std::env::temp_dir().join(format!("sc-roots-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (read, write, outside) = (base.join("in"), base.join("out"), base.join("outside"));
for d in [&read, &write, &outside] {
std::fs::create_dir_all(d).unwrap();
}
// Real files, made by an unconfined engine.
let mut e = soundcraft_engine::demo::demo_engine();
for dir in [&read, &outside] {
let run = |e: &mut Engine, id: &str, p: Value| e.execute(id, &p).unwrap_or_else(|err| panic!("{id}: {err}"));
run(&mut e, "file.bounce_mix", json!({"path": s(dir.join("tone.wav")), "start": 0, "end": 4800}));
run(&mut e, "file.export_midi", json!({"path": s(dir.join("keys.mid")), "tracks": ["Keys"]}));
run(&mut e, "edit.select", json!({"tracks": ["Bass"]}));
run(&mut e, "track.save_preset", json!({"path": s(dir.join("bass.json"))}));
run(&mut e, "session.save_as", json!({"path": s(dir.join("song.scraft")), "replace": true}));
let clips = e.session().track_by_name("Kick").unwrap().clips().iter().map(|c| c.id.0).collect::<Vec<_>>();
run(&mut e, "file.export_clip_groups", json!({"path": s(dir.join("group.scgrp")), "clips": clips}));
}
for f in ["secret.wav", "secret.mid", "secret.scraft", "secret.json", "secret.scgrp", "secret.mp4"] {
std::fs::write(outside.join(f), SECRET).unwrap();
}
Fixture { base, read, write, outside }
}
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read, write).unwrap();
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
}
/// One `execute` call: whether it failed and the text of the result.
fn run(s: &mut Server<Headless>, command: &str, params: Value) -> (bool, String) {
let r = s
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "execute", "arguments": {"command": command, "params": params}}}))
.unwrap();
let res = &r["result"];
(res["isError"].as_bool().unwrap(), res["content"][0]["text"].as_str().unwrap().to_string())
}
fn ok(s: &mut Server<Headless>, command: &str, params: Value) -> String {
let (failed, text) = run(s, command, params);
assert!(!failed, "{command} failed: {text}");
text
}
fn refused(s: &mut Server<Headless>, command: &str, params: Value, needle: &str) {
let (failed, text) = run(s, command, params.clone());
assert!(failed, "{command} {params} must be refused, got {text}");
assert!(text.contains(needle), "{command}: {text}");
assert!(!text.contains(SECRET), "{command} leaked file contents: {text}");
}
/// Every file under `dir`, to show nothing was written.
fn listing(dir: &Path) -> Vec<String> {
let mut out = vec![];
for entry in std::fs::read_dir(dir).unwrap().flatten() {
let p = entry.path();
if p.is_dir() {
out.extend(listing(&p));
}
out.push(s(&p));
}
out.sort();
out
}
fn inspect(s: &mut Server<Headless>) -> Value {
let r = s
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "inspect_session", "arguments": {"detail": "full"}}}))
.unwrap();
serde_json::from_str(r["result"]["content"][0]["text"].as_str().unwrap()).unwrap()
}
/// Every command that reads a user-named file, with params for a file in `dir`.
fn reads(dir: &Path, name: &dyn Fn(&str) -> String) -> Vec<(&'static str, Value)> {
let p = |f: &str| s(dir.join(name(f)));
vec![
("session.open", json!({"path": p("song.scraft")})),
("file.import_audio", json!({"path": p("tone.wav")})),
("file.import_audio", json!({"paths": [p("tone.wav")]})),
("file.import_midi", json!({"path": p("keys.mid")})),
("file.import_video", json!({"path": p("movie.mp4")})),
("file.import_clip_groups", json!({"path": p("group.scgrp")})),
("file.import_session_data", json!({"path": p("song.scraft")})),
("track.load_preset", json!({"path": p("bass.json"), "tracks": ["Bass"]})),
]
}
#[test]
fn reads_outside_the_read_root_are_refused_and_nothing_reaches_the_result() {
let f = fixture("reads");
let mut srv = server(Some(&f.read), Some(&f.write));
ok(&mut srv, "session.new", json!({"name": "Work"}));
ok(&mut srv, "track.new", json!({"name": "Bass", "count": 1}));
ok(&mut srv, "track.new", json!({"name": "Pads", "count": 1, "kind": "instrument"}));
let before = inspect(&mut srv);
// The secret files hold the marker under the names the commands read, so a read would show.
let secret_name = |f: &str| format!("secret.{}", f.rsplit('.').next().unwrap());
for (command, params) in reads(&f.outside, &secret_name) {
refused(&mut srv, command, params, "outside the read root");
}
refused(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.outside.join("secret.wav"))}), "outside the read root");
// The open session is exactly as it was: nothing was imported or opened.
assert_eq!(inspect(&mut srv), before);
// The same commands work on files inside the root.
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
ok(&mut srv, "file.import_midi", json!({"path": s(f.read.join("keys.mid"))}));
ok(&mut srv, "file.import_clip_groups", json!({"path": s(f.read.join("group.scgrp"))}));
ok(&mut srv, "file.import_session_data", json!({"path": s(f.read.join("song.scraft")), "tracks": ["Kick"]}));
ok(&mut srv, "track.load_preset", json!({"path": s(f.read.join("bass.json")), "tracks": ["Bass"]}));
ok(&mut srv, "mix.sampler_load", json!({"track": "Pads", "path": s(f.read.join("tone.wav"))}));
// A missing movie inside the root is an ordinary error, not a refusal.
let (failed, text) = run(&mut srv, "file.import_video", json!({"path": s(f.read.join("movie.mp4"))}));
assert!(failed && !text.contains("read root"), "{text}");
ok(&mut srv, "session.open", json!({"path": s(f.read.join("song.scraft"))}));
// The typed tools take the same path through the same checks.
let r = srv
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "open_session", "arguments": {"path": s(f.outside.join("secret.scraft"))}}}))
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the read root"));
}
/// A clip of the demo session.
fn kick_clip() -> u64 {
soundcraft_engine::demo::demo_engine().session().track_by_name("Kick").unwrap().clips()[0].id.0
}
/// Every command that writes a user-named file or folder, with params for `dir`.
fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
let p = |f: &str| s(dir.join(f));
let clip = kick_clip();
vec![
("session.save", json!({"path": p("a.scraft")})),
("session.save_as", json!({"path": p("b.scraft")})),
("session.save_copy", json!({"path": p("c.scraft")})),
("session.save_template", json!({"path": p("d.scraft")})),
("file.bounce_mix", json!({"path": p("e.wav"), "start": 0, "end": 4800})),
("file.bounce_stems", json!({"dir": p("stems"), "start": 0, "end": 4800})),
("file.export_midi", json!({"path": p("f.mid")})),
("file.export_session_text", json!({"path": p("g.txt")})),
("file.export_clips", json!({"dir": p("clips"), "clips": [clip]})),
("file.export_selected_tracks_as_session", json!({"path": p("h.scraft"), "tracks": ["Kick"]})),
("file.export_sibelius", json!({"path": p("i.musicxml")})),
("file.send_to_sibelius", json!({"path": p("j.musicxml")})),
("file.print_score", json!({"path": p("k.svg")})),
("file.export_clip_groups", json!({"path": p("l.scgrp"), "clips": [clip]})),
("track.save_preset", json!({"path": p("m.json"), "tracks": ["Kick"]})),
]
}
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read, write).unwrap();
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
}
#[test]
fn every_writing_command_is_refused_outside_the_write_root_and_writes_nothing() {
let f = fixture("writes");
let mut srv = demo_server(Some(&f.read), Some(&f.write));
let before = listing(&f.base);
let outside_before = listing(&f.outside);
for (command, params) in writes(&f.outside) {
refused(&mut srv, command, params, "outside the write root");
}
// Not even the temporary file a save goes through, nor a folder.
assert_eq!(listing(&f.base), before, "nothing may appear anywhere");
// With no path, the score goes to the temp folder: outside the write root, so refused too.
refused(&mut srv, "file.send_to_sibelius", json!({}), "outside the write root");
refused(&mut srv, "file.print_score", json!({}), "outside the write root");
assert_eq!(listing(&f.base), before);
// Inside the write root every one of them works.
for (command, params) in writes(&f.write) {
let text = ok(&mut srv, command, params);
assert!(!text.is_empty(), "{command}");
}
assert!(f.write.join("e.wav").is_file() && f.write.join("stems").is_dir() && f.write.join("l.scgrp").is_file());
// A save puts the audio in `Audio Files/` beside it, inside the root as well.
assert!(f.write.join("Audio Files").is_dir());
assert_eq!(listing(&f.outside), outside_before, "outside is as it was");
}
#[test]
fn saving_over_an_existing_file_outside_does_not_report_that_it_exists() {
let f = fixture("exists");
let mut srv = demo_server(Some(&f.read), Some(&f.write));
// Without `replace`, an existing target is reported as existing: not for a path outside the root.
refused(&mut srv, "session.save_as", json!({"path": s(f.outside.join("song.scraft"))}), "outside the write root");
}
#[test]
fn the_typed_bounce_and_save_tools_are_confined() {
let f = fixture("typed");
let mut srv = demo_server(Some(&f.read), Some(&f.write));
for (tool, args) in [
("bounce_mix", json!({"path": s(f.outside.join("x.wav")), "start": 0, "end": 4800})),
("save_session", json!({"path": s(f.outside.join("x.scraft"))})),
] {
let r = srv.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": tool, "arguments": args}})).unwrap();
assert_eq!(r["result"]["isError"], true, "{tool}");
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("outside the write root"), "{tool}");
}
assert!(!f.outside.join("x.wav").exists() && !f.outside.join("x.scraft").exists());
let r = srv
.handle(&json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": {"name": "bounce_mix", "arguments": {"path": s(f.write.join("x.wav")), "start": 0, "end": 4800}}}))
.unwrap();
assert_eq!(r["result"]["isError"], false);
}
#[test]
fn one_root_grants_only_its_own_access() {
let f = fixture("one-root");
// Read root only: reads work, nothing can be written anywhere.
let mut srv = demo_server(Some(&f.read), None);
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
let before = listing(&f.base);
for (command, params) in writes(&f.write) {
refused(&mut srv, command, params, "write authority is absent");
}
for (command, params) in writes(&f.read) {
refused(&mut srv, command, params, "write authority is absent");
}
assert_eq!(listing(&f.base), before);
// Write root only: writes work, nothing can be read anywhere.
let mut srv = demo_server(None, Some(&f.write));
for (command, params) in writes(&f.write) {
ok(&mut srv, command, params);
}
let name = |f: &str| f.to_string();
for (command, params) in reads(&f.read, &name) {
refused(&mut srv, command, params, "read authority is absent");
}
refused(&mut srv, "file.import_audio", json!({"path": s(f.write.join("e.wav"))}), "read authority is absent");
}
#[test]
fn the_roots_may_be_the_same_folder_and_are_compared_by_whole_names() {
let f = fixture("same");
let mut srv = demo_server(Some(&f.read), Some(&f.read));
ok(&mut srv, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
ok(&mut srv, "file.bounce_mix", json!({"path": s(f.read.join("again.wav")), "start": 0, "end": 4800}));
// `in2` shares the prefix of `in` and is a different folder.
let sibling = f.base.join("in2");
std::fs::create_dir_all(&sibling).unwrap();
std::fs::copy(f.read.join("tone.wav"), sibling.join("tone.wav")).unwrap();
refused(&mut srv, "file.import_audio", json!({"path": s(sibling.join("tone.wav"))}), "outside the read root");
refused(&mut srv, "file.bounce_mix", json!({"path": s(sibling.join("x.wav"))}), "outside the write root");
// `..` out of the root is outside it.
let up = format!("{}/../outside/tone.wav", s(&f.read));
refused(&mut srv, "file.import_audio", json!({"path": up}), "outside the read root");
}
#[cfg(unix)]
#[test]
fn links_out_of_the_roots_are_refused() {
use std::os::unix::fs::symlink;
let f = fixture("links");
symlink(f.outside.join("tone.wav"), f.read.join("leak.wav")).unwrap();
symlink(&f.outside, f.write.join("away")).unwrap();
symlink(f.outside.join("not-yet.wav"), f.write.join("dangling.wav")).unwrap();
let mut srv = demo_server(Some(&f.read), Some(&f.write));
refused(&mut srv, "file.import_audio", json!({"path": s(f.read.join("leak.wav"))}), "outside the read root");
let before = listing(&f.outside);
refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("away/x.wav"))}), "outside the write root");
refused(&mut srv, "file.bounce_stems", json!({"dir": s(f.write.join("away/stems"))}), "outside the write root");
refused(&mut srv, "session.save_as", json!({"path": s(f.write.join("away/x.scraft"))}), "outside the write root");
refused(&mut srv, "file.bounce_mix", json!({"path": s(f.write.join("dangling.wav"))}), "a link that leads nowhere");
assert_eq!(listing(&f.outside), before, "nothing was written through a link");
assert!(!f.outside.join("not-yet.wav").exists());
// A link to a folder inside the root stays inside it.
symlink(f.read.join("."), f.base.join("in-alias")).unwrap();
let mut via_alias = demo_server(Some(&f.base.join("in-alias")), None);
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
}
/// A session file is hostile input too: media it names outside the read root is not loaded,
/// and the refusal is listed with the other missing media.
#[test]
fn media_a_session_names_outside_the_read_root_is_not_loaded() {
let f = fixture("media");
let session = f.read.join("song.scraft");
let text = std::fs::read_to_string(&session).unwrap();
let outside_wav = s(f.outside.join("tone.wav"));
let swapped = {
let v: Value = serde_json::from_str(&text).unwrap();
let mut rel = vec![];
for src in v["sources"].as_array().unwrap() {
rel.push(src["path"].as_str().unwrap().to_string());
}
assert!(!rel.is_empty());
rel.iter().fold(text.clone(), |t, r| t.replace(&format!("\"{r}\""), &format!("{}", json!(outside_wav))))
};
std::fs::write(&session, swapped).unwrap();
let mut srv = server(Some(&f.read), None);
let opened = ok(&mut srv, "session.open", json!({"path": s(&session)}));
let opened: Value = serde_json::from_str(&opened).unwrap();
let missing = opened["missing"].as_array().unwrap();
assert!(!missing.is_empty() && missing.iter().all(|m| m.as_str().unwrap().contains("outside the read root")), "{opened}");
let sources = inspect(&mut srv)["sources"].clone();
assert!(sources.as_array().unwrap().iter().all(|x| x["loaded"] == false), "{sources}");
// The same session opened without roots does load the file it names.
let mut free = server(None, None);
ok(&mut free, "session.open", json!({"path": s(&session)}));
assert!(inspect(&mut free)["sources"].as_array().unwrap().iter().all(|x| x["loaded"] == true));
}
#[test]
fn without_roots_nothing_is_confined() {
let f = fixture("free");
let mut srv = server(None, None);
ok(&mut srv, "file.import_audio", json!({"path": s(f.outside.join("tone.wav"))}));
ok(&mut srv, "file.bounce_mix", json!({"path": s(f.outside.join("free.wav")), "start": 0, "end": 4800}));
assert!(f.outside.join("free.wav").is_file());
}

View File

@ -1,7 +1,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! SoundCraft DSP.
//!
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
//! AudioSuite-style whole-buffer processing.
//!
@ -28,6 +28,8 @@ pub use offline::FadeShape;
pub use osc::Waveform;
pub use plugins::eq::{eq1_response, eq7_response};
use std::sync::Arc;
/// Plugin category, used to group the insert menu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Category {
@ -197,6 +199,13 @@ pub trait Plugin: Send {
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
fn note_off(&mut self, _offset: usize, _note: u8) {}
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.
/// The previous sample is dropped here, so hosts keep their own reference to it (the
/// session's source pool does) to keep deallocation off the audio thread.
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
false
}
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
/// the plugin refused. May allocate: never call it in steady-state audio processing.
@ -240,6 +249,40 @@ pub trait PluginEditor: Send {
fn set_param(&mut self, _id: &str, _value: f32) {}
}
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
/// thread: no locks, no I/O.
pub trait SampleSource: Send + Sync {
/// Sample rate of the audio in Hz.
fn sample_rate(&self) -> f32;
fn num_channels(&self) -> usize;
/// One channel's frames (`None` past the last channel).
fn channel(&self, ch: usize) -> Option<&[f32]>;
/// Frames per channel (the shortest channel, so every index below it is readable).
fn frames(&self) -> usize {
(0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0)
}
}
/// A sample held in memory (tests, generated audio).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SampleBuffer {
pub sample_rate: f32,
pub channels: Vec<Vec<f32>>,
}
impl SampleSource for SampleBuffer {
fn sample_rate(&self) -> f32 {
self.sample_rate
}
fn num_channels(&self) -> usize {
self.channels.len()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.channels.get(ch).map(Vec::as_slice)
}
}
/// The plugin registry.
pub fn plugins() -> &'static [&'static PluginInfo] {
plugins::REGISTRY
@ -298,4 +341,21 @@ mod trait_default_tests {
assert!(p.editor().is_none(), "{}", info.id);
}
}
#[test]
fn only_the_sampler_takes_samples() {
for info in plugins() {
let mut p = create(info.id).unwrap();
let s: Arc<dyn SampleSource> = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] });
assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id);
assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id);
}
}
#[test]
fn sample_buffer_frames_is_the_shortest_channel() {
let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] };
assert_eq!(s.frames(), 7);
assert_eq!(SampleBuffer::default().frames(), 0);
}
}

View File

@ -164,10 +164,12 @@ fn lufs(energy: f64) -> f32 {
/// K-weighting pre-filter (high shelf) and RLB high-pass designed for any sample rate.
fn k_weighting(sr: f32) -> [Coeffs; 2] {
let sr = f64::from(sr);
let pi = std::f64::consts::PI;
// Bilinear prewarp. A corner at or past Nyquist wraps tan() negative (poles outside the unit
// circle) or aliases it, so it is held just below Nyquist, as `Coeffs::design` does.
let prewarp = |f0: f64| (std::f64::consts::PI * f0.min(sr * 0.49) / sr).tan();
// Stage 1: shelving.
let (f0, g, q) = (1_681.974_450_955_533, 3.999_843_853_973_347, 0.707_175_236_955_419_6);
let k = (pi * f0 / sr).tan();
let k = prewarp(f0);
let vh = 10f64.powf(g / 20.0);
let vb = vh.powf(0.499_666_774_154_541_6);
let a0 = 1.0 + k / q + k * k;
@ -180,7 +182,7 @@ fn k_weighting(sr: f32) -> [Coeffs; 2] {
};
// Stage 2: high-pass.
let (f0, q) = (38.135_470_876_024_44, 0.500_327_037_323_877_3);
let k = (pi * f0 / sr).tan();
let k = prewarp(f0);
let a0 = 1.0 + k / q + k * k;
let s2 = Coeffs { b0: 1.0, b1: -2.0, b2: 1.0, a1: (2.0 * (k * k - 1.0) / a0) as f32, a2: ((1.0 - k / q + k * k) / a0) as f32 };
[s1, s2]

View File

@ -341,7 +341,14 @@ pub fn pitch_shift(ch: &[Vec<f32>], semitones: f32, sample_rate: f32) -> Vec<Vec
return ch.to_vec();
}
let factor = 2f64.powf(f64::from(st) / 12.0);
let stretched = time_stretch(ch, factor, sample_rate);
// time_stretch clamps to 0.1..=10, short of the 16x (or 1/16) that ±48 needs: stretch twice by
// the square root instead.
let stretched = if (0.1..=10.0).contains(&factor) {
time_stretch(ch, factor, sample_rate)
} else {
let half = factor.sqrt();
time_stretch(&time_stretch(ch, half, sample_rate), half, sample_rate)
};
let len = max_len(ch);
let slen = max_len(&stretched);
if slen == 0 || len == 0 {
@ -443,39 +450,71 @@ pub fn detect_transients(ch: &[Vec<f32>], sample_rate: f32, sensitivity: f32) ->
/// Each region is padded by `pad_before`/`pad_after` (clamped to the buffer) and overlapping
/// regions are merged.
pub fn non_silent_ranges(ch: &[Vec<f32>], threshold_db: f32, min_len: usize, pad_before: usize, pad_after: usize) -> Vec<(usize, usize)> {
let len = max_len(ch);
let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db });
let mut raw: Vec<(usize, usize)> = Vec::new();
let mut start: Option<usize> = None;
let mut last_loud = 0usize;
for i in 0..len {
let loud = ch.iter().any(|c| c.get(i).is_some_and(|v| v.is_finite() && v.abs() > thr));
if !loud {
continue;
}
match start {
Some(s) if i - last_loud > min_len.max(1) => {
raw.push((s, last_loud + 1));
start = Some(i);
let mut scan = SilenceScan::new(threshold_db, min_len as u64);
scan.push(ch);
let at = |x: u64| usize::try_from(x).unwrap_or(usize::MAX);
scan.finish(pad_before as u64, pad_after as u64).into_iter().map(|(s, e)| (at(s), at(e))).collect()
}
/// [`non_silent_ranges`] over audio that arrives a block at a time, so material of any length can
/// be scanned without holding all of it.
pub struct SilenceScan {
thr: f32,
min_len: u64,
len: u64,
start: Option<u64>,
last_loud: u64,
raw: Vec<(u64, u64)>,
}
impl SilenceScan {
pub fn new(threshold_db: f32, min_len: u64) -> Self {
let thr = db_to_gain(if threshold_db.is_nan() { -48.0 } else { threshold_db });
SilenceScan { thr, min_len, len: 0, start: None, last_loud: 0, raw: Vec::new() }
}
/// Scan the next block (planar), which follows the audio scanned so far.
pub fn push(&mut self, ch: &[Vec<f32>]) {
let n = max_len(ch);
for k in 0..n {
if !ch.iter().any(|c| c.get(k).is_some_and(|v| v.is_finite() && v.abs() > self.thr)) {
continue;
}
None => start = Some(i),
_ => {}
let i = self.len.saturating_add(k as u64);
match self.start {
Some(s) if i - self.last_loud > self.min_len.max(1) => {
self.raw.push((s, self.last_loud.saturating_add(1)));
self.start = Some(i);
}
None => self.start = Some(i),
_ => {}
}
self.last_loud = i;
}
last_loud = i;
self.len = self.len.saturating_add(n as u64);
}
if let Some(s) = start {
raw.push((s, last_loud + 1));
/// Pass over `n` samples known to be silent.
pub fn skip(&mut self, n: u64) {
self.len = self.len.saturating_add(n);
}
let mut out: Vec<(usize, usize)> = Vec::with_capacity(raw.len());
for (s, e) in raw {
let s = s.saturating_sub(pad_before);
let e = e.saturating_add(pad_after).min(len);
match out.last_mut() {
Some(last) if s <= last.1 => last.1 = last.1.max(e),
_ => out.push((s, e)),
/// The regions found, padded and merged (see [`non_silent_ranges`]).
pub fn finish(mut self, pad_before: u64, pad_after: u64) -> Vec<(u64, u64)> {
if let Some(s) = self.start {
self.raw.push((s, self.last_loud.saturating_add(1)));
}
let mut out: Vec<(u64, u64)> = Vec::with_capacity(self.raw.len());
for (s, e) in self.raw {
let s = s.saturating_sub(pad_before);
let e = e.saturating_add(pad_after).min(self.len);
match out.last_mut() {
Some(last) if s <= last.1 => last.1 = last.1.max(e),
_ => out.push((s, e)),
}
}
out
}
out
}
/// Runs a plugin over whole buffers in blocks, compensating its latency so the output lines up

View File

@ -17,7 +17,7 @@ pub fn yin(frame: &[f32], sample_rate: f32, min_hz: f32, max_hz: f32, threshold:
}
let max_tau = ((sample_rate / min_hz.max(20.0)) as usize).min(frame.len() / 2);
let min_tau = ((sample_rate / max_hz.max(min_hz + 1.0)) as usize).max(2);
if max_tau <= min_tau + 2 {
if max_tau <= min_tau.saturating_add(2) {
return None;
}
let w = frame.len() - max_tau;
@ -140,4 +140,16 @@ mod tests {
assert!(audio_to_notes(&[], f32::NAN).is_empty());
assert!(yin(&[0.0; 10], 48_000.0, 50.0, 1600.0, 0.15).is_none());
}
#[test]
fn huge_finite_rate_is_unvoiced() {
// #241: the shortest lag saturates to usize::MAX; that is "no pitch", not an overflow.
assert!(yin(&[0.0; 64], 1.0e30, 50.0, 1600.0, 0.15).is_none());
assert!(yin(&[0.5; 8192], f32::MAX, 50.0, 1600.0, 0.15).is_none());
}
#[test]
fn audio_to_notes_survives_huge_finite_rate() {
assert!(audio_to_notes(&[0.5; 8192], 1.0e30).is_empty());
}
}

View File

@ -184,12 +184,18 @@ impl<const S: usize> Eq<S> {
}
fn update(&mut self) {
let mut snapped = false;
for (i, (s, info)) in self.sm.iter_mut().zip(self.info.params).enumerate() {
s.set(self.p.v(i));
if matches!(info.unit, Unit::Toggle | Unit::Choice) {
s.snap();
snapped |= self.vals.get(i) != Some(&s.value());
}
}
// A snapped smoother never settles, so `process` would not redesign for it.
if snapped {
self.redesign();
}
let g = db_to_gain(self.total_gain_db(|i| self.p.v(i)));
self.gain.set(g);
}

View File

@ -7,6 +7,7 @@ pub mod harmonic;
pub mod modulation;
pub mod pitch;
pub mod reverb;
pub mod sampler;
pub mod strip;
pub mod synth;
pub mod utility;
@ -41,6 +42,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&utility::DITHER_INFO,
&synth::SYNTH_INFO,
&drums::DRUM_INFO,
&sampler::SAMPLER_INFO,
];
/// Constructs an (unprepared) plugin by id.
@ -72,6 +74,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"dither" => Box::new(utility::Dither::new()),
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
"drum_synth" => Box::new(drums::DrumSynth::new()),
"sampler" => Box::new(sampler::Sampler::new()),
_ => return None,
})
}

View File

@ -316,7 +316,15 @@ impl Plugin for ModFx {
}
fn tail_samples(&self) -> usize {
ms_to_samples(200.0, self.sr) as usize
// Time for feedback to fall by 60 dB at the longest delay of the sweep, as for the delay
// above; never shorter than the 200 ms this used to report.
let longest = match self.kind {
ModKind::Chorus => self.p.v(2) + self.p.v(1) / 100.0 * 6.0,
ModKind::Flanger => self.p.v(2),
};
let fb = (self.p.v(3).abs() / 100.0).clamp(0.0, 0.99);
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
ms_to_samples((longest * repeats).max(200.0), self.sr) as usize
}
}

View File

@ -0,0 +1,817 @@
//! Sampler: plays one recorded sample across the keyboard, 32 voices.
//!
//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with
//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and
//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or
//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with
//! [`Plugin::set_sample`]; without one the Sampler is silent.
use std::sync::Arc;
use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain};
const PLAY_MODES: &[&str] = &["Gate", "One Shot"];
const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"];
const ROOT: usize = 0;
const TRANSPOSE: usize = 1;
const FINE: usize = 2;
const KEY_TRACK: usize = 3;
const KEY_LOW: usize = 4;
const KEY_HIGH: usize = 5;
const VEL_LOW: usize = 6;
const VEL_HIGH: usize = 7;
const VEL_SENS: usize = 8;
const ATTACK: usize = 9;
const DECAY: usize = 10;
const SUSTAIN: usize = 11;
const RELEASE: usize = 12;
const PLAY_MODE: usize = 13;
const START: usize = 14;
const LOOP_MODE: usize = 15;
const LOOP_START: usize = 16;
const LOOP_END: usize = 17;
const LEVEL: usize = 18;
static SAMPLER_PARAMS: [ParamInfo; 19] = [
lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None),
lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones),
lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents),
toggle("key_track", "Key Tracking", true),
lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None),
lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None),
lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None),
lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None),
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms),
log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms),
lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent),
log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms),
choice("play_mode", "Play Mode", PLAY_MODES, 0),
lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent),
choice("loop_mode", "Loop", LOOP_MODES, 0),
lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent),
lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent),
lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db),
];
pub static SAMPLER_INFO: PluginInfo = PluginInfo {
id: "sampler",
name: "Sampler",
short_name: "Sampler",
category: Category::Instrument,
params: &SAMPLER_PARAMS,
is_instrument: true,
audiosuite: false,
};
const VOICES: usize = 32;
/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded).
const MAX_STEP: f64 = 256.0;
#[derive(Debug, Clone, Copy, PartialEq)]
enum LoopMode {
Off,
Forward,
PingPong,
}
#[derive(Debug, Clone, Copy)]
struct Voice {
note: u8,
/// Key still down (gate mode only; one-shot voices are never held).
held: bool,
gain: f32,
/// Read position in sample frames.
pos: f64,
step: f64,
forward: bool,
looping: bool,
env: Adsr,
}
impl Voice {
const fn new() -> Self {
Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() }
}
fn active(&self) -> bool {
self.env.stage != Stage::Idle
}
fn stop(&mut self) {
*self = Voice::new();
}
}
/// Values derived from the parameters (refreshed on every change).
#[derive(Debug, Clone, Copy)]
struct Derived {
root: f64,
/// Transpose plus fine tune, in semitones.
offset: f64,
key_track: bool,
keys: (u8, u8),
vels: (u8, u8),
sens: f32,
one_shot: bool,
start: f64,
loop_mode: LoopMode,
/// Loop start and end as fractions of the sample (start <= end).
loop_span: (f64, f64),
}
/// A MIDI key or velocity parameter as a whole number in `lo..=127`.
fn whole(v: f32, lo: u8) -> u8 {
if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo }
}
fn ordered(a: u8, b: u8) -> (u8, u8) {
(a.min(b), a.max(b))
}
/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads
/// outside the slice are silence.
#[inline]
fn hermite(x: &[f32], pos: f64) -> f32 {
let i = pos as usize;
let t = (pos - i as f64) as f32;
let at = |k: usize| x.get(k).copied().unwrap_or(0.0);
let x0 = at(i);
if t == 0.0 {
return if x0.is_finite() { x0 } else { 0.0 };
}
let xm1 = at(i.saturating_sub(1));
let x1 = at(i.saturating_add(1));
let x2 = at(i.saturating_add(2));
let c1 = 0.5 * (x1 - xm1);
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
let y = ((c3 * t + c2) * t + c1) * t + x0;
// A float file can hold NaN or infinities; they play as silence.
if y.is_finite() { y } else { 0.0 }
}
/// Plays a sample across the keyboard.
pub struct Sampler {
p: Params,
d: Derived,
sr: f32,
ch: usize,
sample: Option<Arc<dyn SampleSource>>,
/// Frames in the sample (0 = nothing to play).
len: usize,
/// The sample's own rate.
sample_sr: f32,
voices: [Voice; VOICES],
events: EventQueue,
rates: AdsrRates,
level: Smoother,
}
impl Default for Sampler {
fn default() -> Self {
Self::new()
}
}
impl Sampler {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SAMPLER_PARAMS),
d: Derived {
root: 60.0,
offset: 0.0,
key_track: true,
keys: (0, 127),
vels: (1, 127),
sens: 0.7,
one_shot: false,
start: 0.0,
loop_mode: LoopMode::Off,
loop_span: (0.0, 1.0),
},
sr: 48_000.0,
ch: 0,
sample: None,
len: 0,
sample_sr: 48_000.0,
voices: [Voice::new(); VOICES],
events: EventQueue::new(),
rates: AdsrRates::default(),
level: Smoother::new(1.0),
};
s.update();
s
}
fn update(&mut self) {
let v = |i| self.p.v(i);
let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0);
let (ls, le) = (pct(LOOP_START), pct(LOOP_END));
self.d = Derived {
root: f64::from(whole(v(ROOT), 0)),
offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0,
key_track: self.p.on(KEY_TRACK),
keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)),
vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)),
sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0),
one_shot: self.p.choice(PLAY_MODE) == 1,
start: pct(START),
loop_mode: match self.p.choice(LOOP_MODE) {
1 => LoopMode::Forward,
2 => LoopMode::PingPong,
_ => LoopMode::Off,
},
loop_span: (ls.min(le), ls.max(le)),
};
self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr);
self.level.set(db_to_gain(v(LEVEL)));
}
/// The loop region in sample frames, when it is long enough to loop.
fn loop_frames(&self) -> Option<(f64, f64)> {
if self.d.loop_mode == LoopMode::Off {
return None;
}
let len = self.len as f64;
let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len);
(b - a >= 1.0).then_some((a, b))
}
fn start_note(&mut self, note: u8, vel: u8) {
if vel == 0 {
self.stop_note(note);
return;
}
let d = self.d;
let (note, vel) = (note.min(127), vel.min(127));
if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 {
return;
}
let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset;
let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0);
if !step.is_finite() || step <= 0.0 {
return;
}
if !d.one_shot {
// Retriggering a held key releases the earlier voice instead of stacking it.
self.stop_note(note);
}
let idx = self
.voices
.iter()
.position(|v| !v.active())
.or_else(|| {
self.voices
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
})
.unwrap_or(0);
let start = d.start * self.len.saturating_sub(1) as f64;
let looping = self.loop_frames().is_some_and(|(_, end)| start < end);
let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0;
if let Some(v) = self.voices.get_mut(idx) {
*v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() };
v.env.gate_on();
}
}
fn stop_note(&mut self, note: u8) {
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
v.held = false;
v.env.gate_off();
}
}
fn apply(&mut self, kind: EventKind) {
match kind {
EventKind::On(n, v) => self.start_note(n, v),
EventKind::Off(n) => self.stop_note(n),
EventKind::AllOff => {
for v in &mut self.voices {
v.held = false;
v.env.gate_off();
}
}
}
}
/// Adds every voice's output for frame `n` into `io` and advances the voices.
#[inline]
fn render_frame(&mut self, io: &mut [Vec<f32>], ch: usize, n: usize, level: f32) {
let Some(sample) = self.sample.as_deref() else { return };
let nch = sample.num_channels();
let len = self.len as f64;
let mode = self.d.loop_mode;
let region = self.loop_frames();
for v in &mut self.voices {
if !v.active() {
continue;
}
let a = v.env.next(&self.rates) * v.gain * level;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let src = if nch == 1 { 0 } else { c };
if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) {
*x += hermite(data, v.pos) * a;
}
}
let r = if v.looping { region } else { None };
advance(v, len, r, mode);
}
}
}
/// Moves a voice's read position one output frame on, looping or ending it.
#[inline]
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
match region {
Some((ls, le)) if mode == LoopMode::PingPong => {
if v.forward && v.pos >= le {
v.pos = (le - (v.pos - le)).max(ls);
v.forward = false;
} else if !v.forward && v.pos <= ls {
v.pos = (ls + (ls - v.pos)).min(le);
v.forward = true;
}
}
Some((ls, le)) => {
if v.pos >= le {
v.pos = ls + (v.pos - ls) % (le - ls);
}
}
None => {
if v.pos >= len || !v.pos.is_finite() {
v.stop();
}
}
}
if !v.pos.is_finite() || v.pos < 0.0 {
v.stop();
}
}
impl Plugin for Sampler {
param_plumbing!(SAMPLER_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = sane_channels(channels);
self.level.set_time(20.0, self.sr);
self.update();
self.reset();
}
fn reset(&mut self) {
for v in &mut self.voices {
v.stop();
}
self.events.clear();
self.level.snap();
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.min(io.len());
let frames = frames_in(io, frames, ch);
for b in io.iter_mut().take(ch) {
b.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
let count = {
let sorted = self.events.sorted();
for (d, s) in evs.iter_mut().zip(sorted) {
*d = *s;
}
sorted.len().min(MAX_EVENTS)
};
self.events.clear();
let mut next = 0;
for n in 0..frames {
while let Some(e) = evs.get(next).filter(|_| next < count) {
if e.offset > n && n + 1 < frames {
break;
}
let kind = e.kind;
self.apply(kind);
next += 1;
}
let level = self.level.next();
self.render_frame(io, ch, n, level);
}
// Events for an empty block take effect immediately.
for e in evs.iter().take(count).skip(next) {
self.apply(e.kind);
}
}
fn tail_samples(&self) -> usize {
let release = ms_to_samples(self.p.v(RELEASE), self.sr);
// A one-shot keeps playing its sample after the note ends.
let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 };
let t = release + rest;
if t.is_finite() { t.min(1.0e9) as usize } else { 0 }
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
self.events.push(offset, EventKind::On(note, velocity));
}
fn note_off(&mut self, offset: usize, note: u8) {
self.events.push(offset, EventKind::Off(note));
}
fn all_notes_off(&mut self) {
self.events.push(0, EventKind::AllOff);
}
fn set_sample(&mut self, sample: Option<Arc<dyn SampleSource>>) -> bool {
for v in &mut self.voices {
v.stop();
}
self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() });
let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate());
// An unusable rate plays at the host rate rather than not at all.
self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr };
self.sample = sample;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SampleBuffer;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
fn sampler(sample: SampleBuffer) -> Sampler {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
assert!(s.set_sample(Some(Arc::new(sample))));
s
}
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
SampleBuffer { sample_rate, channels: vec![data] }
}
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
}
/// Renders `len` frames in 512-frame blocks; returns both channels.
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
let mut out = [Vec::new(), Vec::new()];
let mut buf = vec![vec![1.0f32; 512]; 2];
let mut done = 0;
while done < len {
let n = 512.min(len - done);
s.process(&mut buf, n);
out[0].extend_from_slice(&buf[0][..n]);
out[1].extend_from_slice(&buf[1][..n]);
done += n;
}
out
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
/// Frequency from upward zero crossings.
fn freq(x: &[f32], sr: f32) -> f32 {
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
(ups.len() - 1) as f32 * sr / (b - a) as f32
}
#[test]
fn silent_without_a_sample_and_replaces_the_buffer() {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
s.note_on(0, 60, 127);
let out = run(&mut s, 4800);
assert_eq!(peak(&out[0]), 0.0);
assert_eq!(peak(&out[1]), 0.0);
}
#[test]
fn root_note_plays_the_sample_verbatim() {
let data = sine(440.0, SR, 9600);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.note_on(0, 60, 100);
let out = run(&mut s, 9600);
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
for i in 100..9600 {
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
}
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
}
#[test]
fn keys_transpose_from_the_root_note() {
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
s.note_on(0, 72, 127);
let up = run(&mut s, 24_000);
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
s.reset();
s.note_on(0, 53, 127);
let down = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
s.reset();
s.set_param("root_note", 48.0);
s.set_param("transpose", -12.0);
s.set_param("fine_tune", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(1.0 / 12.0);
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
}
#[test]
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
s.set_param("key_track", 0.0);
s.note_on(0, 90, 127);
let out = run(&mut s, 24_000);
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
}
#[test]
fn converts_the_sample_rate() {
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
s.note_on(0, 60, 127);
let out = run(&mut s, 60_000);
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
// And it lasts as long as the recording: one second.
assert!(peak(&out[0][46_000..47_500]) > 0.1);
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
// A 96 kHz recording is read faster, not cut short.
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
}
#[test]
fn key_and_velocity_ranges_gate_notes() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("key_low", 48.0);
s.set_param("key_high", 60.0);
s.set_param("vel_low", 40.0);
s.set_param("vel_high", 100.0);
for (note, vel, sounds) in
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
{
s.reset();
s.note_on(0, note, vel);
let out = run(&mut s, 960);
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
}
// Reversed ranges are read in order rather than muting everything.
s.set_param("key_low", 60.0);
s.set_param("key_high", 48.0);
s.reset();
s.note_on(0, 50, 80);
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
}
#[test]
fn velocity_sensitivity_scales_level() {
let level = |sens: f32, vel: u8| {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("velocity", sens);
s.note_on(0, 60, vel);
peak(&run(&mut s, 2400)[0][1000..])
};
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
assert!(level(70.0, 30) < level(70.0, 127));
}
#[test]
fn adsr_shapes_the_note() {
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
s.set_param("velocity", 0.0);
s.set_param("attack", 100.0);
s.set_param("decay", 100.0);
s.set_param("sustain", 50.0);
s.set_param("release", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
assert!(peak(&out[0][4700..4900]) > 0.49);
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
s.note_off(0, 60);
let out = run(&mut s, 24_000);
assert!(out[0][100] > 0.2, "release starts from the sustain level");
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
}
#[test]
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 24_000);
assert!(peak(&out[0][..900]) > 0.1);
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 30_000);
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
// Stopping the transport still silences one-shots.
s.reset();
s.note_on(0, 60, 127);
run(&mut s, 512);
s.all_notes_off();
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
}
#[test]
fn retriggering_a_held_key_releases_the_old_voice() {
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
s.set_param("velocity", 0.0);
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
// One-shots overlap.
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
}
#[test]
fn forward_loop_sustains_past_the_end() {
let data = sine(480.0, SR, 4800);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
for i in 0..2400 {
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
}
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
// Without the loop the note ends with the sample.
s.set_param("loop_mode", 0.0);
s.reset();
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
}
#[test]
fn ping_pong_loop_reverses_direction() {
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 2.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 3000);
assert!(out[0][900] > out[0][500], "rising");
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
}
#[test]
fn a_loop_too_short_to_loop_plays_once() {
let mut s = sampler(mono(SR, vec![0.5; 1000]));
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 40.0);
s.set_param("loop_end", 40.0);
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
}
#[test]
fn sample_start_skips_into_the_sample() {
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("attack", 0.1);
s.set_param("start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2000);
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
}
#[test]
fn stereo_samples_keep_their_sides() {
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
let mut s = sampler(sample);
s.set_param("velocity", 0.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2400);
assert!((out[0][1000] - 0.5).abs() < 1e-6);
assert!((out[1][1000] + 0.25).abs() < 1e-6);
}
#[test]
fn note_offsets_are_sample_accurate() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.note_on(300, 60, 127);
let out = run(&mut s, 512);
assert!(out[0][..300].iter().all(|&v| v == 0.0));
assert!(out[0][301..].iter().any(|&v| v != 0.0));
}
#[test]
fn many_notes_steal_voices_and_stay_bounded() {
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
s.set_param("level", 12.0);
for n in 0..100u8 {
s.note_on(usize::from(n), n, 127);
}
let out = run(&mut s, 9600);
assert!(out[0].iter().all(|v| v.is_finite()));
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
}
#[test]
fn a_new_sample_stops_the_old_voices() {
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
s.note_on(0, 60, 127);
run(&mut s, 512);
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
assert!(s.set_sample(None));
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
}
#[test]
fn hostile_samples_and_params_never_panic() {
let samples = [
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: SR, channels: vec![] },
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
];
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
for sample in samples {
let mut s = Sampler::new();
s.prepare(f32::NAN, 0, 99);
s.set_sample(Some(Arc::new(sample)));
for (id, v) in &params {
assert!(s.set_param(id, *v));
for n in [0u8, 60, 127, 255] {
s.note_on(0, n, 255);
s.note_on(7, n, 1);
}
let mut io = vec![vec![0.0f32; 64]; 3];
s.process(&mut io, 1000);
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
s.process(&mut [], 64);
let _ = s.tail_samples();
}
}
// Loops with every mode at the extremes of pitch.
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
for mode in [0.0, 1.0, 2.0] {
s.set_param("loop_mode", mode);
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
s.set_param("transpose", t);
s.set_param("root_note", root);
s.note_on(0, 127, 127);
s.note_on(0, 0, 127);
let out = run(&mut s, 4096);
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
}
}
}
}

View File

@ -97,7 +97,7 @@ const VOICES: usize = 16;
const SILENT: f32 = 1.0e-5;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Stage {
pub(crate) enum Stage {
Idle,
Attack,
Decay,
@ -107,13 +107,13 @@ enum Stage {
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
#[derive(Debug, Clone, Copy)]
struct Adsr {
stage: Stage,
level: f32,
pub(crate) struct Adsr {
pub(crate) stage: Stage,
pub(crate) level: f32,
}
#[derive(Debug, Clone, Copy, Default)]
struct AdsrRates {
pub(crate) struct AdsrRates {
attack: f32,
decay: f32,
sustain: f32,
@ -121,29 +121,29 @@ struct AdsrRates {
}
impl AdsrRates {
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
}
}
impl Adsr {
const fn new() -> Self {
pub(crate) const fn new() -> Self {
Self { stage: Stage::Idle, level: 0.0 }
}
fn gate_on(&mut self) {
pub(crate) fn gate_on(&mut self) {
self.stage = Stage::Attack;
}
fn gate_off(&mut self) {
pub(crate) fn gate_off(&mut self) {
if self.stage != Stage::Idle {
self.stage = Stage::Release;
}
}
#[inline]
fn next(&mut self, r: &AdsrRates) -> f32 {
pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 {
match self.stage {
Stage::Idle => self.level = 0.0,
Stage::Attack => {

View File

@ -65,6 +65,21 @@ fn loudness_full_scale_mono_and_44k() {
}
}
#[test]
fn loudness_stays_bounded_at_low_sample_rates() {
// The K-weighting shelf corner (~1.7 kHz) sits at or above Nyquist below ~3.4 kHz; the
// filter must stay stable there instead of reading hundreds of LUFS (issue #240).
for sr in [1000.0f32, 1200.0, 1600.0, 2000.0, 3000.0, 3400.0, 8000.0, 48_000.0] {
let len = sr as usize;
let mut m = LoudnessMeter::new(sr, 1);
assert_eq!(m.sample_rate(), sr);
m.process(&[sine(100.0, 0.5, sr, len)], len);
for level in [m.integrated_lufs(), m.momentary_lufs(), m.short_term_lufs()] {
assert!(level.is_finite() && (-30.0..=0.0).contains(&level), "{sr}: {level}");
}
}
}
#[test]
fn loudness_gating_ignores_silence() {
let sr = 48_000.0;
@ -155,6 +170,19 @@ fn pitch_shift_preserves_length_and_shifts() {
assert!((f - want).abs() < 4.0, "{f} vs {want}");
}
#[test]
fn pitch_shift_reaches_the_ends_of_its_range() {
let sr = 48_000.0;
let x = sine(440.0, 0.5, sr, 48_000);
for st in [40.0f32, 48.0, -40.0, -48.0] {
let out = offline::pitch_shift(std::slice::from_ref(&x), st, sr);
assert_eq!(out[0].len(), x.len());
let f = dominant_hz(&out[0], sr);
let want = 440.0 * 2f32.powf(st / 12.0);
assert!((f / want - 1.0).abs() < 0.002, "{st} semitones: {f} vs {want}");
}
}
#[test]
fn normalize_hits_target_peak() {
let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)];
@ -200,6 +228,26 @@ fn non_silent_ranges_finds_bursts() {
assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty());
}
#[test]
fn silence_scan_in_blocks_finds_what_one_pass_finds() {
let mut x = vec![0.0f32; 48_000];
for (i, v) in sine(500.0, 0.5, 48_000.0, 4800).into_iter().enumerate() {
x[10_000 + i] = v;
x[30_000 + i] = v;
}
let whole: Vec<(u64, u64)> =
offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200).into_iter().map(|(a, b)| (a as u64, b as u64)).collect();
let mut blocks = offline::SilenceScan::new(-40.0, 480);
x.chunks(1000).for_each(|b| blocks.push(&[b.to_vec()]));
assert_eq!(blocks.finish(100, 200), whole);
// Skipping the silence between the bursts counts its length like scanning it.
let mut skipped = offline::SilenceScan::new(-40.0, 480);
skipped.push(&[x[..20_000].to_vec()]);
skipped.skip(10_000);
skipped.push(&[x[30_000..].to_vec()]);
assert_eq!(skipped.finish(100, 200), whole);
}
#[test]
fn transients_detected_near_onsets() {
let sr = 48_000.0;

View File

@ -199,6 +199,44 @@ fn eq_magnitude_at_center_matches_gain() {
assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}");
}
#[test]
fn eq_switches_and_choices_apply_to_a_running_eq() {
// (plugin, settings, the one switch or choice changed while running, test tone)
let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0),
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0),
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0),
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0),
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0),
("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0),
("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0),
];
let mut stale = Vec::new();
for (id, setup, (key, value), tone) in cases {
let x = [sine(tone, 0.5, 24_000)];
let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..]));
let build = |settings: &[(&str, f32)]| {
let mut p = create(id).unwrap();
p.prepare(SR, BLOCK, 1);
for &(k, v) in settings {
assert!(p.set_param(k, v), "{id} {k}");
}
p.reset();
p
};
let mut live = build(setup);
let before = level(live.as_mut());
assert!(live.set_param(key, value));
let after = level(live.as_mut());
let want = level(build(&[setup, &[(key, value)]].concat()).as_mut());
assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart");
if (after - want).abs() >= 0.1 {
stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB"));
}
}
assert!(stale.is_empty(), "{stale:#?}");
}
#[test]
fn compressor_steady_state_gain_reduction() {
let mut p = prepared("compressor");
@ -329,6 +367,23 @@ fn delay_repeats_at_time() {
assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}");
}
#[test]
fn modulation_tail_covers_feedback_echoes() {
for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] {
let mut p = prepared(id);
for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] {
assert!(p.set_param(param, v));
}
p.reset();
let mut x = vec![0.0f32; 48_000 * 5];
x[0] = 0.5;
let out = run(p.as_mut(), &[x]);
let floor = peak(&out[0]) * 1e-3;
let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap();
assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples());
}
}
#[test]
fn trim_and_invert() {
let mut p = prepared("invert");

View File

@ -22,6 +22,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
zip.workspace = true
[dev-dependencies]
proptest.workspace = true

View File

@ -31,6 +31,9 @@ struct GroupFile {
/// Write the clips `ids` (grouped by track) to `path`. Returns the number of clips written.
pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
let p = with_extension(path);
// First, so a refused group file leaves nothing behind and is refused whatever else is wrong.
crate::file_access::check_write(&p).map_err(EngineError::Io)?;
let s = e.session();
let mut lanes: Vec<(usize, Vec<Clip>)> = Vec::new();
for id in ids {
@ -47,7 +50,6 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
};
let n = clips.len();
lanes.sort_by_key(|(i, _)| *i);
let p = with_extension(path);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string();
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
@ -62,10 +64,10 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
&& let (Some(buf), Some(src)) = (s.pool.get(source), s.sources.iter().find(|x| x.id == source))
{
let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0);
std::fs::create_dir_all(dir.join(&audio_dir_name)).map_err(|err| EngineError::Io(format!("{audio_dir_name}: {err}")))?;
crate::io::make_dir(dir.join(&audio_dir_name))?;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(dir.join(&rel), bytes).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
crate::io::write_path(dir.join(&rel), bytes)?;
slot.insert(rel);
}
lane.push(c);
@ -74,7 +76,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
}
let file = GroupFile { format: FORMAT.into(), version: 1, sample_rate: s.sample_rate.hz(), length: end - start, lanes: out_lanes, audio };
let text = serde_json::to_string_pretty(&file).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&p, text).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
crate::io::write_path(&p, text)?;
Ok(n)
}
@ -82,7 +84,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
@ -98,7 +100,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
if !full.starts_with(&dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}")));
}
let bytes = std::fs::read(&full).map_err(|err| EngineError::Io(format!("{}: {err}", full.display())))?;
let bytes = crate::io::read_file(&full)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
decoded.insert(*old, (rel.clone(), buf));
}

View File

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

View File

@ -74,7 +74,7 @@ pub fn specs() -> Vec<CommandSpec> {
let ids = clip_ids_param(e, p);
let mut n = 0;
for id in ids {
if crate::io::render_clip_gain(e, id)? {
if crate::io::render_clip_gain(e, "clip.gain_render", id)? {
n += 1
}
}
@ -185,10 +185,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");
// Start from the unwarped original if this clip was warped before.
let key = |k: &str| format!("elastic.{}.{k}", id.0);
let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) {
(Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)),
_ => None,
};
let orig = warp_original(s, *id);
let mut base = c.clone();
if let Some((src, off, len)) = orig {
base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
@ -202,9 +199,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
continue;
}
let out_len = soundcraft_time::to_samples(base.length as f64 * ratio).max(1);
let mut warp = base.clone();
warp.stretch = ratio;
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &warp, out_len)? else { continue };
let Some(src) = super::more_util::render_stretched(s, "clip.elastic_properties", &base, out_len)? else { continue };
if orig.is_none()
&& let soundcraft_model::ClipContent::Audio { source, offset } = base.content
{
@ -229,12 +224,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
let mut n = 0;
for id in &ids {
let key = |k: &str| format!("elastic.{}.{k}", id.0);
let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied());
let orig = warp_original(s, *id);
if let Some(c) = s.find_clip_mut(*id) {
c.stretch = 1.0;
if let (Some(src), Some(off), Some(len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 };
c.length = (len as i64).max(1);
if let Some((src, off, len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
c.length = len.max(1);
c.fade_out.len = c.fade_out.len.min(c.length);
n += 1;
}
@ -246,6 +241,14 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"restored": n}))
}
/// The audio a warped clip was rendered from, while that file is still in the session.
fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> {
let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied();
let src = soundcraft_model::SourceId(v("src")? as u64);
s.source(src)?;
Some((src, v("off")? as i64, v("len")? as i64))
}
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
@ -283,8 +286,15 @@ fn loop_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut made = 0;
for id in ids {
let Some((tid, c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
let Some((tid, mut c)) = s.find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
let reps = total.map_or(count - 1, |l| (l / c.length.max(1)).max(1) - 1).clamp(0, 1000);
if reps > 0 && c.loop_id.is_none() {
let lid = s.alloc();
if let Some(src) = s.find_clip_mut(id) {
src.loop_id = Some(lid);
}
c.loop_id = Some(lid);
}
let mut copies = Vec::new();
for k in 1..=reps {
let mut nc = c.clone();
@ -373,6 +383,59 @@ 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();
let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap();
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
let clip = e.session().track(pad).unwrap().clips()[0].clone();
e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap();
e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap();
let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap();
assert_eq!(r["restored"], 0);
let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap();
assert!(e.session().source(src).is_some());
}
#[test]
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
let mut e = crate::demo::demo_engine();

View File

@ -0,0 +1,160 @@
//! Clip List commands.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::SourceId;
use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"clip.clear",
"Clear Clips",
[],
Some("Cmd+Shift+B"),
"{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too",
has_list_selection,
clear
)]
}
fn has_list_selection(e: &Engine) -> std::result::Result<(), String> {
let ed = &e.session().edit;
if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) }
}
fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose()
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let (clips, sources): (BTreeSet<ClipId>, BTreeSet<SourceId>) = match (ids(p, "clips")?, ids(p, "sources")?) {
(None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()),
(c, f) => (c.unwrap_or_default(), f.unwrap_or_default()),
};
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
return Err(bad(CLEAR, format!("no clip with id {c}")));
}
let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::<Result<Vec<_>>>()?;
if clips.is_empty() && files.is_empty() {
return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List"));
}
if !bool_or(p, "with_clips", false)
&& let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0)
{
let plural = if n == 1 { "" } else { "s" };
return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name)));
}
let gone: BTreeSet<ClipId> = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect();
let s = e.session_mut();
for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) {
pl.clips.retain(|c| !gone.contains(&c.id));
}
s.sources.retain(|f| !sources.contains(&f.id));
for f in &sources {
s.pool.remove(*f);
}
s.edit.selected_clips.retain(|c| !gone.contains(c));
s.edit.selected_sources.retain(|f| !sources.contains(f));
Ok(json!({"clips": gone.len(), "sources": sources.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
let s = e.session();
let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap();
(f, s.clips_using(f).map(|c| c.id).collect())
}
#[test]
fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() {
let mut e = crate::demo::demo_engine();
let (f, clips) = kick(&e);
let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap();
assert_eq!(r, json!({"clips": 1, "sources": 0}));
assert!(e.session().find_clip(clips[0]).is_none());
assert!(e.session().source(f).is_some());
assert!(e.undo());
assert!(e.session().find_clip(clips[0]).is_some());
}
#[test]
fn a_file_in_use_stays_unless_its_clips_go_too() {
let mut e = crate::demo::demo_engine();
e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap();
let (f, clips) = kick(&e);
assert_eq!(clips.len(), 6);
let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err();
assert!(err.to_string().contains("used by 6 clips"), "{err}");
let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap();
assert_eq!(r, json!({"clips": 6, "sources": 1}));
let s = e.session();
assert!(s.source(f).is_none() && !s.pool.contains(f));
assert_eq!(s.clips_using(f).count(), 0);
assert_eq!(e.undo_label(), Some("Clear Clips"));
assert!(e.undo());
assert!(e.session().pool.contains(f));
assert_eq!(e.session().clips_using(f).count(), 6);
}
#[test]
fn listing_a_files_clips_too_is_enough() {
let mut e = crate::demo::demo_engine();
let (f, clips) = kick(&e);
let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap();
assert_eq!(r, json!({"clips": 3, "sources": 1}));
}
#[test]
fn the_clip_list_selection_is_the_default_target() {
let mut e = crate::demo::demo_engine();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] };
let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav);
let (_, clips) = kick(&e);
e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap();
assert_eq!(r["sources"], json!([f]));
assert_eq!(r["clips"], json!([]));
let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
assert_eq!(r["sources"], json!([]));
e.execute("clip.clear", &json!({})).unwrap();
assert!(e.session().find_clip(clips[0]).is_none());
assert!(e.session().edit.selected_clips.is_empty());
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
e.execute("clip.clear", &json!({})).unwrap();
assert!(e.session().source(f).is_none());
assert!(e.session().edit.selected_sources.is_empty());
}
#[test]
fn bad_targets_are_errors() {
let mut e = crate::demo::demo_engine();
for p in [
json!({}),
json!({"clips": []}),
json!({"clips": [999_999]}),
json!({"sources": [999_999]}),
json!({"sources": "Kick"}),
json!({"clips": [-1]}),
] {
assert!(e.execute("clip.clear", &p).is_err(), "{p}");
}
}
#[test]
fn deselect_all_clears_the_clip_list_selection() {
let mut e = crate::demo::demo_engine();
let (f, _) = kick(&e);
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.session().edit.selected_sources.is_empty());
}
}

View File

@ -101,12 +101,13 @@ fn regroup(e: &mut Engine, p: &Value) -> Result<Value> {
fn same_material(a: &Clip, b: &Clip) -> bool {
a.length == b.length
&& match (&a.content, &b.content) {
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
_ => false,
}
&& (a.loop_id.is_some() && a.loop_id == b.loop_id
|| match (&a.content, &b.content) {
(ClipContent::Audio { source: s1, offset: o1 }, ClipContent::Audio { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
(ClipContent::Midi { sequence: q1 }, ClipContent::Midi { sequence: q2 }) => q1 == q2,
(ClipContent::Video { source: s1, offset: o1 }, ClipContent::Video { source: s2, offset: o2 }) => s1 == s2 && o1 == o2,
_ => false,
})
}
/// The loop a clip belongs to: back-to-back copies of the same material. Origin first.
@ -160,6 +161,7 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
} else {
for c in pl.clips.iter_mut().filter(|c| chain.contains(&c.id)) {
c.group = None;
c.loop_id = None;
}
}
}
@ -204,7 +206,7 @@ fn effects_render(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let mut n = 0;
for id in &ids {
if crate::io::render_clip_gain(e, *id)? {
if crate::io::render_clip_gain(e, "clip.effects_render", *id)? {
n += 1;
}
let s = e.session_mut();
@ -304,6 +306,42 @@ mod tests {
assert!(e.execute("clip.unloop", &json!({"clip": ids[3].0})).is_err());
}
/// A 100-sample MIDI clip with one note, on a fresh instrument-less MIDI track.
fn midi_engine() -> (Engine, TrackId, ClipId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Midi, ChannelFormat::Mono, None);
let id = s.new_clip_id();
let mut seq = soundcraft_midi::Sequence::default();
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 240 });
crate::edit::place_clip(&mut s, t, Clip::midi(id, "m", 0, 100, seq));
s.edit.selected_tracks = vec![t];
(Engine::new(s), t, id)
}
#[test]
fn unloop_recognises_loops_after_source_edit() {
let (mut e, t, id) = midi_engine();
assert_eq!(e.execute("clip.loop", &json!({"clip": id.0, "count": 4})).unwrap()["loops"], 3);
// Editing the source note makes the copies' content differ from it.
if let Some(c) = e.session_mut().find_clip_mut(id)
&& let ClipContent::Midi { sequence } = &mut c.content
{
sequence.notes[0].length = 1100;
}
e.execute("clip.unloop", &json!({"clip": id.0})).unwrap();
let left: Vec<ClipId> = e.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
assert_eq!(left, vec![id]);
}
#[test]
fn unloop_flatten_turns_loop_into_independent_clips() {
let (mut e, t, id) = midi_engine();
e.execute("clip.loop", &json!({"clip": id.0, "count": 3})).unwrap();
e.execute("clip.unloop", &json!({"clip": id.0, "mode": "flatten"})).unwrap();
assert_eq!(e.session().track(t).unwrap().clips().len(), 3);
assert!(e.session().track(t).unwrap().clips().iter().all(|c| c.loop_id.is_none() && c.group.is_none()));
}
#[test]
fn regroup_reuses_group() {
let (mut e, _, ids) = engine_with(&[(0, 100, 0), (500, 100, 0), (900, 100, 0)]);

View File

@ -10,16 +10,15 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "edit.undo", "Undo", ["Edit"], Some("Cmd+Z"), "{}", can_undo, |e, _| Ok(json!({"undone": e.undo()}))),
cmd!(noundo "edit.redo", "Redo", ["Edit"], Some("Cmd+Shift+Z"), "{}", can_redo, |e, _| Ok(json!({"redone": e.redo()}))),
cmd!("edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", always, restore_selection),
cmd!(noundo "edit.restore_last_selection", "Restore Last Selection", ["Edit"], Some("Cmd+Alt+Z"), "{}", has_last_selection, restore_selection),
cmd!("edit.cut", "Cut", ["Edit"], Some("Cmd+X"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, true, "edit.cut")),
cmd!(noundo "edit.copy", "Copy", ["Edit"], Some("Cmd+C"), "{tracks?, start?, end?}", has_range, |e, p| cut_copy(e, p, false, "edit.copy")),
cmd!("edit.paste", "Paste", ["Edit"], Some("Cmd+V"), "{tracks?, at?}", has_clipboard, paste),
cmd!("edit.clear", "Clear", ["Edit"], Some("Cmd+B"), "{tracks?, start?, end?}", has_range, clear),
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| reset_clip_gain(
e,
p,
"edit.cut_clip_gain"
)),
cmd!("edit.cut_clip_gain", "Clip Gain", ["Edit", "Cut Special"], Some("Cmd+Ctrl+X"), "{}", has_range, |e, p| {
super::edit_more::copy_clip_gain(e, p)?;
reset_clip_gain(e, p, "edit.cut_clip_gain")
}),
cmd!("edit.clear_clip_gain", "Clip Gain", ["Edit", "Clear Special"], Some("Cmd+Ctrl+B"), "{}", has_range, |e, p| reset_clip_gain(
e,
p,
@ -49,11 +48,12 @@ pub fn specs() -> Vec<CommandSpec> {
repeat_to_fill
),
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
let s = e.session_mut();
s.edit.selected_tracks.clear();
s.edit.selected_clips.clear();
s.edit.selected_sources.clear();
s.edit.selection.end = s.edit.selection.start;
Ok(json!({}))
}),
@ -102,7 +102,15 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.trim_to_file_boundaries", "To File Boundaries", ["Edit", "Trim"], None, "{clips?}", has_selection, trim_to_file),
cmd!("edit.trim_to_fill_selection", "To Fill Selection", ["Edit", "Trim"], None, "{}", has_range, trim_to_fill),
cmd!("edit.mute_clips", "Mute", ["Edit"], Some("Cmd+M"), "{clips?}", has_selection, mute_clips),
cmd!("edit.copy_to_new_playlist", "New Playlist", ["Edit", "Copy Selection to..."], None, "{}", has_range, copy_to_new_playlist),
cmd!(
"edit.copy_to_new_playlist",
"New Playlist",
["Edit", "Copy Selection to..."],
Some("Alt+Shift+Down"),
"{}",
has_range,
copy_to_new_playlist
),
cmd!(
"edit.strip_silence",
"Strip Silence",
@ -124,7 +132,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("edit.fades_delete", "Delete", ["Edit", "Fades"], None, "{tracks?, start?, end?}", has_selection, fades_delete),
cmd!("edit.fade_to_start", "Fade to Start", ["Edit", "Fades"], Some("Cmd+Ctrl+D"), "{}", has_selection, |e, p| fade_to(e, p, true)),
cmd!("edit.fade_to_end", "Fade to End", ["Edit", "Fades"], Some("Cmd+Ctrl+G"), "{}", has_selection, |e, p| fade_to(e, p, false)),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n}", has_selection, nudge),
cmd!("edit.nudge", "Nudge", [], Some("Plus/Minus"), "{direction: 1|-1, count?: n, next?: bool (next larger value)}", has_selection, nudge),
cmd!(noundo "edit.mode", "Edit Mode", [], Some("F1-F4"), "{mode: shuffle|slip|spot|grid|relative_grid}", always, |e, p| {
let m = str_param(p, "mode").and_then(EditMode::from_id).ok_or_else(|| bad("edit.mode", "mode must be shuffle|slip|spot|grid|relative_grid"))?;
e.session_mut().edit.edit_mode = m;
@ -152,8 +160,28 @@ fn shuffle(s: &Session) -> bool {
s.edit.edit_mode == EditMode::Shuffle
}
fn has_last_selection(e: &Engine) -> std::result::Result<(), String> {
if e.last_selection.is_some() { Ok(()) } else { Err("no previous selection to restore".into()) }
}
/// Remember the selection `before` had when a command changed it, for Restore Last Selection.
pub(crate) fn remember_selection(e: &mut Engine, before: &Session) {
let (old, new) = (&before.edit, &e.session().edit);
if old.selection == new.selection && old.selected_tracks == new.selected_tracks {
return;
}
e.last_selection = Some((old.selection, old.selected_tracks.clone()));
}
/// Swaps in the previous selection; `remember_selection` then keeps the one replaced, so
/// invoking it again goes back.
fn restore_selection(e: &mut Engine, _: &Value) -> Result<Value> {
Ok(json!({"selection": e.session().edit.selection}))
let (range, mut tracks) = e.last_selection.clone().ok_or_else(|| bad("edit.restore_last_selection", "no previous selection to restore"))?;
tracks.retain(|t| e.session().track(*t).is_some());
let ed = &mut e.session_mut().edit;
ed.selection = range;
ed.selected_tracks = tracks;
Ok(json!({"selection": range}))
}
fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
@ -351,8 +379,12 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let en = position_param(e, "edit.select", p, "end")?;
let clips =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
let sources =
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
let exact = bool_or(p, "exact", false);
let s = e.session_mut();
let s = e.session();
let mut ed = s.edit.clone();
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
@ -366,11 +398,11 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
s.edit.selected_tracks = t;
ed.selected_tracks = t;
}
match (st, en) {
(Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)),
(Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)),
_ => {}
}
if let Some(c) = clips {
@ -386,17 +418,25 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
if let Some(r) = range {
s.edit.selection = r;
s.edit.selected_tracks = trs;
ed.selection = r;
ed.selected_tracks = trs;
}
s.edit.selected_clips = c;
} else if st.is_some() {
s.edit.selected_clips.clear();
ed.selected_clips = c;
} else if list_changes {
ed.selected_clips.clear();
}
if s.edit.link_timeline_edit {
s.edit.timeline_selection = s.edit.selection;
if list_changes {
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
}
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}))
if ed.link_timeline_edit {
ed.timeline_selection = ed.selection;
}
let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources});
if ed != s.edit {
e.session_mut().edit = ed;
}
Ok(out)
}
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
@ -416,20 +456,25 @@ fn scale_selection(e: &mut Engine, f: f64) -> Result<Value> {
Ok(json!({"selection": s.edit.selection}))
}
/// Adds the nearest track above (`dir` < 0) or below the selected ones that the Edit window shows.
fn extend_tracks(e: &mut Engine, dir: i64) -> Result<Value> {
let s = e.session_mut();
let s = e.session();
let shown = |t: &&soundcraft_model::Track| !t.hidden && t.folder.and_then(|f| s.track(f)).is_none_or(|f| f.folder_open);
let idxs: Vec<usize> = s.edit.selected_tracks.iter().filter_map(|t| s.track_index(*t)).collect();
let next = if dir < 0 { idxs.iter().min().and_then(|i| i.checked_sub(1)) } else { idxs.iter().max().map(|i| i + 1) };
if let Some(t) = next.and_then(|i| s.tracks.get(i)).map(|t| t.id) {
let next = match (idxs.iter().min(), idxs.iter().max()) {
(Some(&top), _) if dir < 0 => s.tracks.iter().take(top).rev().find(shown),
(_, Some(&bottom)) if dir > 0 => s.tracks.iter().skip(bottom + 1).find(shown),
(None, _) => s.tracks.iter().find(shown),
_ => None,
}
.map(|t| t.id);
let s = e.session_mut();
if let Some(t) = next {
if dir < 0 {
s.edit.selected_tracks.insert(0, t);
} else {
s.edit.selected_tracks.push(t);
}
} else if idxs.is_empty()
&& let Some(t) = s.tracks.first()
{
s.edit.selected_tracks.push(t.id);
}
Ok(json!({"tracks": s.edit.selected_tracks}))
}
@ -578,13 +623,13 @@ fn trim_to_file(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut n = 0;
for id in ids {
let frames =
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
if let Some((frames, lo, hi)) = trim_limits(s, id)
&& let Some(c) = s.find_clip_mut(id)
{
let off = c.source_offset();
let new_start = c.start - off;
c.trim_start_to(new_start);
c.trim_end_to(new_start + frames);
c.trim_start_to(new_start.max(lo));
c.trim_end_to((new_start + frames).min(hi));
n += 1;
}
}
@ -597,18 +642,31 @@ fn trim_to_fill(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut n = 0;
for id in ids {
let frames =
s.find_clip(id).and_then(|(_, c)| c.source()).and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX));
if let (Some(frames), Some(c)) = (frames, s.find_clip_mut(id)) {
if let Some((frames, lo, hi)) = trim_limits(s, id)
&& let Some(c) = s.find_clip_mut(id)
{
let file_start = c.start - c.source_offset();
c.trim_start_to(r.start.max(file_start));
c.trim_end_to(r.end.min(file_start + frames));
c.trim_start_to(r.start.max(file_start).max(lo));
c.trim_end_to(r.end.min(file_start + frames).min(hi));
n += 1;
}
}
Ok(json!({"trimmed": n}))
}
/// How far a trim may extend an audio clip: its source length in frames, and the earliest
/// start and latest end before it would run over the clip before or after it on the playlist
/// (an extension never overwrites, like the trim tool). An overlap the clip already has with
/// a neighbour (a crossfade) is kept rather than trimmed away.
fn trim_limits(s: &Session, id: ClipId) -> Option<(Samples, Samples, Samples)> {
let (track, c) = s.find_clip(id)?;
let frames = c.source().and_then(|src| s.source(src)).map(|src| i64::try_from(src.frames).unwrap_or(i64::MAX))?;
let others = || s.track(track).into_iter().flat_map(|t| t.clips()).filter(|x| x.id != id);
let lo = others().filter(|x| x.start < c.start).map(|x| x.end()).max().map_or(Samples::MIN, |end| end.min(c.start));
let hi = others().filter(|x| x.start > c.start).map(|x| x.start).min().map_or(Samples::MAX, |start| start.max(c.end()));
Some((frames, lo, hi))
}
fn mute_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
@ -652,7 +710,7 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut kept = 0;
for t in &tracks {
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post);
kept += crate::io::strip_silence(s, *t, r, thr, min_len, pre, post)?;
}
Ok(json!({"clips": kept}))
}
@ -702,7 +760,8 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
let dir = i64_or(p, "direction", 1).signum();
let count = i64_or(p, "count", 1).clamp(1, 1000);
let s = e.session();
let step = s.edit.nudge.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
let value = if bool_or(p, "next", false) { s.edit.nudge.stepped(true) } else { s.edit.nudge };
let step = value.step_samples(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate) * count * if dir == 0 { 1 } else { dir };
let ids = clip_ids_param(e, p);
let s = e.session_mut();
let n = if ids.is_empty() { 0 } else { ops::move_clips(s, &ids, step, None) };
@ -746,9 +805,97 @@ 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;
}
}
Ok(json!({"consolidated": n}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
let mut e = Engine::default();
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200});
e.execute("edit.select", &sel).unwrap();
let rev = e.revision;
assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]]));
assert_eq!(e.revision, rev);
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]]));
}
}

View File

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

View File

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

View File

@ -15,32 +15,41 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing}))
}),
cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }),
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| {
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| {
let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?;
refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| {
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string();
refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| {
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session_copy(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| {
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string();
let file = crate::io::session_file(&path);
refuse_replacing(e, "session.save_template", &file, p)?;
let mut s = e.session().clone();
for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } }
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(json!({"path": path}))
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?;
}
Ok(json!({"path": file}))
}),
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| {
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| {
let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?;
crate::io::open_session(e, &path)?;
Ok(json!({}))
@ -57,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}))
}),
@ -71,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)
}
),
@ -98,24 +107,32 @@ pub fn specs() -> Vec<CommandSpec> {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None };
let bytes = crate::io::export_midi(e, only.as_deref())?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") {
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(path, text.as_bytes())?;
}
Ok(json!({"text": text}))
}),
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| {
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string();
let ids = clip_ids_param(e, p);
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}))
Ok(json!({"written": n, "dir": dir}))
}),
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| {
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string();
refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?;
let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?;
let mut s = e.session().clone();
s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master);
@ -179,6 +196,16 @@ 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())));
}
Ok(())
}
fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Untitled").to_string();
let sr =
@ -210,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);
@ -272,7 +299,7 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
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)?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
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}),
)
@ -291,7 +318,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
};
std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, text.as_bytes())?;
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
}
@ -438,6 +465,7 @@ mod tests {
fn session_open_reports_missing_media() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
@ -448,6 +476,17 @@ mod tests {
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_a_session_without_audio_creates_its_folder() {
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song").join("Song.scraft");
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn tone_engine() -> (Engine, u64) {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
@ -486,6 +525,20 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
let p = json!({"dir": dir.to_string_lossy()});
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.execute("file.export_clips", &p).is_err());
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
let r = e.execute("file.export_clips", &p).unwrap();
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
let _ = std::fs::remove_dir_all(&dir);
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};

View File

@ -8,15 +8,26 @@ use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
)]
vec![
cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
),
cmd!(
"mix.instrument_param",
"Set Instrument Parameter",
[],
None,
"{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",
has_selection,
set_instrument_param
),
]
}
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
@ -39,6 +50,20 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"track": t, "plugin": info.id}))
}
fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.instrument_param";
let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();
let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;
// Hosted instruments keep their parameters in their own state.
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;
let pi = info.param(&param).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;
let v = pi.clamp(value);
ins.params.insert(param.clone(), v);
Ok(json!({"track": t, "param": param, "value": v}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
@ -68,6 +93,24 @@ mod tests {
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
}
#[test]
fn sets_a_built_in_instruments_parameters() {
let mut e = Engine::default();
let t = track(&mut e, "instrument");
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 5000})).unwrap();
assert_eq!(r["value"], 5000.0);
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 1.0e9})).unwrap();
assert_eq!(r["value"], 20000.0, "clamped");
let v = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied());
assert_eq!(v, Some(20000.0));
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "nope", "value": 1})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff"})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": "loud"})).is_err());
let audio = track(&mut e, "audio");
assert!(e.execute("mix.instrument_param", &json!({"track": audio.0, "param": "cutoff", "value": 1})).is_err());
e.execute("edit.undo", &json!({})).unwrap();
}
#[test]
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
let mut e = Engine::default();

View File

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

View File

@ -100,12 +100,20 @@ 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
),
cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move),
cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send),
cmd!(
"mix.send",
"Assign Send",
[],
None,
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
has_selection,
send
),
cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| {
let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?;
if let Some(tr) = e.session_mut().track_mut(t)
@ -127,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
),
@ -352,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"))?;
@ -364,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;
@ -390,15 +417,17 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = slot_of(p, "slot", SEND_SLOTS);
let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0);
let pre = bool_or(p, "pre_fader", false);
let s = e.session_mut();
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid));
sl.level_db = level;
sl.pre_fader = pre;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
let mut sl = match tr.mixer.sends.get(slot).cloned().flatten() {
Some(old) => SendSlot { target: soundcraft_model::Route::Bus(bid), ..old },
None => SendSlot::new(soundcraft_model::Route::Bus(bid)),
};
sl.level_db = f32_or(p, "level_db", sl.level_db).clamp(-144.0, 12.0);
sl.pre_fader = bool_or(p, "pre_fader", sl.pre_fader);
if let Some(x) = tr.mixer.sends.get_mut(slot) {
*x = Some(sl);
}
@ -432,15 +461,21 @@ fn lane_param(p: &Value, cmd: &str) -> Result<AutoParam> {
str_param(p, "param").map_or(Ok(AutoParam::Volume), |s| AutoParam::parse(s).ok_or_else(|| bad(cmd, format!("unknown automation param `{s}`"))))
}
/// With `from` (a write pass's previous point), the points between `from` and `at` are removed first.
fn set_point(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.set_point", p)?;
let param = lane_param(p, "automation.set_point")?;
let at = position_param(e, "automation.set_point", p, "at")?.unwrap_or(e.session().edit.selection.start).max(0);
let from = position_param(e, "automation.set_point", p, "from")?;
let v = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("automation.set_point", "`value` required"))? as f32;
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
tr.lane_mut(&param).set_point(at, v);
let lane = tr.lane_mut(&param);
if let Some(from) = from {
lane.clear_range(from.saturating_add(1), at);
}
lane.set_point(at, v);
}
}
Ok(json!({"at": at, "value": v}))
@ -778,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)]);
@ -791,4 +837,53 @@ mod tests {
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[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();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
}

View File

@ -10,6 +10,7 @@ mod audio_midi;
mod audiosuite;
mod clap;
mod clip;
mod clip_list;
mod clip_more;
mod edit;
mod edit_more;
@ -24,6 +25,7 @@ mod more_util;
mod options;
mod plugin_state;
mod query;
mod sampler;
mod setup_more;
mod surround;
mod track;
@ -35,6 +37,8 @@ mod view_more;
mod vst3;
mod zoom_toggle;
pub(crate) use edit::remember_selection;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
pub type Enabled = fn(&Engine) -> std::result::Result<(), String>;
@ -52,6 +56,8 @@ pub struct CommandSpec {
pub run: Run,
pub journal: bool,
pub undoable: bool,
/// Only touches `edit.zoom`, through `Engine::zoom_mut`.
pub view: bool,
}
/// Serializable description of a command.
@ -81,18 +87,16 @@ impl CommandSpec {
}
}
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants.
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants.
#[macro_export]
macro_rules! cmd {
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
};
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
(query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) };
(noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) };
(view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) };
(@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view }
};
($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) };
}
pub fn command_specs() -> &'static [CommandSpec] {
@ -117,6 +121,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view_more::specs());
v.extend(track_more::specs());
v.extend(clip_more::specs());
v.extend(clip_list::specs());
v.extend(event_more::specs());
v.extend(setup_more::specs());
v.extend(clap::specs());
@ -124,6 +129,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
v
@ -273,7 +279,7 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
}
};
for f in fmts {
if let Ok(x) = soundcraft_time::parse_position(body, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start) {
if let Ok(x) = s.parse_position(body, f) {
return Ok(Some(x));
}
}

View File

@ -132,9 +132,10 @@ pub fn set_clip_env(c: &mut Clip, mut pts: Vec<(Samples, f32)>) {
}
}
/// Longest audio we render in one stretch (one hour at the session rate).
/// Longest audio we render in one stretch: one hour at the session rate, and no more than
/// `time_stretch` makes.
fn max_render(s: &Session) -> Samples {
s.sample_rate.samples(3600.0)
s.sample_rate.samples(3600.0).min(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples)
}
/// Render the audio a clip plays, time-stretched (pitch preserved) to `out_len` samples, into a new
@ -142,8 +143,10 @@ fn max_render(s: &Session) -> Samples {
pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples) -> Result<Option<SourceId>> {
let ClipContent::Audio { source, offset } = c.content else { return Ok(None) };
let Some(audio) = s.pool.get(source).cloned() else { return Ok(None) };
if out_len <= 0 || out_len > max_render(s) {
return Err(bad(cmd, "the target length must be between 1 sample and one hour"));
let max = max_render(s);
if out_len <= 0 || out_len > max {
let secs = max as f64 / s.sample_rate.as_f64();
return Err(bad(cmd, format!("the target length must be between 1 sample and {secs:.0} s")));
}
let stretch = if c.stretch.is_finite() && c.stretch > 0.0 { c.stretch } else { 1.0 };
let src_len = ((c.length as f64) / stretch).round().max(1.0);
@ -202,4 +205,12 @@ mod tests {
assert_eq!(env_db(&[], 50), 0.0);
assert_eq!(env_db(&env, 500), -10.0);
}
#[test]
fn stretches_are_no_longer_than_time_stretch_makes() {
let mut s = Session::default();
assert_eq!(max_render(&s), s.sample_rate.samples(3600.0));
s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();
assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);
}
}

View File

@ -17,6 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
run: insert_state,
journal: false,
undoable: false,
view: false,
}]
}

View File

@ -34,7 +34,7 @@ pub fn specs() -> Vec<CommandSpec> {
let at = position_param(e, "session.format_time", p, "at")?.unwrap_or(0);
let s = e.session();
let f = str_param(p, "format").and_then(soundcraft_time::TimeFormat::from_id).unwrap_or(s.edit.main_counter);
Ok(json!({"text": soundcraft_time::format_position(at, f, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start)}))
Ok(json!({"text": s.format_position(at, f)}))
}),
]
}

View File

@ -0,0 +1,321 @@
//! The built-in Sampler's sample: `mix.sampler_load` decodes an audio file (WAV, AIFF, FLAC, MP3,
//! OGG, anything `file.import_audio` reads) into a session source, resampled to the session rate,
//! and makes it the sample an instrument track's Sampler plays; `mix.sampler_clear` removes it.
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
use std::path::Path;
const SAMPLER: &str = "sampler";
/// Largest sample accepted (frames × channels): ten minutes of 192 kHz stereo.
const MAX_SAMPLE_VALUES: usize = 192_000 * 600 * 2;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mix.sampler_load",
"Load Sampler Sample",
[],
None,
"{track?, path, params?: {root_note, key_low, key_high, attack, loop_mode, …}}: an instrument track plays the file through the built-in Sampler (made its instrument if it is not already) → {track, source, name, frames, channels, sample_rate}",
has_selection,
load
),
cmd!("mix.sampler_clear", "Clear Sampler Sample", [], None, "{track?}: the track's Sampler stops playing its sample", has_selection, clear),
]
}
/// The instrument track a sampler command acts on.
fn instrument_track(e: &Engine, id: &str, p: &Value) -> Result<TrackId> {
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
return Err(bad(id, "only instrument tracks have a sampler"));
}
Ok(t)
}
fn load(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_load";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
let t = instrument_track(e, id, p)?;
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
// Validate the parameters before touching the session.
let mut values = Vec::new();
if let Some(v) = p.get("params") {
let obj = v.as_object().ok_or_else(|| bad(id, "`params` must be an object"))?;
for (k, v) in obj {
let pi = info.param(k).ok_or_else(|| bad(id, format!("the sampler has no parameter `{k}`")))?;
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(id, format!("`{k}` must be a number")))?;
values.push((k.clone(), pi.clamp(x as f32)));
}
}
let bytes = crate::io::read_file(&path)?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
}
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
if !keep {
let mut ins = Insert::new(SAMPLER);
for pi in info.params {
ins.params.insert(pi.id.to_string(), pi.default);
}
tr.instrument = Some(ins);
}
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = Some(src);
for (k, v) in values {
ins.params.insert(k, v);
}
}
Ok(json!({"track": t, "source": src, "name": stem, "frames": frames, "channels": channels, "sample_rate": rate}))
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_clear";
let t = instrument_track(e, id, p)?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).filter(|i| i.plugin == SAMPLER);
let ins = ins.ok_or_else(|| bad(id, "the track's instrument is not the sampler"))?;
let had = ins.sample.take();
Ok(json!({"track": t, "cleared": had}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, TrackId};
use soundcraft_time::{Range, TICKS_PER_QUARTER};
use std::path::PathBuf;
/// A scratch folder for one test.
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sc-sampler-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Writes a generated tone (no recorded or third-party audio) as a 16-bit WAV.
fn tone_wav(path: &std::path::Path, freq: f32, rate: u32, secs: f32, channels: usize) {
let n = (rate as f32 * secs) as usize;
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
}
fn instrument_track(e: &mut Engine) -> TrackId {
e.execute("track.new", &json!({"kind": "instrument", "format": "stereo"})).unwrap();
e.session().tracks.last().unwrap().id
}
/// One MIDI note (pitch, start in quarter notes, length in quarters) on the track at 120 bpm.
fn add_notes(e: &mut Engine, t: TrackId, notes: &[(u8, i64, i64)]) {
let q = TICKS_PER_QUARTER;
let mut seq = Sequence::default();
for &(pitch, start, len) in notes {
seq.notes.push(Note { pitch, velocity: 127, release_velocity: 64, channel: 0, start: start * q, length: len * q });
}
let s = e.session_mut();
let cid = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::midi(cid, "notes", 0, 48_000 * 8, seq));
}
fn render(e: &Engine, secs: i64) -> Vec<Vec<f32>> {
soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512)
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
fn crossings(x: &[f32]) -> usize {
x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count()
}
#[test]
fn loads_a_sample_plays_it_and_undoes() {
let d = dir("play");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 44_100, 2.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1), (72, 2, 1)]);
assert!(peak(&render(&e, 2)[0]) > 0.0, "the default synth plays the notes first");
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert_eq!(r["frames"], 96_000, "resampled to the 48 kHz session: {r}");
assert_eq!(r["sample_rate"], 48_000);
assert_eq!(r["channels"], 1);
assert_eq!(r["name"], "tone");
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let out = render(&e, 2);
// Middle C plays the tone at its pitch for the first quarter (0.5 s), key 72 an octave up.
let first = &out[0][2400..21_600];
let second = &out[0][50_400..69_600];
assert!(peak(first) > 0.05, "the sample plays");
let (a, b) = (crossings(first), crossings(second));
assert!((a as f32 - 176.0).abs() <= 2.0, "440 Hz over 0.4 s: {a}");
assert!((b as f32 - 352.0).abs() <= 3.0, "an octave up: {b}");
assert!(peak(&out[0][36_000..48_000]) < 1e-3, "released between the notes");
e.execute("edit.undo", &json!({})).unwrap();
let back = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(back.plugin, "subtractive_synth");
assert!(back.sample.is_none());
assert!(e.session().sources.is_empty(), "undo removes the source too");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn loads_flac_and_aiff_too() {
let d = dir("formats");
let n = 4800;
let tone: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * 440.0 * i as f32 / 96_000.0).sin()).collect();
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
let mut e = Engine::default();
let t = instrument_track(&mut e);
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
let path = d.join(format!("tone.{ext}"));
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["channels"], 2, "{ext}");
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn instrument_params_reach_the_sampler() {
let d = dir("params");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 2.0, 2);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"key_low": 61}})).unwrap();
assert_eq!(peak(&render(&e, 1)[0]), 0.0, "key 60 is below the key range");
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "key_low", "value": 0})).unwrap();
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "root_note", "value": 48})).unwrap();
let out = render(&e, 1);
assert!((crossings(&out[0][2400..21_600]) as f32 - 352.0).abs() <= 3.0, "root note 48: key 60 plays an octave up");
assert!(peak(&out[1]) > 0.05, "a stereo sample plays on both sides");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"nope": 1}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": "C3"}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": 3})).is_err());
assert_eq!(e.session().sources.len(), 1, "rejected loads add nothing");
// Reloading keeps the sampler's settings and swaps the sample.
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.params.get("root_note"), Some(&48.0));
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn clear_silences_the_sampler() {
let d = dir("clear");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert!(peak(&render(&e, 1)[0]) > 0.05);
let r = e.execute("mix.sampler_clear", &json!({"track": t.0})).unwrap();
assert!(r["cleared"].is_u64());
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
assert!(e.execute("mix.sampler_clear", &json!({"track": t.0})).is_err(), "not a sampler");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_sampled_session_saves_and_reopens_with_its_sample() {
let d = dir("save");
let wav = d.join("pluck.wav");
tone_wav(&wav, 330.0, 22_050, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(64, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": 64, "loop_mode": 1}})).unwrap();
let before = render(&e, 1);
let path = d.join("song.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
std::fs::remove_file(&wav).unwrap();
let mut e2 = Engine::default();
e2.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let ins = e2.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.params.get("loop_mode"), Some(&1.0));
let after = render(&e2, 1);
assert!(peak(&after[0]) > 0.05, "the saved copy in Audio Files plays");
let diff = before[0].iter().zip(&after[0]).fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
assert!(diff < 1e-5, "same audio after reopening: {diff}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn refuses_bad_input() {
let d = dir("bad");
let junk = d.join("junk.wav");
std::fs::write(&junk, b"definitely not audio").unwrap();
let empty = d.join("empty.wav");
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
let wav = d.join("ok.wav");
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
e.execute("track.new", &json!({"kind": "audio"})).unwrap();
let audio = e.session().tracks.last().unwrap().id;
let load = |e: &mut Engine, track: u64, path: &std::path::Path| {
e.execute("mix.sampler_load", &json!({"track": track, "path": path.to_string_lossy()}))
};
assert!(load(&mut e, t.0, &junk).is_err(), "not audio");
assert!(load(&mut e, t.0, &empty).is_err(), "no frames");
assert!(load(&mut e, t.0, &d.join("missing.wav")).is_err(), "missing file");
assert!(load(&mut e, audio.0, &wav).is_err(), "an audio track");
assert!(load(&mut e, 999_999, &wav).is_err(), "no such track");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0})).is_err(), "no path");
assert!(e.execute("mix.sampler_clear", &json!({"track": audio.0})).is_err());
assert!(e.session().sources.is_empty());
assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn missing_media_plays_silence() {
let d = dir("missing");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let src = soundcraft_model::SourceId(r["source"].as_u64().unwrap());
e.session_mut().pool.remove(src);
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
let _ = std::fs::remove_dir_all(d);
}
}

View File

@ -147,7 +147,9 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
let r = soundcraft_time::Range::new(0, end);
let mut n = 0;
for t in tracks {
if crate::io::consolidate(e, t, r)? {
// Each track keeps its own length (its material plus a second), as before.
let tr = crate::io::bounded_to_track(e.session(), t, r);
if crate::io::consolidate(e, id, t, tr)? {
n += 1;
}
}
@ -318,7 +320,7 @@ fn current_feet_frames(e: &mut Engine, p: &Value) -> Result<Value> {
let at = s.edit.selection.start.max(0);
let frames_at = s.frame_rate.frames_at(at, s.sample_rate);
let start = ft * 16 + fr - frames_at;
s.edit.values.insert("feet_frames.start".into(), start as f64);
s.edit.values.insert(soundcraft_model::FEET_FRAMES_START.into(), start as f64);
Ok(json!({"feet_frames_start": start}))
}
@ -404,7 +406,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
/// Decode a source file of another session (paths relative to that session's folder).
fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io::AudioBuffer> {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
let bytes = std::fs::read(&p).ok()?;
let bytes = crate::io::read_file(&p).ok()?;
let (_, mut buf) = soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())).ok()?;
if buf.sample_rate != sr && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
@ -416,7 +418,7 @@ fn load_source(dir: &Path, src: &Source, sr: u32) -> Option<soundcraft_audio_io:
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "file.import_session_data";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
let text_v = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let text_v = crate::io::read_text(&path)?;
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default();
let names: Option<Vec<String>> =
@ -436,6 +438,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
let mut needed: Vec<SourceId> =
picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect();
// A Sampler's sample is session audio too.
needed.extend(picked.iter().filter_map(|t| t.instrument.as_ref().and_then(|i| i.sample)));
needed.sort_by_key(|x| x.0);
needed.dedup();
let sr = e.session().sample_rate.hz();
@ -481,6 +485,10 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = ins.sample.and_then(|sid| map.get(&sid.0).copied());
}
// Busses are matched by name (created when missing).
let remap = |s: &mut Session, r: &Route| -> Route {
match r {
@ -550,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");
}
@ -603,4 +610,26 @@ mod tests {
assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_a_samplers_sample() {
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("hit.wav");
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let mut donor = Engine::default();
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = crate::demo::demo_engine();
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Pads"]})).unwrap();
let sample = e.session().track_by_name("Pads").and_then(|t| t.instrument.as_ref()).and_then(|i| i.sample).unwrap();
let src = e.session().source(sample).unwrap();
assert_eq!(src.name, "hit");
assert!(e.session().pool.get(sample).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

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

View File

@ -153,7 +153,7 @@ pub fn specs() -> Vec<CommandSpec> {
"track.designate_target_playlist",
"Designate as Target Playlist",
["Track"],
None,
Some("Cmd+Shift+Right"),
"{tracks?, index?} — the active playlist (or `index`) becomes the target for Copy/Move Selection to",
has_selection,
designate_target
@ -162,7 +162,7 @@ pub fn specs() -> Vec<CommandSpec> {
"track.show_target_playlist",
"Show Target Playlist",
["Track"],
None,
Some("Shift+Right"),
"{tracks?} — makes the target playlist the main playlist",
has_selection,
show_target
@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
"comments": tr.comments,
});
let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, text.as_bytes())?;
Ok(json!({"path": path, "track": first}))
}
@ -271,7 +271,7 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "track.load_preset";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
let tracks = tracks_required(e, id, p)?;
let text = std::fs::read_to_string(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let text = crate::io::read_text(&path)?;
let v: Value = serde_json::from_str(&text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
if v.get("format").and_then(Value::as_str) != Some(PRESET_FORMAT) {
return Err(EngineError::Io(format!("{path}: not a SoundCraft track preset")));
@ -447,6 +447,21 @@ fn coalesce_trim(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"tracks": n}))
}
pub(super) fn clear_trim(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.clear_trim_automation", p)?;
let s = e.session_mut();
let mut n = 0;
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let before = tr.automation.len();
tr.automation.retain(|l| l.param != trim_param());
if tr.automation.len() < before {
n += 1;
}
}
Ok(json!({"tracks": n}))
}
fn coalesce_vca(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "track.coalesce_vca";
let picked = tracks_param(e, id, p)?;
@ -649,6 +664,26 @@ mod tests {
assert!(tr.lane(&trim_param()).is_none());
}
#[test]
fn clear_trim_automation_removes_only_trim() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let ids: Vec<TrackId> = e.session().tracks.iter().map(|t| t.id).collect();
let (a, b) = (ids[0], ids[1]);
e.execute("automation.set_point", &json!({"track": a.0, "param": "volume", "at": 0, "value": -3.0})).unwrap();
e.execute("track.trim_automation_write", &json!({"tracks": [a.0, b.0], "start": 0, "end": 4800, "db": -12.0})).unwrap();
let has_trim = |e: &Engine, t: TrackId| e.session().track(t).unwrap().lane(&trim_param()).is_some_and(|l| !l.points.is_empty());
e.execute("edit.select", &json!({"tracks": [a.0]})).unwrap();
e.execute("track.clear_trim_automation", &json!({})).unwrap();
assert!(!has_trim(&e, a), "the selected track loses its trim");
assert!(has_trim(&e, b), "other tracks keep theirs");
assert_eq!(e.session().track(a).unwrap().lane(&AutoParam::Volume).unwrap().points.len(), 1);
e.execute("track.clear_trim_automation", &json!({"tracks": [b.0]})).unwrap();
assert!(!has_trim(&e, b), "an explicit track is cleared too");
assert!(e.undo());
assert!(has_trim(&e, b), "undo brings the trim back");
}
#[test]
fn target_and_recent_playlists() {
let mut e = crate::demo::demo_engine();

View File

@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
.map(|v| {
let clips = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| c.video_source() == Some(v.id)).count();
json!({"id": v.id, "name": v.name, "path": v.path, "width": v.width, "height": v.height, "frame_rate": v.frame_rate,
"duration": v.duration, "codec": v.codec, "clips": clips, "file_found": std::path::Path::new(&v.path).is_file()})
"duration": v.duration, "codec": v.codec, "clips": clips, "file_found": crate::io::file_found(&v.path)})
})
.collect();
Ok(json!({"videos": rows, "online": !s.edit.flag(OFFLINE_FLAG)}))

View File

@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)),
cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)),
cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0);
e.session_mut().edit.zoom.samples_per_px = v;
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)),
cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| {
let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?;
zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64))
}),
cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP);
e.zoom_mut().samples_per_px = v;
Ok(json!({"samples_per_px": v}))
}),
cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize;
let s = e.session_mut();
let z = e.zoom_mut();
if bool_or(p, "store", false) {
if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px }
} else if let Some(z) = s.edit.zoom.presets.get(i) {
s.edit.zoom.samples_per_px = *z;
if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px }
} else if let Some(v) = z.presets.get(i) {
z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP);
}
Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px}))
Ok(json!({"samples_per_px": z.samples_per_px}))
}),
cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session_mut();
let r = s.edit.selection;
let r = e.session().edit.selection;
if !r.is_empty() {
s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25);
s.edit.zoom.scroll = r.start;
let z = e.zoom_mut();
z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = r.start;
}
Ok(json!({}))
}),
cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session_mut();
let s = e.session();
let end = s.content_end().max(s.sample_rate.samples(30.0));
s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25);
s.edit.zoom.scroll = 0;
let z = e.zoom_mut();
z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = 0;
Ok(json!({}))
}),
cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
let to = position_param(e, "view.scroll", p, "to")?;
let by = f64_or(p, "by_px", 0.0);
let s = e.session_mut();
let spp = s.edit.zoom.samples_per_px;
s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0);
Ok(json!({"scroll": s.edit.zoom.scroll}))
let z = e.zoom_mut();
let by = soundcraft_time::to_samples(by * z.samples_per_px);
z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION);
Ok(json!({"scroll": z.scroll}))
}),
cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let s = e.session_mut();
let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) };
s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom}))
cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let z = e.zoom_mut();
let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) };
z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": z.waveform_zoom}))
}),
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
@ -67,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;
@ -80,26 +85,27 @@ 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()}))
}),
]
}
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
let s = e.session_mut();
let anchor = s.edit.selection.start;
let old = s.edit.zoom.samples_per_px;
let new = (old * f).clamp(0.25, 1_000_000.0);
// Keep the insertion point at the same screen x when it is visible.
let x = (anchor - s.edit.zoom.scroll) as f64 / old;
if (0.0..4000.0).contains(&x) {
s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0);
const MIN_SPP: f64 = 0.25;
const MAX_SPP: f64 = 1_000_000.0;
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
let insertion = e.session().edit.selection.start as f64;
let z = e.zoom_mut();
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
}
s.edit.zoom.samples_per_px = new;
Ok(json!({"samples_per_px": new}))
z.samples_per_px = new;
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
}
fn ruler(e: &mut Engine, p: &Value) -> Result<Value> {
@ -116,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)?;
@ -157,4 +169,77 @@ mod tests {
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
#[test]
fn zoom_by_holds_the_position_under_the_pointer() {
let mut e = Engine::default();
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
e.execute("view.scroll", &json!({"to": 48_000})).unwrap();
e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap();
assert_eq!(view(&e), (50.0, 78_000 - 300 * 50));
e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap();
assert_eq!(view(&e), (100.0, 48_000));
}
#[test]
fn zoom_and_scroll_stay_in_range() {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap();
e.execute("view.zoom_to_selection", &json!({})).unwrap();
assert_eq!(view(&e).0, MAX_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap();
assert_eq!(view(&e).0, MIN_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap();
assert_eq!(view(&e), (MAX_SPP, 0));
for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] {
assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}");
}
e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap();
assert_eq!(view(&e).1, MAX_POSITION);
e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap();
assert_eq!(view(&e).1, 0);
}
#[test]
fn view_commands_skip_undo_the_journal_and_session_copies() {
let mut e = Engine::default();
let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone();
let steps = e.undo_history().len();
for db in [-1.0, -2.0] {
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap();
}
assert_eq!(e.undo_history().len(), steps + 1);
let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len());
let before = state(&e);
e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap();
e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap();
assert_eq!(state(&e), before);
}
#[test]
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
let mut e = Engine::default();
e.execute("view.grid", &json!({"value": "1/16"})).unwrap();
e.execute("view.grid", &json!({"step": 2})).unwrap();
assert_eq!(e.session().edit.grid.label(), "1/4 note");
e.execute("view.grid", &json!({"step": -1})).unwrap();
assert_eq!(e.session().edit.grid.label(), "1/8 note");
e.execute("view.nudge", &json!({"value": {"seconds": 0.01}})).unwrap();
e.execute("view.nudge", &json!({"step": i64::MIN})).unwrap();
assert_eq!(e.session().edit.nudge, GridValue::Seconds(0.001));
assert!(e.execute("view.nudge", &json!({"step": "up"})).is_err());
let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone();
e.execute("view.nudge", &json!({"value": "1/16"})).unwrap();
e.execute("edit.select", &json!({"tracks": [t], "start": 0, "end": 1000})).unwrap();
e.execute("edit.nudge", &json!({"direction": 1, "next": true})).unwrap();
assert_eq!(e.session().edit.selection.start, 12_000);
e.execute("edit.nudge", &json!({"direction": -1})).unwrap();
assert_eq!(e.session().edit.selection.start, 6_000);
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
}
}

View File

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

View File

@ -0,0 +1,487 @@
//! The folders automation may read and write.
//!
//! `soundcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in
//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
//! [`confine_process`] (`soundcraft-cli mcp`), except inside [`unconfined`]. Without roots
//! nothing is checked.
//!
//! A path is checked, then opened: a process that can change the folders in between (swapping in
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
//! guard against other programs on the computer.
use std::cell::RefCell;
use std::ffi::OsString;
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, OnceLock};
/// Read access or write access.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Access {
Read,
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
}
#[derive(Debug)]
struct Root {
/// The folder made absolute, links followed: what paths are compared with.
resolved: PathBuf,
/// The same without Windows' `\\?\` prefix, for people.
shown: PathBuf,
}
impl Root {
/// The folder at `path`, which must exist.
fn open(path: &Path, access: Access) -> Result<Self, String> {
let kind = match access {
Access::Read => "read",
Access::Write => "write",
};
let resolved = std::fs::canonicalize(path)
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
Ok(Self { shown: plain(resolved.clone()), resolved })
}
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
let path = path.as_ref();
let shown_path = path.display();
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
};
let Some(Root { resolved: folder, shown }) = root else {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown_path}")),
};
};
if inside(&resolve(path)?, folder) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
}
}
}
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
#[derive(Clone, Debug)]
enum Scope {
Confined(Arc<AutomationRoots>),
Unconfined,
}
thread_local! {
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
/// (they stay as they were).
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
PROCESS.set(roots).is_ok()
}
/// Puts the scope it replaced back when dropped (also when `f` panics).
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
let previous = self.0.take();
SCOPE.with(|s| *s.borrow_mut() = previous);
}
}
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
f()
}
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
match roots {
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
None => f(),
}
}
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
/// app's own folders.
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
with_scope(Some(Scope::Unconfined), f)
}
/// The roots in force on this thread, if any.
pub fn current() -> Option<Arc<AutomationRoots>> {
match SCOPE.with(|s| s.borrow().clone()) {
Some(Scope::Confined(r)) => Some(r),
Some(Scope::Unconfined) => None,
None => PROCESS.get().cloned(),
}
}
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
fn plain(path: PathBuf) -> PathBuf {
if !cfg!(windows) {
return path;
}
let text = path.to_string_lossy();
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
return PathBuf::from(format!(r"\\{rest}"));
}
match text.strip_prefix(r"\\?\") {
Some(rest) => PathBuf::from(rest),
None => path,
}
}
/// May `path` be read here? Always, without roots in force.
pub fn check_read(path: impl AsRef<Path>) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path.as_ref(), Access::Read))
}
/// May `path` be written here? Always, without roots in force.
pub fn check_write(path: impl AsRef<Path>) -> Result<(), String> {
current().map_or(Ok(()), |r| r.check(path.as_ref(), Access::Write))
}
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
/// existing folder's links followed and the rest appended.
fn resolve(path: &Path) -> Result<PathBuf, String> {
if path.as_os_str().is_empty() {
return Err("automation path rejected: path is empty".into());
}
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
return Err(format!("automation path rejected: reserved device names are not allowed: {}", path.display()));
}
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {}: {e}", path.display()))?;
// The names below the nearest existing folder, last first.
let mut missing: Vec<OsString> = vec![];
let mut at = absolute.as_path();
loop {
match std::fs::canonicalize(at) {
Ok(mut found) => {
found.extend(missing.iter().rev());
return Ok(found);
}
Err(e) => match std::fs::symlink_metadata(at) {
Ok(m) if m.file_type().is_symlink() => {
return Err(format!("automation path rejected: a link that leads nowhere: {}", path.display()));
}
// There, yet its real path can't be had (no permission): nothing to compare.
Ok(_) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display())),
},
}
let parent = at.parent();
match (at.components().next_back(), parent) {
(Some(Component::Normal(name)), Some(parent)) => {
missing.push(name.to_os_string());
at = parent;
}
(Some(Component::ParentDir), _) => {
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {}", path.display()));
}
// Down to the root (or drive) and it doesn't exist either.
_ => {
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
return Err(format!("automation path rejected: cannot resolve {}: {e}", path.display()));
}
}
}
}
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
/// Windows, whose file systems ignore it)?
fn inside(path: &Path, root: &Path) -> bool {
let mut path = path.components();
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
}
fn same_component(a: Component, b: Component) -> bool {
if a == b {
return true;
}
if !cfg!(windows) {
return false;
}
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
_ => false,
}
}
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
fn is_device_name(component: &str) -> bool {
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
}
#[cfg(test)]
mod tests {
use super::*;
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
let base = std::env::temp_dir().join(format!("sc-file-access-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
for d in ["in/sub", "out", "outside", "in2"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
std::fs::write(base.join("in/a.scraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.scraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
(base, roots)
}
fn s(p: PathBuf) -> String {
p.to_string_lossy().into_owned()
}
#[test]
fn paths_inside_the_roots_pass_and_others_dont() {
let (base, roots) = folders("inside");
assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("in")), Access::Read), Ok(()), "the root itself");
assert_eq!(roots.check(s(base.join("in/sub/new.scraft")), Access::Read), Ok(()), "missing files are checked by their folder");
assert_eq!(roots.check(s(base.join("out/new/deeper/x.wav")), Access::Write), Ok(()), "and by their nearest folder");
let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
// Read and write access are separate.
let e = roots.check(s(base.join("in/a.scraft")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "{e}");
assert!(roots.check(s(base.join("out/x.wav")), Access::Read).unwrap_err().contains("outside the read root"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_sibling_with_the_same_prefix_is_outside() {
let (base, roots) = folders("prefix");
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
assert!(roots.check(s(base.join("in2/b.scraft")), Access::Read).is_err(), "in2 is not inside in");
assert!(roots.check(format!("{}2/c.scraft", s(base.join("in"))), Access::Read).is_err());
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn parent_traversal_is_resolved_or_refused() {
let (base, roots) = folders("dotdot");
let sep = std::path::MAIN_SEPARATOR;
let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in")));
assert!(roots.check(up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
let back = format!("{}{sep}..{sep}a.scraft", s(base.join("in/sub")));
assert_eq!(roots.check(back, Access::Read), Ok(()), "`..` that stays inside");
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.scraft", s(base.join("out")));
let e = roots.check(missing, Access::Write).unwrap_err();
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn relative_paths_resolve_against_the_working_directory() {
let (base, roots) = folders("relative");
// The tests' working directory is the crate's folder: not inside the roots.
assert!(roots.check("a.scraft", Access::Read).unwrap_err().contains("outside the read root"));
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn links_are_followed_before_comparing() {
let (base, roots) = folders("links");
#[cfg(unix)]
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
#[cfg(windows)]
let made = |target: &Path, link: &Path| {
let made =
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
made.is_ok()
};
#[cfg(not(any(unix, windows)))]
let made = |_: &Path, _: &Path| false;
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
if !made(&base.join("outside/secret.scraft"), &base.join("in/leak.scraft")) {
eprintln!("skipped: symbolic links can't be made here");
let _ = std::fs::remove_dir_all(base);
return;
}
assert!(roots.check(s(base.join("in/leak.scraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
assert!(made(&base.join("outside"), &base.join("out/away")));
let e = roots.check(s(base.join("out/away/new.wav")), Access::Write).unwrap_err();
assert!(e.contains("outside the write root"), "a folder link out: {e}");
assert!(made(&base.join("outside/gone.scraft"), &base.join("out/dangling.wav")));
let e = roots.check(s(base.join("out/dangling.wav")), Access::Write).unwrap_err();
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
// A link to a folder inside the root stays inside.
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
let (base, roots) = folders("case");
let upper = s(base.join("IN/a.scraft"));
assert_eq!(roots.check(upper, Access::Read), Ok(()), "{upper}");
assert_eq!(roots.check(s(base.join("In/Sub/New.scraft")), Access::Read), Ok(()));
let e = roots.check(s(base.join("out/NUL")), Access::Write).unwrap_err();
assert!(e.contains("reserved device names"), "{e}");
assert!(roots.check(s(base.join("out/com1.txt")), Access::Write).is_err());
assert_eq!(roots.check(s(base.join("out/console.txt")), Access::Write), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn scopes_nest_and_stay_on_their_thread() {
let (base, roots) = folders("scopes");
let outside = s(base.join("outside/secret.scraft"));
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
confine(Some(&roots), || {
assert!(check_read(&outside).is_err());
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
assert!(check_read(&outside).is_err(), "restored after unconfined");
let o = outside.clone();
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
assert!(there, "another thread is not confined by this thread's scope");
});
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
assert!(r.is_err());
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn device_names() {
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.scraft", "NUL "] {
assert!(is_device_name(d), "{d}");
}
for f in ["console", "com10", "COM0", "lpt", "a.scraft", "nul-x"] {
assert!(!is_device_name(f), "{f}");
}
}
/// Every file a command reads or writes by path goes through `io` (`read_file`, `write_file`
/// and the other checked helpers there), which check the roots. Engine code that reaches for
/// the file system itself would bypass them, so none may: every file under `src/` except
/// `io.rs`, this module and the test files. Audio is decoded into the session when it enters
/// it (import, open, sampler load), so playback and bounce read nothing from disk and need no
/// check of their own. No engine module reads a fixed folder (plug-in scans live in the host
/// crates), so nothing else is skipped.
#[test]
fn commands_do_not_touch_the_file_system_themselves() {
fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(dir).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
walk(&path, files);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files = vec![];
walk(&src, &mut files);
assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}");
let mut offenders = vec![];
for path in files {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs") || name.starts_with("tests") {
continue;
}
let text = std::fs::read_to_string(&path).unwrap();
// Test modules (which build their own fixtures) come last in each file.
let code = text.split("#[cfg(test)]").next().unwrap_or("");
for (n, line) in code.lines().enumerate() {
let line = line.trim_start();
let touches =
["std::fs", "fs::", "File::open", "File::create", "OpenOptions", ".is_file()", ".exists()", ".is_dir()", "canonicalize"]
.iter()
.any(|w| line.contains(w));
if !line.starts_with("//") && touches {
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
}
}
}
assert!(offenders.is_empty(), "use the checked helpers in crate::io (read_file, write_file, ...): {offenders:#?}");
}
}

View File

@ -3,11 +3,11 @@
use crate::Engine;
use serde_json::{Value, json};
use soundcraft_model::{Clip, ClipContent, ClipId, Track, TrackId};
use soundcraft_time::{TimeFormat, format_position};
use soundcraft_time::TimeFormat;
fn clip_json(e: &Engine, c: &Clip) -> Value {
let s = e.session();
let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let fmt = |x| s.format_position(x, TimeFormat::MinSecs);
let mut v = json!({
"id": c.id, "name": c.name, "start": c.start, "end": c.end(), "length": c.length,
"start_time": fmt(c.start), "gain_db": c.gain_db, "muted": c.muted,
@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value {
})).collect::<Vec<_>>(),
"selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips},
"edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(),
"zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll},
"transport": e.transport,
"undo": e.undo_label(), "redo": e.redo_label(),
})
@ -109,7 +110,7 @@ pub fn clip(e: &Engine, id: ClipId) -> Option<Value> {
/// Plain-text session report (File › Export › Session Info as Text).
pub fn session_text(e: &Engine) -> String {
let s = e.session();
let fmt = |x| format_position(x, TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let fmt = |x| s.format_position(x, TimeFormat::MinSecs);
let mut out = String::new();
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),

File diff suppressed because it is too large Load Diff

View File

@ -11,13 +11,15 @@ 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;
use serde_json::{Value, json};
use soundcraft_model::{Clip, Session, TrackId};
use soundcraft_model::{Clip, Session, TrackId, ZoomState};
use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
@ -108,13 +110,22 @@ pub struct Engine {
pub journal: Vec<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change (UI caches key on it).
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
pub revision: u64,
dirty: bool,
/// The document as last opened or saved, for `is_dirty`.
saved: Arc<Session>,
/// `is_dirty` as of a revision.
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 {
@ -126,8 +137,10 @@ impl Default for Engine {
impl Engine {
pub fn new(session: Session) -> Self {
install_panic_hook();
let doc = Arc::new(session);
Engine {
doc: Arc::new(session),
saved: Arc::clone(&doc),
doc,
undo: Vec::new(),
redo: Vec::new(),
history_limit: 200,
@ -138,8 +151,11 @@ impl Engine {
path: None,
revision: 1,
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
merge_hold: None,
messages: Vec::new(),
last_selection: None,
}
}
@ -158,23 +174,43 @@ impl Engine {
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
&mut Arc::make_mut(&mut self.doc).edit.zoom
}
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
self.transport_requests.clear();
self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
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;
}
/// Whether the document changed since it was opened or saved. Selecting, locating and zooming
/// don't count, and undoing back to the saved state makes it clean again.
pub fn is_dirty(&self) -> bool {
self.dirty
if !self.dirty {
return false;
}
let (rev, dirty) = self.dirty_at.get();
if rev == self.revision {
return dirty;
}
let dirty = !same_document(&self.saved, &self.doc);
self.dirty_at.set((self.revision, dirty));
dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
self.saved = Arc::clone(&self.doc);
}
pub fn can_undo(&self) -> bool {
@ -227,16 +263,18 @@ impl Engine {
/// 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> {
self.merge_key = None;
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
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.
let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
if !names_targets {
return Err(EngineError::Disabled(id.to_string(), reason));
}
}
if spec.view {
return self.execute_view(spec, params);
}
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
@ -244,16 +282,19 @@ impl Engine {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
self.doc = before;
self.revision = rev.wrapping_add(1);
self.dirty = dirty;
return Err(EngineError::Internal(id.to_string(), msg));
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);
}
@ -274,20 +315,39 @@ impl Engine {
}
}
fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result<Value> {
let zoom = self.doc.edit.zoom;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)))
.unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p))));
if result.is_err() && self.doc.edit.zoom != zoom {
Arc::make_mut(&mut self.doc).edit.zoom = zoom;
}
result
}
/// 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;
@ -318,6 +378,19 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@ -329,6 +402,10 @@ fn install_panic_hook() {
});
}
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
mod merge_tests {
use super::*;
@ -345,4 +422,56 @@ 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)]
mod dirty_tests {
use super::*;
#[test]
fn selecting_locating_and_zooming_leave_the_session_clean() {
let mut e = crate::demo::demo_engine();
for (id, p) in [
("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})),
("edit.select_none", json!({})),
("transport.locate", json!({"at": 96_000})),
("view.zoom_in", json!({})),
("view.scroll", json!({"to": 48_000})),
] {
e.execute(id, &p).unwrap();
assert!(!e.is_dirty(), "{id}");
}
e.execute("mix.mute", &json!({"track": "Kick"})).unwrap();
assert!(e.is_dirty());
e.undo();
assert!(!e.is_dirty());
}
}

View File

@ -0,0 +1,124 @@
//! Consolidate makes one clip per track that covers exactly the edit selection (#164).
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_engine::{Engine, EngineError};
use soundcraft_model::{Clip, TrackId};
use soundcraft_time::Range;
fn stereo_tracks(n: u64) -> Engine {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": n, "format": "stereo"})).unwrap();
e.session_mut().edit.selected_tracks = e.session().tracks.iter().map(|t| t.id).collect();
e
}
fn sr(e: &Engine) -> i64 {
e.session().sample_rate.samples(1.0)
}
/// Put a constant-level clip of `secs` seconds at the start of track `i`.
fn add_clip(e: &mut Engine, i: usize, secs: i64) -> TrackId {
let frames = usize::try_from(secs * sr(e)).unwrap();
let t = e.session().tracks[i].id;
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; frames]; 2] };
let src = soundcraft_engine::io::add_source(e.session_mut(), "tone", buf, None, FileFormat::Wav);
let id = e.session_mut().new_clip_id();
soundcraft_engine::edit::place_clip(e.session_mut(), t, Clip::audio(id, "tone", src, 0, 0, frames as i64));
t
}
fn clips(e: &Engine, i: usize) -> Vec<(i64, i64)> {
e.session().tracks[i].clips().iter().map(|c| (c.start, c.end())).collect()
}
/// Frames in the source behind the only clip on track `i`, and its first sample.
fn source_of_only_clip(e: &Engine, i: usize) -> (usize, f32) {
let s = e.session();
let c = &s.tracks[i].clips()[0];
let audio = s.pool.get(c.source().unwrap()).unwrap();
(audio.buffer.frames(), audio.buffer.channels[0][0])
}
fn assert_each_track_has(e: &Engine, start: i64, end: i64) {
for (i, t) in e.session().tracks.iter().enumerate() {
assert_eq!(clips(e, i), vec![(start, end)], "track {}", t.name);
assert_eq!(source_of_only_clip(e, i).0 as i64, end - start, "track {}", t.name);
}
}
#[test]
fn consolidating_silence_from_zero_covers_the_whole_selection() {
let mut e = stereo_tracks(5);
let sr = sr(&e);
e.session_mut().edit.selection = Range::new(0, 5 * sr);
e.execute("edit.consolidate", &json!({})).unwrap();
assert_each_track_has(&e, 0, 5 * sr);
}
#[test]
fn consolidating_silence_later_in_the_timeline_covers_the_whole_selection() {
let mut e = stereo_tracks(5);
let sr = sr(&e);
e.session_mut().edit.selection = Range::new(2 * sr, 7 * sr);
e.execute("edit.consolidate", &json!({})).unwrap();
assert_each_track_has(&e, 2 * sr, 7 * sr);
}
#[test]
fn consolidating_past_the_end_of_a_clip_keeps_the_trailing_silence() {
let mut e = stereo_tracks(1);
let sr = sr(&e);
add_clip(&mut e, 0, 1);
e.session_mut().edit.selection = Range::new(0, 5 * sr);
e.execute("edit.consolidate", &json!({})).unwrap();
assert_each_track_has(&e, 0, 5 * sr);
assert_eq!(source_of_only_clip(&e, 0).1, 0.25);
}
#[test]
fn multi_track_consolidate_is_one_undo_step_and_fills_empty_tracks() {
let mut e = stereo_tracks(3);
let sr = sr(&e);
add_clip(&mut e, 1, 2);
let before = e.session().clone();
let undo_depth = e.undo_history().len();
e.session_mut().edit.selection = Range::new(sr, 4 * sr);
e.execute("edit.consolidate", &json!({})).unwrap();
// The clip on track 2 is split at the selection start; the head stays put.
assert_eq!(clips(&e, 0), vec![(sr, 4 * sr)]);
assert_eq!(clips(&e, 1), vec![(0, sr), (sr, 4 * sr)]);
assert_eq!(clips(&e, 2), vec![(sr, 4 * sr)]);
assert_eq!(e.undo_history().len(), undo_depth + 1);
assert!(e.undo());
for (a, b) in e.session().tracks.iter().zip(before.tracks.iter()) {
assert_eq!(a.clips(), b.clips(), "track {}", a.name);
}
}
#[test]
fn a_selection_too_long_to_render_is_refused_not_shortened() {
let mut e = stereo_tracks(2);
add_clip(&mut e, 0, 1);
let before: Vec<_> = (0..2).map(|i| clips(&e, i)).collect();
// A stereo track renders at most MAX_RENDER_SAMPLES / 2 frames (about 93 minutes at 48 kHz).
let max = i64::try_from(soundcraft_mix::MAX_RENDER_SAMPLES / 2).unwrap();
let err = e.execute("edit.consolidate", &json!({"start": 0, "end": max + 1})).unwrap_err();
assert!(matches!(err, EngineError::BadParams(..)), "{err}");
assert!(err.to_string().contains("too long"), "{err}");
let after: Vec<_> = (0..2).map(|i| clips(&e, i)).collect();
assert_eq!(after, before);
}
#[test]
fn prepare_dpe_still_consolidates_each_track_to_its_own_material() {
let mut e = stereo_tracks(2);
let sr = sr(&e);
let a = add_clip(&mut e, 0, 1);
let b = add_clip(&mut e, 1, 3);
e.execute("options.prepare_dpe_tracks", &json!({"tracks": [a.0, b.0]})).unwrap();
// Unchanged from before #164: each track runs to its last clip plus a second, capped at the session end.
assert_eq!(clips(&e, 0), vec![(0, 2 * sr)]);
assert_eq!(clips(&e, 1), vec![(0, 3 * sr)]);
assert_eq!(source_of_only_clip(&e, 0), (2 * sr as usize, 0.25));
}

View File

@ -0,0 +1,30 @@
use serde_json::json;
use soundcraft_engine::{Engine, model::Session};
use soundcraft_time::{FrameRate, SampleRate};
fn feet_frames(engine: &mut Engine, at: i64) -> serde_json::Value {
engine.execute("session.format_time", &json!({"at": at, "format": "feet_frames"})).unwrap()["text"].clone()
}
#[test]
fn current_feet_frames_sets_the_reading_at_the_insertion_point() {
let mut session = Session::new("Feet frames", SampleRate::HZ_48000);
session.frame_rate = FrameRate::Fps30;
let mut engine = Engine::new(session);
engine.execute("edit.select", &json!({"start": 48_000})).unwrap();
assert_eq!(feet_frames(&mut engine, 48_000), "1+14");
let response = engine.execute("setup.current_feet_frames", &json!({"feet_frames": "12+08"})).unwrap();
assert_eq!(response, json!({"feet_frames_start": 170}));
assert_eq!(feet_frames(&mut engine, 48_000), "12+08");
// 30 frames later, at 16 frames per foot.
assert_eq!(feet_frames(&mut engine, 96_000), "14+06");
assert_eq!(engine.session().edit.selection.start, 48_000);
let samples = engine.execute("session.format_time", &json!({"at": 48_000, "format": "samples"})).unwrap();
assert_eq!(samples["text"], "48000");
// Typed positions go through the same reference.
engine.execute("edit.select", &json!({"start": "feet_frames:14+06"})).unwrap();
assert_eq!(engine.session().edit.selection.start, 96_000);
}

View File

@ -45,6 +45,9 @@ const KEYS: &[&str] = &[
"numerator",
"denominator",
"factor",
"x",
"by_px",
"samples_per_px",
"sensitivity",
"rating",
"direction",

View File

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

View File

@ -0,0 +1,30 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_time::Range;
fn render(e: &Engine) -> Vec<Vec<f32>> {
soundcraft_mix::render_range(e.session(), Range::new(0, 72_000), 512)
}
#[test]
fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Timing", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 12_000, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
let baseline = render(&e);
assert!(baseline.iter().flatten().any(|x| x.abs() > 1e-6), "the fixture must make sound");
e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 24_000})).unwrap();
let offset = render(&e);
assert!(offset != baseline, "the offset must change the render");
e.execute("event.midi_track_offsets", &json!({"track": track, "offset": 0})).unwrap();
assert!(render(&e) == baseline, "a zero offset plays as before");
// The offset shifts playback by exactly as much as moving the clip would.
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
}

View File

@ -0,0 +1,20 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, Clip, Session, SourceId, TrackKind};
#[test]
fn trim_to_selection_keeps_clips_sorted() {
let mut session = Session::default();
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let long = session.new_clip_id();
let short = session.new_clip_id();
session.track_mut(track).unwrap().playlist_mut().unwrap().clips =
vec![Clip::audio(long, "long", SourceId(1), 0, 0, 30), Clip::audio(short, "short", SourceId(1), 0, 5, 5)];
let mut engine = Engine::new(session);
engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 15, "end": 20})).unwrap();
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(short, 5, 5), (long, 15, 5)]);
engine.undo();
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
}

View File

@ -43,14 +43,23 @@
//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session);
//! it calls into the plugins and allocates, so a realtime host runs it only on request.
//!
//! A built-in instrument's parameter values are applied the same way. A sample-playing
//! instrument (the Sampler) is handed the decoded audio of its insert's
//! [`Insert::sample`] source straight from the session's pool, shared and never copied.
//!
//! [`Insert::state`]: soundcraft_model::Insert::state
//! [`Insert::sample`]: soundcraft_model::Insert::sample
#![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, db_to_gain};
use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind};
use soundcraft_dsp::{Plugin, SampleSource, db_to_gain};
use soundcraft_model::{
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::ops::Bound;
use std::sync::Arc;
/// Default (stereo) main mix width. The actual width is [`main_channels`].
pub const MAIN_CHANNELS: usize = 2;
@ -187,6 +196,8 @@ struct Strip {
scratch: Vec<f32>,
plugins: Vec<Option<PluginSlot>>,
instrument: Option<PluginSlot>,
/// The pool audio handed to the instrument with `set_sample` (sample-playing instruments).
sample: Option<Arc<SourceAudio>>,
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
held: Vec<u8>,
muted: bool,
@ -241,6 +252,9 @@ impl DelayLine {
}
self.idx = (start + frames) % self.len.max(1);
}
fn clear(&mut self) {
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
}
}
/// Clip Effects: an EQ and a compressor per clip, configured from `edit.values`
@ -261,6 +275,7 @@ impl Strip {
scratch: Vec::new(),
plugins: Vec::new(),
instrument: None,
sample: None,
held: Vec::new(),
muted: false,
gr: 0.0,
@ -333,6 +348,13 @@ impl SlotCtx<'_> {
}
}
/// True when the host handed over an instance for this slot. It replaces whatever the slot
/// holds, even an instance of the same plugin: the host made it because it no longer tracks
/// the old one (it was removed and re-added between two renders), and drives its editor.
fn waiting(&self, t: TrackId, idx: usize) -> bool {
self.external && self.adopt.contains_key(&(t, idx))
}
/// A new instance for `ins` on track `t`, slot `idx`, prepared for `ch` channels.
fn make(&mut self, t: TrackId, idx: usize, ins: &soundcraft_model::Insert, ch: usize) -> Option<PluginSlot> {
let third = is_third_party(&ins.plugin);
@ -463,6 +485,9 @@ impl MixEngine {
i.plugin.reset();
}
s.held.clear();
s.align.clear();
s.align_pre.clear();
s.out_extra.clear();
}
self.meters.clear();
self.main_meter = StripMeter::default();
@ -500,7 +525,7 @@ impl MixEngine {
strip.plugins.resize_with(t.mixer.inserts.len(), || None);
for (idx, (slot, ins)) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()).enumerate() {
match ins {
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) => {
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin || p.ch != ch) || cx.waiting(t.id, idx) => {
let old = slot.take();
cx.retire(old);
*slot = cx.make(t.id, idx, i, ch);
@ -514,11 +539,15 @@ 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));
strip.sample = None;
}
sync_sample(strip, want, s);
}
// Instances nobody claimed (the session changed again before they arrived).
let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect();
@ -750,8 +779,7 @@ impl MixEngine {
let m = build_matrix(src_fmt, src.len(), dst.fmt, dst.buf.len(), &PanSpec { stereo, surround }, law);
apply_matrix(src, frames, &mut dst.buf, &m, gain);
}
let spec = PanSpec { stereo: [pan_at(t, 0, pos), pan_at(t, 1, pos)], surround: t.mixer.surround.as_ref() };
match &t.mixer.output {
let src: &[Vec<f32>] = match &t.mixer.output {
Route::Main if strip.out_extra.len > 0 => {
if strip.outbuf.len() != strip.buf.len() {
strip.outbuf = vec![Vec::new(); strip.buf.len()];
@ -761,20 +789,25 @@ impl MixEngine {
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
}
strip.out_extra.process(&mut strip.outbuf, frames);
let m = build_matrix(src_fmt, strip.outbuf.len(), self.main_fmt, self.main.len(), &spec, law);
apply_matrix(&strip.outbuf, frames, &mut self.main, &m, 1.0);
&strip.outbuf
}
Route::Main => {
let m = build_matrix(src_fmt, strip.buf.len(), self.main_fmt, self.main.len(), &spec, law);
apply_matrix(&strip.buf, frames, &mut self.main, &m, 1.0);
}
Route::Bus(b) => {
if let Some(dst) = self.busses.get_mut(b) {
let m = build_matrix(src_fmt, strip.buf.len(), dst.fmt, dst.buf.len(), &spec, law);
apply_matrix(&strip.buf, frames, &mut dst.buf, &m, 1.0);
}
}
_ => {}
_ => &strip.buf,
};
let (dst, dst_fmt) = match &t.mixer.output {
Route::Main => (&mut self.main, self.main_fmt),
Route::Bus(b) => match self.busses.get_mut(b) {
Some(d) => (&mut d.buf, d.fmt),
None => return,
},
_ => return,
};
let mut start = 0;
while start < frames {
let (at, end) = pan_segment(t, pos, start, frames);
let spec = PanSpec { stereo: [pan_at(t, 0, at), pan_at(t, 1, at)], surround: t.mixer.surround.as_ref() };
let m = build_matrix(src_fmt, src.len(), dst_fmt, dst.len(), &spec, law);
apply_matrix_span(src, start..end, dst, &m, 1.0);
start = end;
}
}
}
@ -839,7 +872,7 @@ fn process_strip(
}
render_clip(s, clip, pos, frames, &mut strip.clipbuf, &mut strip.scratch);
let fx = strip.clip_fx.entry(clip.id.0).or_insert_with(|| ClipFx::new(s.sample_rate.as_f64() as f32, nch));
fx.configure(&params);
fx.configure(params);
fx.process(&mut strip.clipbuf, frames);
for (d, c) in strip.buf.iter_mut().zip(strip.clipbuf.iter()) {
for (x, y) in d.iter_mut().zip(c.iter()).take(frames) {
@ -873,6 +906,14 @@ fn process_strip(
}
TrackKind::Instrument | TrackKind::Midi => {
if let Some(inst) = &mut strip.instrument {
// #152: push the instrument's stored parameter values into the live plugin
// before processing, the same way inserts do below. Without this, the values
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
// `level = -12 dB` sounds the same as `level = -6 dB`.
if let Some(ins) = &t.instrument {
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
}
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
if strip.buf.len() >= 2 {
inst.plugin.process(&mut strip.buf, frames);
@ -959,19 +1000,49 @@ fn process_strip(
}
/// Clip-effect parameters of a clip, or None when it has none or they are bypassed.
fn clip_fx_params(s: &Session, clip: u64) -> Option<Vec<(String, f32)>> {
if s.edit.values.is_empty() {
fn clip_fx_params(s: &Session, clip: u64) -> Option<impl Iterator<Item = (&str, f32)> + Clone> {
if s.edit.values.is_empty() || Key::new(format_args!("clip_fx.bypass.{clip}")).is_none_or(|k| s.edit.flag(k.as_str())) {
return None;
}
let pre = format!("clip_fx.{clip}.");
let v: Vec<(String, f32)> = s
let pre = Key::new(format_args!("clip_fx.{clip}."))?;
let mut params = s
.edit
.values
.range(pre.clone()..)
.take_while(|(k, _)| k.starts_with(&pre))
.map(|(k, v)| (k.get(pre.len()..).unwrap_or("").to_string(), *v as f32))
.collect();
if v.is_empty() || s.edit.flag(&format!("clip_fx.bypass.{clip}")) { None } else { Some(v) }
.range::<str, _>((Bound::Included(pre.as_str()), Bound::Unbounded))
.take_while(move |(k, _)| k.starts_with(pre.as_str()))
.map(move |(k, v)| (k.get(pre.len..).unwrap_or(""), *v as f32))
.peekable();
params.peek()?;
Some(params)
}
/// A short key formatted on the stack: lookups made on the audio thread must not allocate.
#[derive(Clone, Copy)]
struct Key {
buf: [u8; 64],
len: usize,
}
impl Key {
/// None when the key doesn't fit.
fn new(args: std::fmt::Arguments<'_>) -> Option<Key> {
let mut k = Key { buf: [0; 64], len: 0 };
std::fmt::Write::write_fmt(&mut k, args).ok()?;
Some(k)
}
fn as_str(&self) -> &str {
self.buf.get(..self.len).and_then(|b| std::str::from_utf8(b).ok()).unwrap_or("")
}
}
impl std::fmt::Write for Key {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
let end = self.len.checked_add(s.len()).ok_or(std::fmt::Error)?;
self.buf.get_mut(self.len..end).ok_or(std::fmt::Error)?.copy_from_slice(s.as_bytes());
self.len = end;
Ok(())
}
}
impl ClipFx {
@ -985,10 +1056,10 @@ impl ClipFx {
})
}
/// Modules exist only when one of their parameters is set.
fn configure(&mut self, params: &[(String, f32)]) {
fn configure<'a>(&mut self, params: impl Iterator<Item = (&'a str, f32)> + Clone) {
self.gain_db = 0.0;
let has_eq = params.iter().any(|(k, _)| k.starts_with("eq."));
let has_comp = params.iter().any(|(k, _)| k.starts_with("comp."));
let has_eq = params.clone().any(|(k, _)| k.starts_with("eq."));
let has_comp = params.clone().any(|(k, _)| k.starts_with("comp."));
if has_eq && self.eq.is_none() {
self.eq = self.make("eq_7band");
} else if !has_eq {
@ -1002,18 +1073,18 @@ impl ClipFx {
for (k, v) in params {
if let Some(p) = k.strip_prefix("eq.") {
if let Some(eq) = &mut self.eq
&& eq.param(p) != Some(*v)
&& eq.param(p) != Some(v)
{
eq.set_param(p, *v);
eq.set_param(p, v);
}
} else if let Some(p) = k.strip_prefix("comp.") {
if let Some(c) = &mut self.comp
&& c.param(p) != Some(*v)
&& c.param(p) != Some(v)
{
c.set_param(p, *v);
c.set_param(p, v);
}
} else if k == "gain" {
self.gain_db = *v;
self.gain_db = v;
}
}
}
@ -1052,6 +1123,10 @@ fn structure_fingerprint(s: &Session) -> usize {
snd.target.hash(&mut h);
}
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
let sample = t.instrument.as_ref().and_then(|x| x.sample);
sample.hash(&mut h);
// Which decoded audio it is, so new audio under the same id (another session) re-syncs.
sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h);
}
for b in &s.busses {
b.id.hash(&mut h);
@ -1280,9 +1355,27 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
}
}
/// The track's MIDI Track Offset (`midi.offset.<id>` in the edit values), in samples. Looked up
/// without formatting the key, since this runs on the audio thread every block.
fn midi_offset(s: &Session, t: &Track) -> i64 {
const PREFIX: &str = "midi.offset.";
s.edit
.values
.range::<str, _>((std::ops::Bound::Included(PREFIX), std::ops::Bound::Unbounded))
.take_while(|(k, _)| k.starts_with(PREFIX))
.find(|(k, _)| k.get(PREFIX.len()..).and_then(|id| id.parse::<u64>().ok()) == Some(t.id.0))
.map(|(_, v)| *v)
.filter(|v| v.is_finite())
.map_or(0, |v| v as i64)
}
/// Send note on/offs for MIDI clips overlapping the block.
fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mut PluginSlot, held: &mut Vec<u8>) {
let sr = s.sample_rate;
// MIDI Track Offsets: the track's notes sound `offset` samples later (earlier if negative), so
// the block looks that far back into the clips.
let offset = midi_offset(s, t);
let pos = pos.saturating_sub(offset);
let end = pos.saturating_add(frames as i64);
for clip in t.clips() {
if clip.muted {
@ -1294,7 +1387,7 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
let base = s.tempo.samples_to_ticks(clip.start, sr);
// MIDI Real-Time Properties (non-destructive velocity/transpose/duration/delay).
let rtp = |name: &str, d: f64| s.edit.value(&format!("rtp.{}.{name}", t.id.0), d);
let rtp = |name: &str, d: f64| Key::new(format_args!("rtp.{}.{name}", t.id.0)).map_or(d, |k| s.edit.value(k.as_str(), d));
let (dv, dp, dur, delay) = if s.edit.values.is_empty() {
(0i64, 0i64, 1.0f64, 0i64)
} else {
@ -1330,6 +1423,40 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
}
/// Session audio shared with a sample-playing instrument.
struct PoolSample(Arc<SourceAudio>);
impl SampleSource for PoolSample {
fn sample_rate(&self) -> f32 {
self.0.buffer.sample_rate as f32
}
fn num_channels(&self) -> usize {
self.0.buffer.num_channels()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.0.channel(ch)
}
}
/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new
/// sample, a cleared one, or a new instance). Missing media plays nothing.
fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) {
let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id));
let same = match (strip.sample.as_ref(), audio) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if same {
return;
}
if let Some(inst) = strip.instrument.as_mut() {
let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc<dyn SampleSource>);
inst.plugin.set_sample(shared);
}
strip.sample = audio.cloned();
}
/// Record insert latency without processing audio, so this block can compensate for it.
fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
let mut latency = 0usize;
@ -1345,20 +1472,31 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
}
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
let slot_u8 = u8::try_from(i).unwrap_or(0);
// Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it
// must not pick up slot A's lanes.
let Ok(slot_u8) = u8::try_from(i) else {
return apply_values(slot, ins, |_, v| v);
};
let automated = t.mixer.automation_mode.reads()
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
let mut val = *v;
apply_values(slot, ins, |k, val| {
if automated
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
&& !l.points.is_empty()
{
val = l.value_at(pos, val);
return l.value_at(pos, val);
}
val
});
}
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
let val = value(k, *v);
if val.to_bits() != last.to_bits() {
slot.plugin.set_param(k, val);
*last = val;
@ -1388,6 +1526,23 @@ fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
base
}
/// Pan automation is read on a grid of [`PAN_STEP`] timeline samples rather than once per block,
/// so playback (callback-sized blocks) and bounces (1024-frame blocks) pan identically.
const PAN_STEP: Samples = 64;
/// The position to read the pan at for the frames from `start` of the block at `pos`, and the end
/// of the frames that share it. Without pan automation the whole block shares one pan.
fn pan_segment(t: &Track, pos: Samples, start: usize, frames: usize) -> (Samples, usize) {
let automated = t.mixer.automation_mode.reads() && t.automation.iter().any(|l| matches!(l.param, AutoParam::Pan(_)) && !l.points.is_empty());
if !automated {
return (pos, frames);
}
let here = pos.saturating_add(start as i64);
let at = here.div_euclid(PAN_STEP).saturating_mul(PAN_STEP);
let left = usize::try_from(at.saturating_add(PAN_STEP).saturating_sub(here)).unwrap_or(1).max(1);
(at, start.saturating_add(left).min(frames))
}
fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
if t.mixer.automation_mode.reads()
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
@ -1637,6 +1792,11 @@ fn build_matrix(src_fmt: ChannelFormat, ns: usize, dst_fmt: ChannelFormat, nd: u
/// `dst[d] += Σ_s src[s] × gain × m[s][d]` for every destination channel (no allocation).
fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
apply_matrix_span(src, 0..frames, dst, m, gain);
}
/// [`apply_matrix`] over the frames in `span` only.
fn apply_matrix_span(src: &[Vec<f32>], span: std::ops::Range<usize>, dst: &mut [Vec<f32>], m: &Matrix, gain: f32) {
let ns = src.len().min(MAX_CHANNELS);
for (d, out) in dst.iter_mut().enumerate().take(MAX_CHANNELS) {
let mut idx = [(0usize, 0.0f32); MAX_CHANNELS];
@ -1654,7 +1814,7 @@ fn apply_matrix(src: &[Vec<f32>], frames: usize, dst: &mut [Vec<f32>], m: &Matri
if taps.is_empty() {
continue;
}
for (i, o) in out.iter_mut().enumerate().take(frames) {
for (i, o) in out.iter_mut().enumerate().take(span.end).skip(span.start) {
let mut acc = 0.0f32;
for &(s, g) in taps {
acc += src.get(s).and_then(|c| c.get(i)).copied().unwrap_or(0.0) * gain * g;
@ -1782,17 +1942,14 @@ pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>>
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
let mut scratch = Vec::new();
for c in t.clips() {
if c.muted {
continue;
}
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
match clip_fx_params(s, c.id.0) {
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
Some(params) => {
let mut tmp = vec![vec![0.0f32; len]; out.len()];
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
fx.configure(&params);
fx.configure(params);
let mut done = 0;
while done < len {
let n = (len - done).min(4096);
@ -1906,6 +2063,46 @@ mod tests {
use soundcraft_model::{Insert, SourceAudio, SourceId};
use std::sync::Arc;
fn synth_session(level_db: f32) -> Session {
use soundcraft_midi::{Note, Sequence};
let mut s = Session::default();
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
let mut seq = Sequence::default();
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 });
let cid = s.new_clip_id();
let tr = s.track_mut(t).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq));
let mut ins = Insert::new("subtractive_synth");
ins.params.insert("level".into(), level_db);
tr.instrument = Some(ins);
s
}
#[test]
fn stored_instrument_params_reach_the_plugin() {
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
// RMS over the held part of the note, past any parameter smoothing.
let rms = |db: f32| {
let out = render_range(&synth_session(db), Range::new(0, 12_000), 512);
let tail = out[0].get(6_000..).unwrap_or_default();
(tail.iter().map(|x| x * x).sum::<f32>() / tail.len().max(1) as f32).sqrt()
};
let (loud, quiet) = (rms(-6.0), rms(-12.0));
assert!(loud > 0.0, "the synth should sound");
assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
}
#[test]
fn insert_slot_a_automation_does_not_drive_the_instrument() {
let mut s = synth_session(-6.0);
let reference = render_range(&s, Range::new(0, 12_000), 512);
let tr = &mut s.tracks[0];
let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() });
lane.set_point(0, -48.0);
tr.automation.push(lane);
assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference);
}
fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
@ -1953,6 +2150,22 @@ mod tests {
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
}
#[test]
fn pan_automation_does_not_depend_on_the_block_size() {
// Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244).
let (mut s, t) = session_with_dc(0.5, 4800);
{
let l = s.track_mut(t).unwrap().lane_mut(&AutoParam::Pan(0));
l.set_point(0, -1.0);
l.set_point(480, 1.0);
}
let bounce = render_range(&s, Range::new(0, 4800), 1024);
assert!(bounce[0][600].abs() < 1e-4 && bounce[1][600] > 0.49, "{} {}", bounce[0][600], bounce[1][600]);
for block in [512, 100, 37] {
assert_eq!(render_range(&s, Range::new(0, 4800), block), bounce, "block {block}");
}
}
#[test]
fn fades_and_clip_gain_apply() {
let (mut s, t) = session_with_dc(1.0, 48_000);
@ -2058,6 +2271,55 @@ mod tests {
}
}
struct Keys {
inner: Box<dyn Plugin>,
heard: Arc<std::sync::Mutex<Vec<(u8, u8)>>>,
}
impl Plugin for Keys {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.inner.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.inner.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.inner.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.inner.set_param(id, v)
}
fn param(&self, id: &str) -> Option<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.inner.process(io, frames);
}
fn note_on(&mut self, _offset: usize, note: u8, velocity: u8) {
self.heard.lock().unwrap().push((note, velocity));
}
}
#[test]
fn real_time_properties_reach_the_instrument() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.keys"));
let mut seq = soundcraft_midi::Sequence::default();
seq.notes.push(soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(cid, "m", 0, 48_000, seq));
s.edit.values.insert(format!("rtp.{}.transpose", t.0), 12.0);
s.edit.values.insert(format!("rtp.{}.velocity", t.0), -20.0);
let heard = Arc::new(std::sync::Mutex::new(Vec::new()));
let keys = Keys { inner: soundcraft_dsp::create("gain").unwrap(), heard: Arc::clone(&heard) };
let mut eng = MixEngine::new(48_000.0, 512);
eng.set_external_instances(true);
eng.adopt(vec![PreparedInstance { track: t, slot: INSTRUMENT_SLOT, id: "clap:test.keys".into(), ch: 2, plugin: Box::new(keys) }]);
render_block(&mut eng, &s, 0);
assert_eq!(*heard.lock().unwrap(), vec![(72, 80)]);
}
fn render_block(eng: &mut MixEngine, s: &Session, pos: i64) -> f32 {
let mut out = vec![vec![0.0f32; 512]; 2];
eng.render(s, pos, 512, &mut out);
@ -2108,6 +2370,31 @@ mod tests {
assert!(eng.drain_plugins().is_empty());
}
#[test]
fn a_new_instance_replaces_a_matching_one_in_its_slot() {
// The host removed and re-added the plugin, and both session updates reached the audio
// thread in one go: the slot still holds the old instance (same id, same channels), but
// the host made a new one and drives its editor. The new one must win, or the editor
// talks to an instance that was retired ("the plugin is gone").
let (mut s, t) = session_with_dc(0.5, 48_000);
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.fake"));
let mut eng = MixEngine::new(48_000.0, 512);
eng.set_external_instances(true);
let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let make = |blob: &[u8]| {
let mut gain = soundcraft_dsp::create("gain").unwrap();
gain.prepare(48_000.0, 512, 1);
Box::new(Stateful { inner: gain, blob: blob.to_vec(), sets: Arc::clone(&sets) })
};
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"old") }]);
render_block(&mut eng, &s, 0);
assert_eq!(eng.capture_states(), vec![(t, 0, b"old".to_vec())]);
eng.adopt(vec![PreparedInstance { track: t, slot: 0, id: "clap:test.fake".into(), ch: 1, plugin: make(b"new") }]);
render_block(&mut eng, &s, 512);
assert_eq!(eng.capture_states(), vec![(t, 0, b"new".to_vec())]);
assert_eq!(eng.take_retired().len(), 1, "the old instance is retired");
}
#[test]
fn stored_state_comes_first_then_session_params() {
let (mut s, t) = session_with_dc(0.5, 48_000);
@ -2177,6 +2464,27 @@ mod tests {
assert_ne!(argmax(&out[0]), argmax(&out[1]));
}
/// A reset (stop, locate) must not leave the previous position's audio in the alignment delay.
#[test]
fn reset_clears_delay_compensation_history() {
let (mut s, t) = session_with_dc(0.5, 4800);
s.edit.click = false;
s.track_mut(t).unwrap().mixer.pan = vec![1.0];
let latent = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut comp = Insert::new("compressor");
comp.params.insert("lookahead".into(), 5.0);
s.track_mut(latent).unwrap().mixer.inserts[0] = Some(comp);
let mut eng = MixEngine::new(48_000.0, 512);
let mut out = vec![vec![0.0f32; 512]; 2];
for b in 0..9 {
eng.render(&s, b * 512, 512, &mut out);
}
assert!(eng.latency > 0 && out[1][500] > 0.3, "the fixture plays through the alignment delay");
eng.reset();
eng.render(&s, 1_000_000, 512, &mut out);
assert!(out.iter().flatten().all(|x| *x == 0.0), "old aligned audio after a reset");
}
fn impulse_at(ch: &[f32]) -> Vec<usize> {
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
}

View File

@ -204,21 +204,28 @@ impl AutomationLane {
/// Thin: remove points whose removal changes the curve by less than `tolerance`.
pub fn thin(&mut self, tolerance: f32) -> usize {
if self.points.len() < 3 {
if self.points.len() < 3 || tolerance.is_nan() {
return 0;
}
let tolerance = f64::from(tolerance);
let mut keep = vec![true; self.points.len()];
let mut last = 0usize;
// Slopes from the last kept point that keep every point removed since then within `tolerance`.
let (mut lo, mut hi) = (f64::NEG_INFINITY, f64::INFINITY);
for i in 1..self.points.len() - 1 {
let (Some(a), Some(b), Some(c)) = (self.points.get(last), self.points.get(i), self.points.get(i + 1)) else { continue };
let span = (c.at - a.at) as f32;
let interp = if span > 0.0 { a.value + (c.value - a.value) * ((b.at - a.at) as f32 / span) } else { a.value };
if (interp - b.value).abs() <= tolerance {
let dt = b.at.saturating_sub(a.at) as f64;
let rise = f64::from(b.value) - f64::from(a.value);
lo = lo.max((rise - tolerance) / dt);
hi = hi.min((rise + tolerance) / dt);
let slope = (f64::from(c.value) - f64::from(a.value)) / c.at.saturating_sub(a.at) as f64;
if (lo..=hi).contains(&slope) {
if let Some(k) = keep.get_mut(i) {
*k = false;
}
} else {
last = i;
(lo, hi) = (f64::NEG_INFINITY, f64::INFINITY);
}
}
let before = self.points.len();
@ -279,6 +286,23 @@ mod tests {
assert_eq!(l.points.len(), 2);
}
#[test]
fn thin_keeps_removed_points_within_tolerance() {
for direction in [1.0, -1.0] {
let mut lane = AutomationLane::new(AutoParam::Volume);
for (at, value) in [(0, 0.0), (100, 0.0), (200, 0.0), (300, 0.12), (400, 0.26)] {
lane.set_point(at, value * direction);
}
let original = lane.points.clone();
let tolerance = 0.1;
assert!(lane.thin(tolerance) > 0);
for point in original {
let error = (lane.value_at(point.at, 0.0) - point.value).abs();
assert!(error <= tolerance, "at {}: error {error} exceeds {tolerance}", point.at);
}
}
}
#[test]
fn param_ids() {
assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1)));

View File

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

View File

@ -22,7 +22,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, ZoomToggle};
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};

View File

@ -1,6 +1,6 @@
//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo.
use crate::{AutomationMode, BusId};
use crate::{AutomationMode, BusId, SourceId};
use std::collections::BTreeMap;
pub const INSERT_SLOTS: usize = 10;
@ -425,7 +425,7 @@ pub enum Route {
pub struct Insert {
/// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`).
pub plugin: String,
#[serde(default)]
#[serde(default, deserialize_with = "finite_params")]
pub params: BTreeMap<String, f32>,
#[serde(default)]
pub bypass: bool,
@ -438,6 +438,9 @@ pub struct Insert {
/// state), standard base64. Restored into a new instance before it first processes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// The session audio source a sample-playing instrument (the built-in Sampler) plays.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample: Option<SourceId>,
}
fn yes() -> bool {
@ -446,7 +449,15 @@ fn yes() -> bool {
impl Insert {
pub fn new(plugin: impl Into<String>) -> Self {
Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("<factory default>"), state: None }
Insert {
plugin: plugin.into(),
params: BTreeMap::new(),
bypass: false,
active: true,
preset: String::from("<factory default>"),
state: None,
sample: None,
}
}
/// The decoded plugin state (`None` when absent or malformed).
@ -577,6 +588,13 @@ pub(crate) fn finite_or(v: f32, d: f32) -> f32 {
if v.is_finite() { v } else { d }
}
/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so
/// they keep their defaults instead of failing the whole session.
fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f32>, D::Error> {
let params: BTreeMap<String, Option<f64>> = serde::Deserialize::deserialize(d)?;
Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect())
}
/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12).
pub fn fader_pos_to_db(pos: f32) -> f32 {
let p = finite_or(pos, 0.0).clamp(0.0, 1.0);
@ -624,6 +642,16 @@ mod tests {
assert_eq!(bad.state_bytes(), None);
}
#[test]
fn params_saved_as_null_or_out_of_range_are_dropped() {
let mut i = Insert::new("eq_7band");
i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]);
let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)]));
let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap();
assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)]));
}
#[test]
fn fader_taper_round_trips() {
for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] {

View File

@ -9,6 +9,9 @@ use soundcraft_time::{FrameRate, GridValue, Range, SampleRate, Samples, TempoMap
/// Native session file extension.
pub const SESSION_EXTENSION: &str = "scraft";
/// `EditState::values` key holding the feet+frames reading of sample 0, in frames.
pub const FEET_FRAMES_START: &str = "feet_frames.start";
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum SessionError {
#[error("not a SoundCraft session: {0}")]
@ -168,6 +171,9 @@ pub struct EditState {
/// Clips explicitly selected (Grabber selection).
#[serde(default)]
pub selected_clips: Vec<ClipId>,
/// Audio files selected in the Clip List.
#[serde(default)]
pub selected_sources: Vec<SourceId>,
/// Playhead position when stopped.
pub playhead: Samples,
pub grid: GridValue,
@ -244,6 +250,7 @@ impl Default for EditState {
selection: Range::default(),
selected_tracks: Vec::new(),
selected_clips: Vec::new(),
selected_sources: Vec::new(),
playhead: 0,
grid: GridValue::default(),
nudge: GridValue::Seconds(1.0),
@ -319,6 +326,9 @@ pub struct Session {
pub midi_originals: std::collections::BTreeMap<u64, soundcraft_midi::Sequence>,
#[serde(skip)]
pub pool: SourcePool,
/// The folder relative source paths point into: where the session was opened or last saved.
#[serde(skip)]
pub folder: Option<std::path::PathBuf>,
}
impl Default for Session {
@ -352,6 +362,7 @@ impl Session {
comments: String::new(),
midi_originals: std::collections::BTreeMap::new(),
pool: SourcePool::default(),
folder: None,
};
s.edit.pre_roll = sample_rate.samples(2.0);
s.edit.post_roll = sample_rate.samples(2.0);
@ -410,11 +421,35 @@ impl Session {
self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id)))
}
/// Clips on every playlist, alternates included.
pub fn all_clips(&self) -> impl Iterator<Item = &Clip> {
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips))
}
pub fn clips_using(&self, src: SourceId) -> impl Iterator<Item = &Clip> {
self.all_clips().filter(move |c| c.source() == Some(src))
}
/// End of the last clip on any track (session length).
pub fn content_end(&self) -> Samples {
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
}
/// Feet+frames reading of sample 0, in frames (Setup › Current Feet+Frames Position).
pub fn feet_frames_start(&self) -> i64 {
self.edit.value(FEET_FRAMES_START, 0.0) as i64
}
/// Format a position against this session's timecode and feet+frames references.
pub fn format_position(&self, at: Samples, fmt: TimeFormat) -> String {
soundcraft_time::format_position(at, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
}
/// Parse a position typed in `fmt` against this session's references.
pub fn parse_position(&self, text: &str, fmt: TimeFormat) -> Result<Samples, soundcraft_time::TimeError> {
soundcraft_time::parse_position(text, fmt, self.sample_rate, &self.tempo, self.frame_rate, self.timecode_start, self.feet_frames_start())
}
pub fn next_track_color(&self) -> [u8; 3] {
let n = self.tracks.iter().filter(|t| t.kind != TrackKind::Master).count();
TRACK_COLORS.get(n % TRACK_COLORS.len()).copied().unwrap_or([128, 128, 128])

View File

@ -9,6 +9,7 @@ repository.workspace = true
[dependencies]
soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-dsp.workspace = true
@ -20,8 +21,5 @@ cpal.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
cpal = { workspace = true, features = ["wasm-bindgen"] }
[dev-dependencies]
soundcraft-audio-io.workspace = true
[lints]
workspace = true

View File

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

View File

@ -1,22 +1,78 @@
//! Audio input capture for recording.
//!
//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared
//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is
//! kept in a small local backlog and appended next time, so nothing blocks the audio thread.
//! A [`Recorder`] opens the default input device. While armed, the input callback copies each
//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread
//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves
//! a playable file.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
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::time::{Duration, Instant};
/// Captured audio: planar f32 at `sample_rate`.
#[derive(Debug, Clone, Default)]
pub struct Take {
pub sample_rate: u32,
pub channels: Vec<Vec<f32>>,
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>);
const RING_SECONDS: usize = 4;
/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots
/// are atomics, so it needs no lock and no `unsafe`.
struct CaptureRing {
slots: Box<[AtomicU32]>,
mask: usize,
head: AtomicUsize,
tail: AtomicUsize,
}
impl CaptureRing {
fn new(capacity: usize) -> CaptureRing {
let n = capacity.max(2).next_power_of_two();
CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) }
}
/// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit.
fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize {
let head = self.head.load(Ordering::Relaxed);
let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire))));
for i in 0..n {
if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) {
slot.store(sample(i).to_bits(), Ordering::Relaxed);
}
}
self.head.store(head.wrapping_add(n), Ordering::Release);
n
}
fn pop_all(&self, out: &mut Vec<f32>) {
let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire));
out.extend(
(0..head.wrapping_sub(tail))
.map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))),
);
self.tail.store(head, Ordering::Release);
}
}
struct Shared {
armed: AtomicBool,
data: Mutex<Vec<f32>>,
ring: CaptureRing,
/// Samples lost while the ring was full.
dropped: AtomicUsize,
}
impl Shared {
/// 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 };
*owed = owed.saturating_add(data.len() - kept);
self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed);
}
}
pub struct Recorder {
@ -28,12 +84,9 @@ pub struct Recorder {
pub device_name: String,
pub sample_rate: u32,
pub channels: usize,
writer: Option<std::thread::JoinHandle<Takes>>,
}
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`.
const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 };
impl Recorder {
/// Open the default input device. Errors when there is none (the caller shows a message).
#[cfg(not(target_os = "freebsd"))]
@ -48,29 +101,23 @@ impl Recorder {
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
}
let config = cfg.config();
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
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 monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
let mon = Arc::clone(&monitor);
let mut backlog: Vec<f32> = Vec::new();
let mut owed = 0;
let stream = device
.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
mon.push(data);
if !sh.armed.load(Ordering::Relaxed) {
backlog.clear();
return;
}
if let Ok(mut d) = sh.data.try_lock() {
if d.len() < MAX_SAMPLES {
d.extend_from_slice(&backlog);
d.extend_from_slice(data);
}
backlog.clear();
} else if backlog.len() < 1 << 20 {
backlog.extend_from_slice(data);
if sh.armed.load(Ordering::Acquire) {
sh.capture(data, &mut owed);
} else {
owed = 0;
}
},
|e| {
@ -82,14 +129,7 @@ impl Recorder {
)
.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: usize::from(config.channels).max(1),
})
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None })
}
#[cfg(target_os = "freebsd")]
@ -97,51 +137,174 @@ impl Recorder {
Err("recording is not supported on this platform yet".into())
}
/// Start capturing (clears any previous take).
pub fn arm(&self) {
self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear();
self.shared.armed.store(true, Ordering::Relaxed);
/// Create each take's file and start recording into them: a track's take records the given
/// input channels into a new file.
pub fn arm(&mut self, takes: Vec<(TrackId, Vec<usize>, PathBuf)>) -> Result<(), String> {
if self.writer.is_some() {
return Err("already recording".into());
}
let takes =
takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::<Result<Vec<_>, _>>()?;
let ring = &self.shared.ring;
ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release);
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),
Err(e) => {
self.shared.armed.store(false, Ordering::Release);
return Err(e.to_string());
}
}
Ok(())
}
pub fn is_armed(&self) -> bool {
self.shared.armed.load(Ordering::Relaxed)
}
/// Stop capturing and return the take.
pub fn take(&self) -> Take {
self.shared.armed.store(false, Ordering::Relaxed);
let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner));
deinterleave(&data, self.channels, self.sample_rate)
}
/// Samples captured so far (per channel).
pub fn captured_frames(&self) -> usize {
self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.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 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));
(takes, problem.or(slow))
}
}
pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take {
let ch = channels.max(1);
let frames = data.len() / ch;
let mut out = vec![Vec::with_capacity(frames); ch];
for frame in data.chunks_exact(ch) {
for (c, v) in frame.iter().enumerate() {
if let Some(o) = out.get_mut(c) {
o.push(*v);
}
impl Drop for Recorder {
fn drop(&mut self) {
self.finish();
}
}
fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) -> Takes {
let (mut block, mut header_written) = (Vec::new(), Instant::now());
loop {
let stopping = !shared.armed.load(Ordering::Acquire);
// The ring holds interleaved samples and a pop can end mid-frame: write whole frames and
// keep the remainder for the next pop, so channels never shift.
shared.ring.pop_all(&mut block);
let whole = block.len() - block.len() % channels.max(1);
takes.iter_mut().for_each(|t| t.write(&block[..whole], channels));
block.drain(..whole);
if stopping {
break;
}
if header_written.elapsed() >= Duration::from_secs(1) {
header_written = Instant::now();
takes.iter_mut().for_each(TakeWriter::write_header);
}
std::thread::sleep(Duration::from_millis(10));
}
let (mut done, mut problem) = (Vec::new(), None);
for mut t in takes {
t.write_header();
if t.frames == 0 {
let _ = std::fs::remove_file(&t.path);
} else {
done.push((t.id, t.path));
}
problem = problem.or(t.error);
}
(done, problem)
}
/// One take's file. After an error it stops writing; its header keeps covering what was written.
struct TakeWriter {
id: TrackId,
inputs: Vec<usize>,
path: PathBuf,
file: File,
rate: u32,
frames: u64,
error: Option<String>,
}
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 };
take.write_header();
take.error.take().map_or(Ok(take), Err)
}
/// Append this take's channels of `block` (interleaved, `channels` wide).
fn write(&mut self, block: &[f32], channels: usize) {
let channels = channels.max(1);
if self.error.is_some() {
return;
}
let bytes: Vec<u8> = block
.chunks_exact(channels)
.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,
Err(e) => self.fail(e),
}
}
Take { sample_rate, channels: out }
fn write_header(&mut self) {
let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) {
Ok(h) => h,
Err(e) => return self.fail(e),
};
let f = &mut self.file;
if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) {
self.fail(e);
}
}
fn fail(&mut self, e: impl std::fmt::Display) {
self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display()));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn decode(path: &PathBuf) -> Vec<Vec<f32>> {
soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels
}
#[test]
fn deinterleaves() {
let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000);
assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]);
let t = super::deinterleave(&[], 0, 48_000);
assert_eq!(t.channels.len(), 1);
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 (mut owed, mut out) = (0, Vec::new());
shared.capture(&[1.0; 6], &mut owed);
shared.capture(&[2.0; 4], &mut owed);
shared.ring.pop_all(&mut out);
shared.capture(&[3.0; 2], &mut owed);
shared.ring.pop_all(&mut out);
assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat());
assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2));
}
#[test]
fn takes_stream_into_their_files() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap();
let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap();
assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file");
vox.write(&[0.5; 300], 3);
vox.write_header();
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 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();
let mut owed = 0;
input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed));
shared.armed.store(false, Ordering::Release);
let (done, problem) = writer.join().unwrap();
let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::<Vec<f32>>();
assert_eq!((done.len(), problem), (2, None));
assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]);
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);
}
}

View File

@ -50,14 +50,15 @@ fn min_secs(s: Samples, sr: SampleRate) -> String {
format!("{}{mins}:{secs:02}.{ms:03}", if neg { "-" } else { "" })
}
/// Format an absolute position.
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> String {
/// Format an absolute position. `tc_start` is the timecode of sample 0 in samples, `ff_start` the
/// feet+frames reading of sample 0 in frames.
pub fn format_position(s: Samples, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples, ff_start: i64) -> String {
match fmt {
TimeFormat::Samples => s.to_string(),
TimeFormat::MinSecs => min_secs(s, sr),
TimeFormat::Timecode => Timecode::from_samples(s.saturating_add(tc_start), rate, sr).format(rate),
TimeFormat::FeetFrames => {
let (ft, fr) = feet_frames(s, rate, sr);
let (ft, fr) = feet_frames(s, rate, sr, ff_start);
format!("{ft}+{fr:02}")
}
TimeFormat::BarsBeats => {
@ -80,12 +81,20 @@ pub fn format_length(start: Samples, len: Samples, fmt: TimeFormat, sr: SampleRa
format!("{bars}| {}| {:03}", rem / m.ticks_per_beat().max(1), rem % m.ticks_per_beat().max(1))
}
TimeFormat::Timecode => Timecode::from_samples(len, rate, sr).format(rate),
other => format_position(len, other, sr, map, rate, 0),
other => format_position(len, other, sr, map, rate, 0, 0),
}
}
/// Parse a position typed into a counter or dialog.
pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMap, rate: FrameRate, tc_start: Samples) -> Result<Samples, TimeError> {
pub fn parse_position(
text: &str,
fmt: TimeFormat,
sr: SampleRate,
map: &TempoMap,
rate: FrameRate,
tc_start: Samples,
ff_start: i64,
) -> Result<Samples, TimeError> {
let t = text.trim();
let err = || TimeError::Parse(t.to_string(), fmt.label());
match fmt {
@ -110,7 +119,7 @@ pub fn parse_position(text: &str, fmt: TimeFormat, sr: SampleRate, map: &TempoMa
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
let fr: i64 = fr.trim().parse().map_err(|_| err())?;
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr), sr))
Ok(rate.samples_at_frame(ft.saturating_mul(16).saturating_add(fr).saturating_sub(ff_start), sr))
}
TimeFormat::BarsBeats => {
let parts: Vec<i64> =
@ -136,11 +145,20 @@ mod tests {
#[test]
fn formats_like_pro_tools_counters() {
let (sr, map, rate) = ctx();
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0), "12345");
assert_eq!(format_position(0, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "0:00.000");
assert_eq!(format_position(48_000 * 75 + 24_000, TimeFormat::MinSecs, sr, &map, rate, 0, 0), "1:15.500");
assert_eq!(format_position(0, TimeFormat::BarsBeats, sr, &map, rate, 0, 0), "1| 1| 000");
assert_eq!(format_position(48_000, TimeFormat::Timecode, sr, &map, rate, 0, 0), "00:00:01:00");
assert_eq!(format_position(12345, TimeFormat::Samples, sr, &map, rate, 0, 0), "12345");
}
#[test]
fn minimum_bar_parses_without_overflow() {
let (sr, map, rate) = ctx();
let p = |t: &str| parse_position(t, TimeFormat::BarsBeats, sr, &map, rate, 0, 0);
assert!(p("-9223372036854775808|01|000").is_ok_and(|s| s < 0));
assert_eq!(p("2|3|000").ok(), Some(144_000));
assert_eq!(p("0|1|000").ok(), Some(-96_000));
}
#[test]
@ -148,8 +166,8 @@ mod tests {
let (sr, map, rate) = ctx();
for fmt in TimeFormat::ALL {
for s in [0i64, 48_000, 96_000 * 3, 1_440_000] {
let text = format_position(s, fmt, sr, &map, rate, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0).unwrap();
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
assert!((back - s).abs() <= 48, "{fmt:?} {s} → {text} → {back}");
}
}
@ -158,9 +176,9 @@ mod tests {
#[test]
fn parse_rejects_garbage() {
let (sr, map, rate) = ctx();
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0).is_err());
assert!(parse_position("abc", TimeFormat::Samples, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("1:x", TimeFormat::MinSecs, sr, &map, rate, 0, 0).is_err());
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
}
#[test]

View File

@ -86,6 +86,24 @@ impl GridValue {
}
}
/// The next larger or smaller value of the same kind, or `self` at either end. Note values keep their
/// dot or triplet; seconds, frames and samples step by tens.
pub fn stepped(self, larger: bool) -> GridValue {
fn next<T: PartialOrd + Copy>(ladder: &[T], v: T, larger: bool) -> Option<T> {
if larger { ladder.iter().copied().find(|x| *x > v) } else { ladder.iter().rev().copied().find(|x| *x < v) }
}
match self {
GridValue::Note { value, dotted, triplet } => {
let i = NoteValue::ALL.iter().position(|v| *v == value).unwrap_or(0);
let j = if larger { i.checked_sub(1) } else { i.checked_add(1) };
j.and_then(|j| NoteValue::ALL.get(j)).map_or(self, |&value| GridValue::Note { value, dotted, triplet })
}
GridValue::Seconds(s) => next(&[0.001, 0.01, 0.1, 1.0, 10.0], s, larger).map_or(self, GridValue::Seconds),
GridValue::Frames(f) => next(&[1, 10, 100], f, larger).map_or(self, GridValue::Frames),
GridValue::Samples(n) => next(&[1, 10, 100, 1_000, 10_000, 100_000], n, larger).map_or(self, GridValue::Samples),
}
}
fn note_ticks(value: NoteValue, dotted: bool, triplet: bool, map: &TempoMap, at_tick: i64) -> i64 {
let base = if value == NoteValue::Bar { map.meter_at_tick(at_tick).ticks_per_bar() } else { value.ticks() };
let mut t = base;
@ -199,4 +217,19 @@ mod tests {
let d = GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false };
assert_eq!(d.step_samples(0, sr, &map, rate), 36_000);
}
#[test]
fn values_step_to_the_next_larger_or_smaller() {
let note = |value, triplet| GridValue::Note { value, dotted: false, triplet };
assert_eq!(note(NoteValue::Sixteenth, false).stepped(true), note(NoteValue::Eighth, false));
assert_eq!(note(NoteValue::Eighth, true).stepped(false), note(NoteValue::Sixteenth, true));
assert_eq!(note(NoteValue::Bar, false).stepped(true), note(NoteValue::Bar, false));
assert_eq!(note(NoteValue::SixtyFourth, false).stepped(false), note(NoteValue::SixtyFourth, false));
assert_eq!(GridValue::Seconds(0.01).stepped(true), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(0.5).stepped(false), GridValue::Seconds(0.1));
assert_eq!(GridValue::Seconds(10.0).stepped(true), GridValue::Seconds(10.0));
assert_eq!(GridValue::Frames(1).stepped(true), GridValue::Frames(10));
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
}
}

View File

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

View File

@ -109,7 +109,7 @@ impl FrameRate {
return 0;
}
let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
i64::try_from(v).unwrap_or(0)
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
}
/// Samples per frame (fractional).
@ -153,13 +153,14 @@ impl Timecode {
/// Absolute frame count of this label.
pub fn to_frames(&self, rate: FrameRate) -> i64 {
let base = rate.nominal().max(1);
let total_min = self.hours * 60 + self.minutes;
let mut f = (total_min * 60 + self.seconds) * base + self.frames;
let total_min = i128::from(self.hours) * 60 + i128::from(self.minutes);
let mut f = (total_min * 60 + i128::from(self.seconds)) * i128::from(base) + i128::from(self.frames);
if rate.is_drop() {
let drop = if base >= 60 { 4 } else { 2 };
f -= drop * (total_min - total_min / 10);
}
if self.negative { -f } else { f }
let f = if self.negative { -f } else { f };
i64::try_from(f).unwrap_or(if f < 0 { i64::MIN } else { i64::MAX })
}
pub fn from_samples(s: Samples, rate: FrameRate, sr: SampleRate) -> Timecode {
@ -202,9 +203,9 @@ impl Timecode {
}
}
/// Feet+Frames for 35 mm film (16 frames per foot).
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate) -> (i64, i64) {
let f = rate.frames_at(s, sr);
/// Feet+Frames for 35 mm film (16 frames per foot), `start` frames in at sample 0.
pub fn feet_frames(s: Samples, rate: FrameRate, sr: SampleRate, start: i64) -> (i64, i64) {
let f = rate.frames_at(s, sr).saturating_add(start);
(f.div_euclid(16), f.rem_euclid(16))
}
@ -233,6 +234,19 @@ mod tests {
}
}
#[test]
fn huge_timecode_saturates_instead_of_overflowing() {
let sr = SampleRate::HZ_48000;
for r in [FrameRate::Fps30, FrameRate::Fps29_97Drop] {
let tc = Timecode::parse("9223372036854775807:00:00:00").unwrap();
assert_eq!(tc.to_samples(r, sr), i64::MAX);
let tc = Timecode::parse("-9223372036854775807:00:00:00").unwrap();
assert_eq!(tc.to_samples(r, sr), i64::MIN);
}
let tc = Timecode::parse("01:02:03:04").unwrap();
assert_eq!(tc.to_samples(FrameRate::Fps30, sr), 178_710_400);
}
#[test]
fn parse_variants() {
let tc = Timecode::parse("01:02:03:04").unwrap();
@ -246,6 +260,6 @@ mod tests {
#[test]
fn feet_and_frames() {
let sr = SampleRate::HZ_48000;
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr), (1, 8));
assert_eq!(feet_frames(48_000, FrameRate::Fps24, sr, 0), (1, 8));
}
}

View File

@ -0,0 +1,72 @@
//! Meter changes stay on bar lines when an earlier meter is edited (issue #250).
use soundcraft_time::{BarBeat, MeterEvent, SampleRate, TempoMap};
fn assert_positions_round_trip(map: &TempoMap) {
for bar in 1..=5 {
let meter = map.meter_at_tick(map.bar_start_tick(bar));
for beat in 1..=i64::from(meter.numerator) {
let position = BarBeat { bar, beat, tick: 0 };
assert_eq!(map.bar_beat_at_tick(map.tick_at_bar_beat(position)), position);
}
}
}
fn meter_bars(map: &TempoMap) -> Vec<(i64, u32, u32)> {
map.meters()
.iter()
.map(|m| {
let bb = map.bar_beat_at_tick(m.tick);
assert_eq!((bb.beat, bb.tick), (1, 0), "meter at tick {} is off the bar line", m.tick);
(bb.bar, m.numerator, m.denominator)
})
.collect()
}
#[test]
fn earlier_meter_edit_preserves_bar_positions() {
let mut map = TempoMap::default();
let at = map.samples_to_ticks(192_000, SampleRate::HZ_48000);
assert_eq!(at, 7680);
map.set_meter(at, 3, 4).unwrap();
assert_positions_round_trip(&map);
map.set_meter(0, 6, 4).unwrap();
assert_positions_round_trip(&map);
// The 3/4 change stays at bar 3, now two 6/4 bars in.
assert_eq!(meter_bars(&map), vec![(1, 6, 4), (3, 3, 4)]);
assert_eq!(map.meters().get(1).map(|m| m.tick), Some(2 * 5760));
}
#[test]
fn inserting_and_removing_meters_keeps_later_bars() {
let mut map = TempoMap::default();
map.set_meter(map.bar_start_tick(5), 5, 4).unwrap();
map.set_meter(map.bar_start_tick(3), 3, 4).unwrap();
assert_eq!(meter_bars(&map), vec![(1, 4, 4), (3, 3, 4), (5, 5, 4)]);
// Inserting 7/8 at bar 2 keeps 3/4 at bar 3 and 5/4 at bar 5.
map.set_meter(map.bar_start_tick(2), 7, 8).unwrap();
assert_eq!(meter_bars(&map), vec![(1, 4, 4), (2, 7, 8), (3, 3, 4), (5, 5, 4)]);
assert_positions_round_trip(&map);
// Removing 3/4 leaves 5/4 at bar 5.
let three_four = map.meters().iter().find(|m| m.numerator == 3).map(|m| m.tick).unwrap();
assert!(map.remove_meter(three_four));
assert_eq!(meter_bars(&map), vec![(1, 4, 4), (2, 7, 8), (5, 5, 4)]);
assert_positions_round_trip(&map);
}
#[test]
fn initial_meter_first_and_restoring_controls() {
// Setting the initial meter first, then the later change at the same raw tick, snaps to a bar line.
let mut map = TempoMap::default();
map.set_meter(0, 6, 4).unwrap();
map.set_meter(7680, 3, 4).unwrap();
assert_eq!(map.meters().get(1), Some(&MeterEvent { tick: 5760, numerator: 3, denominator: 4 }));
assert_positions_round_trip(&map);
// Restoring 4/4 after the edit puts the 3/4 change back where it started.
let mut map = TempoMap::default();
map.set_meter(7680, 3, 4).unwrap();
map.set_meter(0, 6, 4).unwrap();
map.set_meter(0, 4, 4).unwrap();
assert_eq!(map.meters().get(1), Some(&MeterEvent { tick: 7680, numerator: 3, denominator: 4 }));
assert_positions_round_trip(&map);
}

View File

@ -7,6 +7,7 @@
//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect`
//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs)
//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}`
//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch)
//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`
//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`
//! - `app.quit`
@ -91,6 +92,8 @@ fn mods(p: &Value) -> egui::Modifiers {
fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option<Value> {
let p = &req.params;
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let pos = egui::pos2(f("x"), f("y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
Some(match req.method.as_str() {
"engine.execute" | "command" => {
let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or("");
@ -105,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": "{}"}));
}
}
@ -147,11 +155,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
Err(e) => err(e),
},
"ui.move" => {
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
app.synthetic.push(egui::Event::PointerMoved(pos));
ok(json!({}))
}
"ui.click" => {
let pos = egui::pos2(f("x"), f("y"));
let button = match p.get("button").and_then(Value::as_str) {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
@ -166,9 +173,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
ok(json!({}))
}
"ui.drag" => {
let a = egui::pos2(f("x"), f("y"));
let b = egui::pos2(f("to_x"), f("to_y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y")));
let m = mods(p);
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
@ -179,6 +184,27 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
ok(json!({}))
}
"ui.scroll" => {
let line = p.get("unit").and_then(Value::as_str) == Some("line");
let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point };
let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32;
let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p));
let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers };
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO)));
app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta)));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO)));
ok(json!({}))
}
"ui.zoom" => {
let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else {
return Some(err("ui.zoom needs a positive factor (> 1 zooms in)"));
};
let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32;
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each)));
ok(json!({}))
}
"ui.key" => {
let name = p.get("key").and_then(Value::as_str).unwrap_or("");
let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) };

View File

@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, TrackId};
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
use soundcraft_time::Samples;
#[derive(Debug, Clone)]
@ -15,6 +15,8 @@ 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
@ -23,11 +25,14 @@ pub enum Dialog {
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
Session { frame_rate: String, bit_depth: String },
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
SaveChanges { then: (String, Value) },
}
#[derive(Default)]
pub struct Dialogs {
pub open: Option<Dialog>,
/// Runs once the session has been saved from `SaveChanges`.
after_save: Option<(String, Value)>,
}
impl Dialogs {
@ -38,6 +43,8 @@ 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",
@ -46,6 +53,7 @@ impl Dialogs {
Dialog::Group { .. } => "group",
Dialog::Session { .. } => "session",
Dialog::ScoreSetup { .. } => "score_setup",
Dialog::SaveChanges { .. } => "save_changes",
})
}
@ -57,11 +65,27 @@ 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 };
self.open = Some(Dialog::ClearClips { clips, sources, message });
true
}
/// Open the dialog for a command; false when the command has no dialog.
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
self.after_save = None;
let home = default_dir();
let name = e.session().name.clone();
self.open = Some(match id {
"clip.clear" => {
let ed = &e.session().edit;
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
}
"track.new" => {
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
@ -72,10 +96,16 @@ impl Dialogs {
"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() },
"session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() },
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: id.into(),
"session.save" if e.path.is_some() => return false,
"session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(),
title: "Save Session As".into(),
path: format!("{home}/{name}/{name}.scraft"),
// The browser has no folders: the name becomes the bundle file's name.
path: if home.is_empty() {
format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION)
} else {
format!("{home}/{name}/{name}.scraft")
},
key: "path".into(),
},
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
@ -100,6 +130,11 @@ impl Dialogs {
let s = soundcraft_engine::score::ScoreSetup::from_session(e.session());
Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names }
}
"clip.rename" => {
let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id));
let Some((_, clip)) = target else { return false };
Dialog::RenameClip { id: clip.id, name: clip.name.clone() }
}
_ => match path_dialog(id, &home, &name) {
Some(d) => d,
None => return false,
@ -109,6 +144,32 @@ impl Dialogs {
}
}
/// When `id` would close a session with unsaved changes, ask first; true when the question is
/// open instead. A take in progress is stopped first, so it counts as a change.
pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
{
return false;
}
if app.engine.transport.recording {
app.stop_play();
}
if !app.engine.is_dirty() {
return false;
}
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
true
}
/// Runs `id`, unless it asks to save first (`ask_to_save`).
pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value, String> {
if ask_to_save(app, id, &params) {
return Ok(Value::Null);
}
app.run(id, params)
}
/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus
/// Print Score, whose path is optional), with a default file name from the command.
pub fn takes_path(id: &str) -> bool {
@ -146,6 +207,41 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option<Dialog> {
Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() })
}
fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<String> {
let listed: std::collections::BTreeSet<ClipId> = clips.iter().copied().collect();
let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect();
let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect();
let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") };
let what = match (names.as_slice(), files.as_slice()) {
([], []) => return None,
([name], []) => format!("the clip \"{name}\""),
([], [f]) => format!("the file \"{}\"", f.name),
(c, []) => count(c.len(), "clip"),
([], f) => count(f.len(), "file"),
(c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")),
};
let mut text = format!("Remove {what} from the session?");
let used: Vec<(&str, usize)> =
files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect();
let n: usize = used.iter().map(|u| u.1).sum();
let users = if n == 1 {
"a clip on the timeline, which is removed too.".to_string()
} else {
format!("{n} clips on the timeline, which are removed too.")
};
match used.as_slice() {
[] => {}
[_] if files.len() == 1 => text += &format!(" It is used by {users}"),
u if u.len() == files.len() => text += &format!(" They are used by {users}"),
[(name, _)] => text += &format!(" \"{name}\" is used by {users}"),
many => text += &format!(" {} of the files are used by {users}", many.len()),
}
if !files.is_empty() && files.iter().all(|f| !f.unsaved) {
text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." };
}
Some(text)
}
fn default_dir() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
@ -163,12 +259,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut save_first: Option<(String, Value)> = None;
let confirmed = matches!(d, Dialog::SaveChanges { .. });
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewSession { .. } => "New Session",
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::Fades { .. } => "Fades",
@ -176,6 +276,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Group { .. } => "Create Group",
Dialog::Session { .. } => "Session Setup",
Dialog::ScoreSetup { .. } => "Score Setup",
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
Dialog::SaveChanges { .. } => "Unsaved Changes",
}
.to_string();
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
@ -273,29 +375,48 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
Dialog::RenameTrack { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
name_field(ui, name);
if buttons(ui, "OK", enter) {
action = Some(("track.rename".into(), json!({"track": id.0, "name": name})));
}
}
Dialog::RenameClip { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
name_field(ui, name);
if buttons(ui, "OK", enter) {
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());
if buttons(ui, "Remove", enter) {
action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true})));
}
}
Dialog::PathPrompt { cmd, path, key, .. } => {
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
if let Some(pick) = &app.services.pick_open
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
&& key != "dir"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(cmd, &[])
{
*path = p;
}
if let Some(pick) = &app.services.pick_folder
&& key == "dir"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(path)
{
*path = p;
}
if let Some(pick) = &app.services.pick_save
&& (cmd == "session.save_as" || cmd == "session.save_copy")
&& ui.button("Browse…").clicked()
@ -303,9 +424,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
{
*path = p;
}
if buttons(ui, "OK", enter) {
let file = soundcraft_engine::io::session_file(path);
let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file);
if replaces {
ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy()));
}
if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) {
let mut v = json!({});
v[key.as_str()] = json!(path);
if replaces {
v["replace"] = json!(true);
}
action = Some((cmd.clone(), v));
}
}
@ -404,18 +533,71 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
));
}
}
Dialog::SaveChanges { then } => {
let name = &app.engine.session().name;
let revert = then.0 == "session.revert";
if revert || cfg!(target_arch = "wasm32") {
let (question, ok) = if revert {
(format!("Revert “{name}” to its last saved version?"), "Revert")
} else {
(format!("Close “{name}” without saving?"), "Don't Save")
};
ui.label(format!("{question} Your changes will be lost."));
if buttons(ui, ok, false) {
action = Some(then.clone());
}
} else {
ui.label(format!("Save changes to “{name}” before closing?"));
ui.separator();
ui.horizontal(|ui| {
if ui.button("Don't Save").clicked() {
action = Some(then.clone());
}
if buttons(ui, "Save", enter) {
save_first = Some(then.clone());
}
});
}
}
}
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
keep = false;
app.dialogs.after_save = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
}
if let Some(then) = save_first {
keep = false;
if !app.dialogs.open_for_command(&app.engine, "session.save") {
action = Some(("session.save".into(), json!({})));
}
app.dialogs.after_save = Some(then);
}
if let Some((cmd, params)) = &action
&& !confirmed
&& ask_to_save(app, cmd, params)
{
keep = false;
action = None;
}
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
if let Some((cmd, params)) = action {
keep = false;
if let Err(e) = app.run(&cmd, params) {
app.ui.status = e;
match app.run(&cmd, params) {
Ok(v) if cmd == "file.export_clips" => {
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
}
Ok(_) => {}
Err(e) => app.ui.status = e,
}
}
if saving
&& let Some((then, params)) = app.dialogs.after_save.take()
&& !app.engine.is_dirty()
&& let Err(e) = app.run(&then, params)
{
app.ui.status = e;
}
if keep {
app.dialogs.open = Some(d);
}
@ -436,6 +618,17 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
pressed
}
/// A name field that takes the focus with its text selected, so typing replaces the old name.
fn name_field(ui: &mut egui::Ui, name: &mut String) {
let mut out = egui::TextEdit::singleline(name).show(ui);
if !out.response.has_focus() {
out.response.request_focus();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
out.state.cursor.set_char_range(Some(all));
out.state.store(ui.ctx(), out.response.id);
}
}
/// The command the tempo-change dialog runs on OK.
fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
("event.tempo".into(), json!({"bpm": bpm, "at": at}))
@ -515,6 +708,18 @@ mod tests {
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
#[test]
fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() {
let mut e = soundcraft_engine::demo::demo_engine();
let mut d = Dialogs::default();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(!d.open_for_command(&e, "clip.rename"));
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap();
assert!(d.open_for_command(&e, "clip.rename"));
assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name));
}
#[test]
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
let (cmd, params) = tempo_change_action(96_000, 133.5);
@ -522,4 +727,36 @@ mod tests {
assert_eq!(params["at"], 96_000);
assert_eq!(params["bpm"], 133.5);
}
#[test]
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
let mut e = soundcraft_engine::demo::demo_engine();
let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap();
let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats"));
let kick_clips: Vec<ClipId> = e.session().clips_using(kick).map(|c| c.id).collect();
let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id;
let message = |d: &Dialogs| match &d.open {
Some(Dialog::ClearClips { message, .. }) => message.clone(),
other => panic!("{other:?}"),
};
let mut d = Dialogs::default();
assert!(!d.open_for_command(&e, "clip.clear"));
e.execute("edit.select", &json!({"clips": [snare]})).unwrap();
assert!(d.open_for_command(&e, "clip.clear"));
assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?");
e.execute("edit.select", &json!({"sources": [kick]})).unwrap();
assert!(d.open_for_command(&e, "clip.clear"));
assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick]));
assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too.");
assert!(d.open_clear(&e, kick_clips.clone(), vec![kick]));
assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?");
assert!(d.open_clear(&e, kick_clips, vec![kick, hats]));
assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too.");
e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false);
assert!(d.open_clear(&e, vec![], vec![kick, hats]));
assert_eq!(
message(&d),
"Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk."
);
}
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -68,8 +68,13 @@ pub struct UiState {
pub mix_views: Vec<String>,
/// Edit window track-header columns: io, inserts_ae, sends_ae, comments, color.
pub edit_views: Vec<String>,
/// How strongly Edit window tracks take on their colour, 0..1 (0: only the colour strip).
pub track_tint: f32,
/// Open plugin windows (track, slot).
pub plugin_windows: Vec<(TrackId, usize)>,
/// The send in the Send window (track, slot).
#[serde(skip)]
pub send_window: Option<(TrackId, usize)>,
pub audiosuite: Option<String>,
pub status: String,
pub show_automation: bool,
@ -101,6 +106,11 @@ pub struct UiState {
pub configurations: Vec<(String, Value)>,
/// Appearance (`ui.theme`): dark by default, light or follow the system on request.
pub theme: theme::ThemeMode,
/// The main window's size (logical points) and maximized state, and the interface scale, as
/// the desktop app last saw them; it restores them at launch.
pub window_size: [f32; 2],
pub window_maximized: bool,
pub ui_scale: f32,
}
impl Default for UiState {
@ -123,7 +133,9 @@ impl Default for UiState {
narrow_mix: false,
mix_views: vec!["inserts_ae".into(), "sends_ae".into(), "io".into(), "color".into()],
edit_views: vec!["color".into()],
track_tint: 0.5,
plugin_windows: Vec::new(),
send_window: None,
audiosuite: None,
status: String::new(),
show_automation: false,
@ -153,6 +165,9 @@ impl Default for UiState {
workspace_dir: String::new(),
configurations: Vec::new(),
theme: theme::ThemeMode::Dark,
window_size: [1600.0, 1000.0],
window_maximized: false,
ui_scale: 1.0,
}
}
}
@ -162,6 +177,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
pub pick_folder: Option<Box<dyn Fn(&str) -> Option<String>>>,
}
/// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`).
@ -169,6 +185,15 @@ pub fn preset_folder_name(plugin: &str) -> String {
plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect()
}
/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger
/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor.
pub fn fit_window_to_screen(ctx: &egui::Context) {
let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size));
if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
}
}
/// Drag-and-drop payload: an audio file (source id) dragged from the Clip List.
#[derive(Debug, Clone, Copy)]
pub struct DragSource(pub u64);
@ -216,7 +241,7 @@ pub struct SoundApp {
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: f64,
last_autosave: Option<std::time::Instant>,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
@ -241,17 +266,24 @@ pub struct SoundApp {
pub(crate) docking_context: Option<egui::Context>,
/// Transport simulation when there is no player (tests, offscreen renders).
sim: Option<(Samples, Option<Samples>, Option<Range>)>,
/// When Record last found nothing to record on, and why: the Record buttons show it for a moment.
record_hint: Option<(f64, &'static str)>,
pub quit_requested: bool,
/// Reset floating-window positions next frame (Window › Arrange).
pub arrange_request: bool,
/// Tracks whose automation was touched during this pass (Touch/Latch writing).
touched: std::collections::HashSet<TrackId>,
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
last_write_at: Samples,
pub frame_ms: f32,
last_frame: Option<f64>,
last_meter_poll: f64,
/// Movies for the Video window and Video track (decoded in the background).
pub video: video_track::VideoPool,
/// Test-only: owns the process-wide palette lock while this app exists, so frames painted
/// and colour assertions in one test can never straddle another test's palette change.
#[cfg(test)]
_palette_lock: theme::TestPaletteGuard,
}
/// Whether another automation-write pass is due: the transport moved at least
@ -279,7 +311,7 @@ impl SoundApp {
native_menu_bar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: 0.0,
last_autosave: None,
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
@ -300,6 +332,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(),
@ -308,6 +341,8 @@ impl SoundApp {
last_frame: None,
last_meter_poll: f64::NEG_INFINITY,
video: video_track::VideoPool::default(),
#[cfg(test)]
_palette_lock: theme::lock_test_palette(),
}
}
@ -351,6 +386,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 }
@ -365,6 +407,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.
@ -417,21 +460,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 self.recorder.is_none() {
self.ui.status = "Cannot record: no audio input device".into();
if let Err(err) = self.arm_recorder() {
self.ui.status = format!("Cannot record: {err}");
return;
}
if let Some(p) = &self.player {
p.set_recording(true);
}
let already_playing = self.is_playing();
if let Some(r) = &self.recorder {
r.arm();
}
self.engine.transport.recording = true;
if already_playing {
// Punch in on the fly at the current playhead.
@ -457,6 +503,19 @@ impl SoundApp {
}
}
/// Arm the input for the record-enabled tracks; a session never saved records into its
/// autosave folder.
fn arm_recorder(&mut self) -> Result<(), String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
r.arm(plan)
}
/// Reads every live third-party plugin's state from the audio engine and stores it on its
/// insert (`mix.insert_state`), so saves and bounces include it.
pub fn capture_plugin_states(&mut self) {
@ -483,15 +542,14 @@ impl SoundApp {
}
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
fn autosave(&mut self, now: f64) {
/// Timed by the wall clock: egui's stands still while the window is hidden.
fn autosave(&mut self) {
let Some(dir) = self.autosave_dir.clone() else { return };
if self.last_autosave == 0.0 {
self.last_autosave = now;
}
if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording {
let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now);
if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording {
return;
}
self.last_autosave = now;
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"));
@ -552,70 +610,50 @@ impl SoundApp {
if let Some(p) = &self.player {
p.set_recording(false);
}
let Some(r) = &self.recorder else { return };
let take = r.take();
let rate = take.sample_rate;
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;
if self.engine.session().edit.loop_record && !sel.is_empty() {
// Split the capture into one take per loop pass; each pass gets its own playlist.
let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64()))
.unwrap_or(0)
.max(1);
let total = take.channels.first().map_or(0, Vec::len);
let passes = total.div_ceil(pass).max(1);
let mut made = 0;
for k in 0..passes {
let chunk: Vec<Vec<f32>> =
take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect();
if chunk.first().is_none_or(|c| c.len() < pass / 8) {
continue;
}
if made > 0 {
let armed: Vec<u64> = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect();
let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed}));
}
if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() {
made += 1;
}
}
self.ui.status = format!("Loop-recorded {made} take(s)");
return;
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",
);
}
match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) {
Ok(ids) => {
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 = format!("Recorded {} clip(s)", ids.len());
}
Err(e) => self.ui.status = e.to_string(),
self.ui.status = if looped { 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);
}
}
/// 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),
@ -640,11 +678,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();
}
}
@ -714,6 +756,7 @@ impl SoundApp {
if let Some(sim) = &mut self.sim {
sim.0 = at;
}
self.last_write_at = i64::MIN;
}
self.engine.transport.position = at;
}
@ -781,6 +824,9 @@ impl SoundApp {
/// Per-frame logic: control channel, transport, document sync.
pub fn logic(&mut self, ctx: &egui::Context) {
if ctx.current_pass_index() > 0 {
return;
}
// Fonts set now take effect next frame, so draw only from the frame after.
if !self.fonts_ready {
if self.fonts_installed {
@ -797,8 +843,7 @@ impl SoundApp {
self.theme_applied = Some(light);
}
let now = ctx.input(|i| i.time);
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.last_frame = Some(now);
let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
control::drain(self, ctx);
self.apply_editor_edits();
@ -820,21 +865,17 @@ impl SoundApp {
}
}
self.write_automation(ctx);
self.autosave(now);
self.autosave();
if self.recorder.is_none()
&& !self.recorder_failed
&& self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)
{
self.ensure_input();
}
if self.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
// Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from.
// egui takes its predicted frame time off every delay, so add it back.
let editors = self.player.as_ref().is_some_and(Player::any_editor_open);
ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt));
control::collect_screenshots(self, ctx);
}
@ -900,6 +941,15 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
// Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it.
if self.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
/// UI state as JSON (control channel `ui.inspect`).
@ -987,6 +1037,43 @@ 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()));
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.autosave_dir = Some(dir.clone());
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() };
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() };
let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx));
assert!(app.latest_autosave().is_some());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@ -1035,6 +1122,96 @@ 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();
assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));
}
#[test]
fn a_window_bigger_than_its_screen_opens_maximized() {
let maximizes = |w: f32, frames: usize| {
let ctx = egui::Context::default();
let info = egui::ViewportInfo {
inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))),
monitor_size: Some(egui::vec2(1728.0, 1117.0)),
..Default::default()
};
let mut maximized = false;
for _ in 0..frames {
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx()));
out.textures_delta.clear();
maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true));
}
maximized
};
assert!(maximizes(2400.0, 1));
assert!(!maximizes(1600.0, 1));
assert!(!maximizes(2400.0, 2));
}
#[test]
fn ordinary_play_after_stop_is_full_speed() {
use serde_json::json;

View File

@ -26,23 +26,23 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.close", "Close Window", "Window > Close Window", Some("Cmd+W")),
("window.hide_floating", "Hide All Floating Windows", "Window > Hide All Floating Windows", Some("Cmd+Ctrl+W")),
("window.midi_editor", "MIDI Editor", "Window > MIDI Editor", None),
("window.universe", "Universe", "View > Other Displays > Universe", None),
("window.universe", "Universe", "View > Other Displays > Universe", Some("Alt+7")),
("window.video", "Video", "Window > Video", Some("Cmd+9")),
("window.video_universe", "Video Universe", "Window > Video Universe", None),
("window.automation", "Automation", "Window > Automation", Some("Cmd+4")),
("window.color_palette", "Color Palette", "Window > Color Palette", None),
("window.disk_usage", "Disk Usage", "Window > Disk Usage", None),
("window.system_usage", "System Usage", "Window > System Usage", None),
("window.task_manager", "Task Manager", "Window > Task Manager", None),
("window.task_manager", "Task Manager", "Window > Task Manager", Some("Alt+'")),
("window.metadata", "Metadata Inspector", "Window > Metadata Inspector", None),
("window.event_list", "MIDI Event List", "Window > MIDI Event List", None),
("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", None),
("window.workspace", "Workspace", "Window > New Workspace > Default", None),
("window.event_list", "MIDI Event List", "Window > MIDI Event List", Some("Alt+=")),
("window.midi_keyboard", "MIDI Keyboard", "Window > MIDI Keyboard", Some("Shift+K")),
("window.workspace", "Workspace", "Window > New Workspace > Default", Some("Alt+I")),
("window.workspace_presets", "Workspace", "Window > New Workspace > Track Presets", None),
("window.workspace_sounds", "Workspace", "Window > New Workspace > Soundbase", None),
("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", None),
("window.workspace_front", "Workspaces to Front", "Window > Workspaces > Bring to Front", Some("Alt+J")),
("window.workspace_close", "Close All Workspaces", "Window > Workspaces > Close All Workspaces", None),
("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", None),
("window.configurations", "Window Configuration List", "Window > Configurations > Window Configuration List", Some("Cmd+Alt+J")),
("window.config_new", "New Configuration", "Window > Configurations > New Configuration...", None),
("window.config_update", "Update Active Configuration", "Window > Configurations > Update Active Configuration", None),
("window.config_auto_update", "Auto-Update Active Configuration", "Window > Configurations > Auto-Update Active Configuration", None),
@ -55,7 +55,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.midi_front", "MIDI Editors to Front", "Window > MIDI Editors > Bring to Front", None),
("window.midi_back", "MIDI Editors to Back", "Window > MIDI Editors > Send to Back", None),
("window.clip_effects", "Clip Effects", "Window > Clip Effects", None),
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", None),
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")),
("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")),
("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")),
("window.beat_detective", "Beat Detective", "", None),
@ -145,12 +145,14 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
/// Commands that open a dialog when invoked from a menu (programmatic calls never do).
const DIALOG_COMMANDS: &[&str] = &[
"clip.clear",
"track.new",
"file.bounce_mix",
"session.new",
"file.import_audio",
"file.import_midi",
"session.open",
"session.save",
"session.save_as",
"session.save_copy",
"track.group",
@ -161,6 +163,7 @@ const DIALOG_COMMANDS: &[&str] = &[
"event.quantize",
"event.transpose",
"edit.strip_silence",
"clip.rename",
];
/// Shared menu hierarchy for egui and native desktop hosts.
@ -197,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);
}
@ -221,7 +230,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
}
ui.separator();
if ui.button("Quit").clicked() {
let _ = app.run("app.quit", json!({}));
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
}
});
let extra = ui_aliases();
@ -261,8 +270,9 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
}
let id = command_for_path(&n.path, extra);
let (enabled, checked) = item_state(app, &n.path, id.as_deref());
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone());
let label = if checked { format!("✔ {label}") } else { label };
let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
if ui.add_enabled(enabled, btn).clicked()
&& let Some(id) = id
@ -393,6 +403,21 @@ const MENU_WINDOWS: &[(&str, &str)] = &[
("Event > MIDI Real-Time Properties", "window.rtp"),
];
/// The shortcut of an engine or window command.
pub fn shortcut_for(id: &str) -> Option<&'static str> {
soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut)
}
/// Undo and Redo name the step they would undo or redo; other items keep their catalog label.
pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
let e = &app.engine;
match id? {
"edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))),
"edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))),
_ => None,
}
}
/// Menu-style invocation: may open a dialog.
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
@ -419,12 +444,13 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
return;
}
let params = params_for(path, id);
let _ = app.run(id, params);
let _ = crate::dialogs::run_or_ask(app, id, params);
}
/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled.
/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its
/// dialog. Returns true when handled.
pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool {
wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)
(wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({}))
}
/// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path.
@ -470,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!({})
@ -537,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!({})
@ -638,4 +669,24 @@ mod tests {
invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
assert_eq!(app.dialogs.open_name(), Some("bounce"));
}
#[test]
fn save_asks_for_a_path_until_the_session_has_one() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as"));
app.dialogs.open = None;
assert!(invoke_shortcut_dialog(&mut app, "session.save"));
app.dialogs.open = None;
let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id()));
let path = dir.join("Song.scraft");
app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap();
app.run("track.new", json!({})).unwrap();
assert!(!invoke_shortcut_dialog(&mut app, "session.save"));
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(app.dialogs.open.is_none());
assert!(!app.engine.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
}

View File

@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0;
pub struct MidiEditorState {
pub selected: Vec<usize>,
pub drag: Option<NoteDrag>,
pub top_pitch: i32,
pub scroll: f32,
pub clip: Option<ClipId>,
/// Rubber-band selection start (screen position).
pub band: Option<egui::Pos2>,
@ -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,7 +112,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
op = Some(("event.change_velocity", json!({"add": 10})));
}
if tb.small_button("Quantize").on_hover_text("Quantize to the grid").clicked() {
op = Some(("event.quantize", json!({"grid": "1/16", "strength": 100})));
op = Some(("event.quantize", json!({"grid": grid_ticks, "strength": 100})));
}
if let Some((id, mut p)) = op {
p["clips"] = json!([cid.0]);
@ -109,46 +122,32 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let sr = s.sample_rate;
let base_tick = s.tempo.samples_to_ticks(clip.start, sr);
let len_ticks = (s.tempo.samples_to_ticks(clip.end(), sr) - base_tick).max(1);
let grid_ticks = match s.edit.grid {
soundcraft_time::GridValue::Note { value, dotted, triplet } => {
let mut g = value.ticks();
if dotted {
g = g * 3 / 2;
}
if triplet {
g = g * 2 / 3;
}
g.max(1)
}
_ => TICKS_PER_QUARTER / 4,
};
let roll = Rect::from_min_max(pos2(full.min.x + KEY_W, header.max.y), pos2(full.max.x - 4.0, full.max.y - VEL_H - 4.0));
let keys = Rect::from_min_max(pos2(full.min.x, roll.min.y), pos2(roll.min.x, roll.max.y));
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
// Vertical range: centre on the notes the first time.
let rows = (roll.height() / ROW_H).floor() as i32;
if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) {
if app.midi.clip != Some(cid) {
let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72);
let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48);
let mid = (hi + lo) / 2;
app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127);
app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H;
app.midi.clip = Some(cid);
}
if ui.rect_contains_pointer(roll) {
let dy = ui.input(|i| i.smooth_scroll_delta.y);
if dy.abs() > 0.5 {
app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127);
}
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
}
let top = app.midi.top_pitch;
app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0));
let scroll = app.midi.scroll;
let px_per_tick = roll.width() / len_ticks as f32;
let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick;
let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H;
let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll;
let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32;
let painter = ui.painter().with_clip_rect(roll.union(keys));
// Background rows and keys.
for r in 0..=rows {
let top = pitch_at(roll.min.y);
for r in 0..=rows + 1 {
let pitch = top - r;
let y = roll.min.y + r as f32 * ROW_H;
let y = y_of(pitch);
let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H));
painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) });
let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0));
@ -288,7 +287,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Audition on the instrument would go here (needs a live MIDI input path).
}
None => {
let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127);
let pitch = pitch_at(p.y).clamp(0, 127);
let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks;
let _ = app.run(
"midi.note_add",
@ -301,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,
)
});
@ -331,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();
}

View File

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

View File

@ -4,7 +4,29 @@ use crate::SoundApp;
use crate::theme::{Tokens, bold, mono, regular, rgb};
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_time::format_position;
use soundcraft_model::{ClipId, SourceId};
/// Drag a Tracks list row to reorder the tracks: a line marks the gap it will land in.
fn drag_track(app: &mut SoundApp, ui: &Ui, row: Rect, id: u64, dropped: bool) {
let s = app.engine.session();
let (Some(from), Some(p)) = (s.track_index(soundcraft_model::TrackId(id)), ui.ctx().pointer_latest_pos()) else { return };
let spacing = ui.spacing().item_spacing.y;
let pitch = row.height() + spacing;
let gap = (from as f32 + ((p.y - row.center().y) / pitch).floor() + 1.0).clamp(0.0, s.tracks.len() as f32);
if dropped {
let to = gap as usize;
let to = if to > from { to - 1 } else { to };
if to != from {
let _ = app.run("track.move", json!({"track": id, "to": to}));
}
} else {
let y = row.min.y + (gap - from as f32) * pitch - spacing / 2.0;
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
ui.ctx()
.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("track_list_reorder")))
.line_segment([pos2(row.min.x, y), pos2(row.max.x, y)], Stroke::new(2.0, Tokens::current().accent));
}
}
fn panel_header(ui: &mut Ui, title: &str) {
let t = Tokens::current();
@ -14,17 +36,36 @@ fn panel_header(ui: &mut Ui, title: &str) {
ui.painter().line_segment([pos2(r.min.x, r.max.y), pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
}
/// The strip down the Tracks panel's right edge: the lists' scroll bars, and a clear line between
/// the panel and the window beside it.
const GUTTER_W: f32 = 10.0;
pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let full = ui.max_rect();
let gutter = full.with_min_x(full.max.x - GUTTER_W);
ui.painter().rect_filled(gutter, 0.0, t.panel_bg.lerp_to_gamma(t.text_dim, 0.15));
ui.painter().vline(gutter.min.x + 0.5, full.y_range(), Stroke::new(1.0, t.border));
ui.painter().vline(gutter.max.x - 0.5, full.y_range(), Stroke::new(1.0, t.border));
let ui = &mut ui.new_child(egui::UiBuilder::new().max_rect(full.with_max_x(gutter.min.x)));
let total = ui.available_height();
let header = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), 22.0));
panel_header(ui, "TRACKS");
let plus = Rect::from_center_size(pos2(header.max.x - 13.0, header.center().y), vec2(18.0, 18.0));
let tip = format!("New Tracks… ({})", crate::shortcuts::shortcut_label("Cmd+Shift+N", ui.ctx().os().is_mac()));
if crate::widgets::icon_button(&mut ui.new_child(egui::UiBuilder::new().max_rect(plus)), plus.size(), "plus", false, &tip).clicked() {
let _ = app.dialogs.open_for_command(&app.engine, "track.new");
}
let tracks: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
s.tracks.iter().map(|x| (x.id.0, x.name.clone(), !x.hidden, x.color, s.edit.selected_tracks.contains(&x.id))).collect()
};
egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).show(ui, |ui| {
let hidden = egui::scroll_area::ScrollBarVisibility::AlwaysHidden;
let lists =
egui::ScrollArea::vertical().id_salt("tracks_scroll").max_height(total * 0.62).auto_shrink([false, false]).scroll_bar_visibility(hidden);
let out = lists.show(ui, |ui| {
for (id, name, shown, color, sel) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag());
if sel {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
@ -37,15 +78,19 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
if resp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id]}));
crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true);
}
if resp.dragged() || resp.drag_stopped() {
drag_track(app, ui, r, id, resp.drag_stopped());
}
}
});
gutter_scroll(ui, gutter, &out);
ui.add_space(4.0);
panel_header(ui, "GROUPS");
let groups: Vec<(u64, char, String, bool, [u8; 3])> =
app.engine.session().groups.iter().map(|g| (g.id.0, g.letter, g.name.clone(), g.active, g.color)).collect();
egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).show(ui, |ui| {
let out = egui::ScrollArea::vertical().id_salt("groups_scroll").auto_shrink([false, false]).scroll_bar_visibility(hidden).show(ui, |ui| {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover());
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "<ALL>", regular(11.5).clone(), t.text);
let all = ui.interact(r, ui.id().with("group_all"), Sense::click());
@ -95,56 +140,98 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
});
}
});
gutter_scroll(ui, gutter, &out);
}
/// A list's scroll thumb in the gutter beside it, when the list is longer than its space: drag it,
/// or click above or below it to page.
fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOutput<R>) {
let t = Tokens::current();
let (view, content) = (out.inner_rect.height(), out.content_size.y);
if content <= view + 0.5 {
return;
}
let bar = Rect::from_x_y_ranges(gutter.x_range(), out.inner_rect.y_range());
let (max, h) = (content - view, (bar.height() * view / content).max(20.0));
let mut state = out.state;
let thumb = Rect::from_min_size(pos2(bar.min.x + 2.0, bar.min.y + (bar.height() - h) * state.offset.y / max), vec2(bar.width() - 4.0, h));
let resp = ui.interact(bar, out.id.with("gutter"), Sense::click_and_drag());
ui.painter().rect_filled(thumb, 3.0, if resp.hovered() || resp.dragged() { t.button_hi.lerp_to_gamma(Color32::WHITE, 0.1) } else { t.button_hi });
let to = if resp.dragged() {
Some(state.offset.y + resp.drag_delta().y * max / (bar.height() - h).max(1.0))
} else if let Some(p) = resp.interact_pointer_pos().filter(|_| resp.clicked()) {
let page = if p.y < thumb.min.y {
-view
} else if p.y > thumb.max.y {
view
} else {
0.0
};
Some(state.offset.y + page)
} else {
None
};
if let Some(y) = to {
state.offset.y = y.clamp(0.0, max);
state.store(ui.ctx(), out.id);
}
}
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
panel_header(ui, "CLIPS");
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
let mut v: Vec<(Option<u64>, String, bool, [u8; 3])> = Vec::new();
let mut v = Vec::new();
// Whole files first (bold), then clips on tracks.
for src in &s.sources {
v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150]));
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
}
for tr in &s.tracks {
for c in tr.clips() {
v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color)));
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
}
}
v
};
let selected: Vec<u64> = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect();
let fkey = egui::Id::new("clip_filter");
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
let needle = filter.to_lowercase();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
for (id, name, whole, color) in items {
for (id, name, whole, color, selected) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
if whole && let Some(src) = id {
resp.context_menu(|ui| {
resp.context_menu(|ui| {
if whole {
if ui.button("Place on New Track").clicked() {
let _ = app.run("clip.place_source", json!({"source": src}));
let _ = app.run("clip.place_source", json!({"source": id}));
ui.close();
}
let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0);
if let Some(t) = sel_track
&& ui.button("Place on Selected Track").clicked()
{
let _ = app.run("clip.place_source", json!({"source": src, "track": t}));
let _ = app.run("clip.place_source", json!({"source": id, "track": t}));
ui.close();
}
});
ui.separator();
}
if ui.button("Clear…").clicked() {
let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) };
app.dialogs.open_clear(&app.engine, clips, sources);
ui.close();
}
});
if whole {
// Whole files drag onto tracks.
resp.dnd_set_drag_payload(crate::DragSource(src));
resp.dnd_set_drag_payload(crate::DragSource(id));
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
if !whole && id.is_some_and(|i| selected.contains(&i)) {
if selected {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color));
@ -155,11 +242,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
if whole { bold(11.0) } else { regular(11.0) },
t.text,
);
if resp.clicked()
&& !whole
&& let Some(i) = id
{
let _ = app.run("edit.select", json!({"clips": [i]}));
if resp.clicked() {
let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) });
}
}
});
@ -170,6 +254,7 @@ pub fn floating(app: &mut SoundApp, ctx: &egui::Context) {
transport_window(app, ctx);
big_counter(app, ctx);
plugin_windows(app, ctx);
crate::mix_window::send_window(app, ctx);
about(app, ctx);
session_info(app, ctx);
}
@ -193,15 +278,19 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
("end", "transport.go_to_end"),
("record", "transport.record"),
] {
if crate::widgets::icon_button(
let resp = crate::widgets::icon_button(
ui,
vec2(34.0, 26.0),
icon,
(cmd == "transport.play" && app.is_playing()) || (cmd == "transport.record" && app.engine.transport.recording),
cmd,
)
.clicked()
);
if cmd == "transport.record"
&& let Some(why) = app.record_hint()
{
egui::Tooltip::for_widget(&resp).show(|ui| ui.label(why));
}
if resp.clicked() {
let _ = app.run(cmd, json!({}));
}
}
@ -213,7 +302,7 @@ fn transport_window(app: &mut SoundApp, ctx: &egui::Context) {
}
});
let s = app.engine.session();
let fmt = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let fmt = |x: i64| s.format_position(x, s.edit.main_counter);
if flags.contains("view.transport.counters") || expanded || flags.is_empty() {
ui.label(egui::RichText::new(fmt(app.position())).font(mono(20.0)).color(t.counter_text));
}
@ -266,7 +355,7 @@ fn big_counter(app: &mut SoundApp, ctx: &egui::Context) {
}
egui::Window::new("Big Counter").open(&mut open).default_size(vec2(520.0, 110.0)).show(ctx, |ui| {
let s = app.engine.session();
let txt = format_position(app.position(), s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let txt = s.format_position(app.position(), s.edit.main_counter);
let r = ui.available_rect_before_wrap();
let t = Tokens::current();
ui.painter().rect_filled(r, 4.0, t.big_counter_bg);
@ -286,7 +375,7 @@ pub(crate) fn memory_locations(app: &mut SoundApp, ui: &mut Ui) {
});
let rows: Vec<(u32, String, String, String, String)> = {
let s = app.engine.session();
let f = |x: i64| format_position(x, s.edit.main_counter, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let f = |x: i64| s.format_position(x, s.edit.main_counter);
s.markers
.iter()
.map(|m| {
@ -407,44 +496,46 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
if info.id == "eq_7band" || info.id == "eq_1band" {
eq_curve(ui, info.id, &ins);
}
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.insert_param",
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
&format!("param:{}:{slot}:{}", tid.0, p.id),
);
if let Some(pl) = &app.player {
// Keep the plugin's own editor in step.
pl.editor_set_param(tid, slot, p.id, x);
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.insert_param",
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
&format!("param:{}:{slot}:{}", tid.0, p.id),
);
if let Some(pl) = &app.player {
// Keep the plugin's own editor in step.
pl.editor_set_param(tid, slot, p.id, x);
}
}
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
});
});
});
if open {
@ -556,10 +647,16 @@ fn preset_menu(
if let Ok(text) = std::fs::read_to_string(dir.join(format!("{n}.json")))
&& let Ok(v) = serde_json::from_str::<serde_json::Value>(&text)
{
let _ = app.run(
"mix.insert_params",
json!({"track": tid.0, "slot": slot, "params": v.get("params").cloned().unwrap_or_default(), "preset": n}),
);
// Old presets may carry parameters the plugin no longer exposes; drop them so recall succeeds.
let params: serde_json::Map<String, serde_json::Value> = v
.get("params")
.and_then(serde_json::Value::as_object)
.into_iter()
.flatten()
.filter(|(k, val)| info.params.iter().any(|pi| pi.id == k.as_str()) && val.as_f64().is_some_and(f64::is_finite))
.map(|(k, val)| (k.clone(), val.clone()))
.collect();
let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": params, "preset": n}));
}
ui.close();
}

View File

@ -75,16 +75,26 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
if fixed(app, key, mods, mac) {
continue;
}
let hit =
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)));
if let Some(c) = hit {
let id = c.id;
if let Some(id) = command_for(key, mods, mac) {
if crate::menus::invoke_shortcut_dialog(app, id) {
continue;
}
let _ = app.run(id, json!({}));
} else if let Some(c) =
crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
{
let _ = app.run(c.0, json!({}));
}
}
// Tab moves the insertion point here, so egui must not also hand focus to the first widget: that would block every shortcut.
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
soundcraft_engine::command_specs()
.iter()
.find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
.map(|c| c.id)
}
/// Keys with context-dependent meaning. Returns true when handled.
@ -104,7 +114,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("window.search", json!({}));
true
}
Key::Equals if m.command => {
Key::Equals if m.command && !(mac && m.ctrl) => {
let _ = app.run("window.toggle_mix_edit", json!({}));
true
}
@ -112,6 +122,16 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("markers.add", json!({}));
true
}
// Shift+=/- step the Grid value and Alt+Shift+=/- the Nudge value; macOS sends Alt+Shift+= as =, not +.
Key::Plus | Key::Equals | Key::Minus if m.shift && !m.command && !m.ctrl && app.ui.window == MainWindow::Edit => {
let step = if key == Key::Minus { -1 } else { 1 };
let _ = app.run(if m.alt { "view.nudge" } else { "view.grid" }, json!({"step": step}));
true
}
Key::F12 if plain => {
let _ = app.run("transport.record", json!({}));
true
}
Key::F1 | Key::F2 | Key::F3 | Key::F4 if plain => {
let mode = match key {
Key::F1 => "shuffle",
@ -139,11 +159,12 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("edit.nudge", json!({"direction": dir}));
true
}
Key::Tab if plain || m.alt => {
// Alt+Tab goes back; off macOS the desktop takes Alt+Tab, so Ctrl+Tab does. Cmd+Alt+Tab is Tab to Transient.
Key::Tab if plain || (m.alt && !m.command) || (!mac && m.ctrl && !m.alt) => {
// Tab to next clip boundary (or transient when enabled).
let s = app.engine.session();
let at = s.edit.selection.start;
let back = m.alt;
let back = !plain;
if s.edit.tab_to_transient
&& !back
&& let Some(t) = s.edit.selected_tracks.first().copied()
@ -167,6 +188,20 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
}
true
}
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 };
let _ = app.run("view.waveform_zoom", json!({"factor": factor}));
true
}
Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
let _ = app.run("clip.rating", json!({"rating": digit(key)}));
true
}
Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::CTRL, m, mac) => {
let _ = app.run("view.zoom_preset", json!({"preset": digit(key)}));
true
}
Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => {
let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" };
let _ = app.run(id, json!({}));
@ -179,7 +214,15 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
true
}
// Commands Keyboard Focus: single-key editing (Edit window only).
k if plain && app.ui.window == MainWindow::Edit && app.engine.session().edit.keyboard_focus == "commands" && commands_focus(app, k) => true,
k if !m.command
&& !m.alt
&& !m.ctrl
&& app.ui.window == MainWindow::Edit
&& app.engine.session().edit.keyboard_focus == "commands"
&& commands_focus(app, k, m.shift) =>
{
true
}
Key::R | Key::T if plain => {
// Zoom out / in on the timeline (Commands Focus style).
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
@ -191,18 +234,36 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
}
/// Single-key commands. Returns true when `key` was handled.
fn commands_focus(app: &mut SoundApp, key: Key) -> bool {
fn commands_focus(app: &mut SoundApp, key: Key, shift: bool) -> bool {
let id = match key {
Key::Z if shift => "edit.redo",
Key::P if shift => "edit.extend_selection_up",
Key::Semicolon | Key::Colon if shift => "edit.extend_selection_down",
_ if shift => return false,
Key::A => "edit.trim_start_to_insertion",
Key::S => "edit.trim_end_to_insertion",
Key::D => "edit.fade_to_start",
Key::G => "edit.fade_to_end",
Key::F => "edit.fades_create",
Key::Comma | Key::Period | Key::M | Key::Slash => {
let direction = if matches!(key, Key::Period | Key::Slash) { 1 } else { -1 };
let _ = app.run("edit.nudge", json!({"direction": direction, "next": matches!(key, Key::M | Key::Slash)}));
return true;
}
Key::Num0 => "edit.timeline_to_selection",
Key::O => "edit.selection_to_timeline",
Key::OpenBracket => "transport.play_selection",
Key::CloseBracket => "edit.play_timeline",
Key::B => "edit.separate",
Key::X => "edit.cut",
Key::C => "edit.copy",
Key::V => "edit.paste",
Key::Z => "edit.undo",
Key::N => "options.insertion_follows_playback",
Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 => {
let _ = app.run("view.zoom_preset", json!({"preset": digit(key)}));
return true;
}
Key::P => {
move_selection_track(app, -1);
return true;
@ -251,6 +312,10 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
}
}
fn digit(key: Key) -> u8 {
key.name().parse().unwrap_or(0)
}
/// Shortcut text for display. The registry stores macOS-style strings ("Cmd+N"); off macOS the Cmd
/// modifier is the Ctrl key. Mac-only Control chords ("Cmd+Ctrl+S") have no key there, so they show nothing.
pub fn shortcut_label(s: &str, mac: bool) -> String {
@ -288,8 +353,8 @@ mod label_tests {
#[cfg(test)]
mod tests {
use super::{mods_match, parse, search_chord};
use egui::Modifiers;
use super::{command_for, mods_match, parse, search_chord};
use egui::{Key, Modifiers};
/// Ctrl held on Windows or Linux, as egui reports it.
const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
@ -319,4 +384,11 @@ mod tests {
assert!(!search_chord(MAC_CMD, true));
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
}
#[test]
fn shift_cmd_k_prompts_for_a_folder_to_export_clips() {
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips"));
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
assert!(crate::dialogs::takes_path("file.export_clips"));
}
}

View File

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

View File

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

View File

@ -7,7 +7,7 @@ use crate::video_track::{MovieKey, movie_secs, online, picture_at};
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use serde_json::json;
use soundcraft_model::{ClipContent, TrackKind};
use soundcraft_time::{TimeFormat, format_position};
use soundcraft_time::TimeFormat;
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
if app.ui.show_video {
@ -81,7 +81,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
painter.text(img.center(), Align2::CENTER_CENTER, m, regular(12.0), Color32::from_gray(170));
}
if burn {
let tc = format_position(pos, TimeFormat::Timecode, s.sample_rate, &s.tempo, s.frame_rate, s.timecode_start);
let tc = s.format_position(pos, TimeFormat::Timecode);
let size = (h / 12.0).clamp(10.0, 36.0);
let galley = painter.layout_no_wrap(tc, mono(size), Color32::WHITE);
let at = pos2(img.center().x - galley.size().x / 2.0, img.max.y - galley.size().y - size * 0.6);
@ -95,6 +95,12 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
}
}
/// Timeline position of strip coordinate `x` (the strip spans `left`..`left + width` and samples
/// 0..`end`), clamped to the start of the clip being drawn.
fn strip_position(x: f32, left: f32, width: f32, end: i64, clip_start: i64) -> i64 {
(((x - left) / width * end as f32) as i64).max(clip_start)
}
fn first_movie_aspect(s: &soundcraft_model::Session) -> Option<f32> {
s.videos.first().map(|v| v.aspect() as f32)
}
@ -131,8 +137,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;
let mut x = cr.min.x;
while x < cr.max.x {
let at = c.start + ((x - r.min.x) / r.width() * end as f32) as i64 - c.start.min(0);
let secs = movie_secs(&s, c.start, offset, at.max(c.start)).max(0.0);
let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0);
if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) {
let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height()));
let vis = tr.intersect(cr);
@ -164,3 +169,33 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_video_universe = false;
}
}
#[cfg(test)]
mod tests {
use super::strip_position;
// A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32.
const LEFT: f32 = 100.0;
const WIDTH: f32 = 1024.0;
const END: i64 = 1_024_000;
#[test]
fn strip_position_is_the_timeline_position_for_clips_after_zero() {
// A clip at 48_000 has its left edge at x = 148.
assert_eq!(strip_position(148.0, LEFT, WIDTH, END, 48_000), 48_000);
assert_eq!(strip_position(248.0, LEFT, WIDTH, END, 48_000), 148_000);
let s = soundcraft_model::Session::default();
let at = strip_position(148.0, LEFT, WIDTH, END, 48_000);
assert_eq!(crate::video_track::movie_secs(&s, 48_000, 0, at), 0.0, "the clip's left edge shows the movie's first picture");
}
#[test]
fn strip_position_is_unchanged_for_clips_at_or_before_zero() {
assert_eq!(strip_position(100.0, LEFT, WIDTH, END, 0), 0);
assert_eq!(strip_position(200.0, LEFT, WIDTH, END, 0), 100_000);
// A clip at -48_000 starts left of the strip; positions before it clamp to its start.
assert_eq!(strip_position(0.0, LEFT, WIDTH, END, -48_000), -48_000);
assert_eq!(strip_position(52.0, LEFT, WIDTH, END, -48_000), -48_000);
assert_eq!(strip_position(200.0, LEFT, WIDTH, END, -48_000), 100_000);
}
}

View File

@ -4,7 +4,88 @@ use craft_ui::buttons::{ButtonVisuals, IconButton, IconButtonStyle};
use crate::icons;
use crate::theme::{Tokens, bold, regular};
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
use egui::{Align2, Color32, CornerRadius, Mesh, Painter, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2};
/// `c` moved towards white (`k > 0`) or black (`k < 0`).
pub fn shade(c: Color32, k: f32) -> Color32 {
if k >= 0.0 { c.lerp_to_gamma(Color32::WHITE, k) } else { c.lerp_to_gamma(Color32::BLACK, -k) }
}
fn quad(mesh: &mut Mesh, r: Rect, [tl, tr, bl, br]: [Color32; 4]) {
let i = mesh.vertices.len() as u32;
for (p, c) in [(r.left_top(), tl), (r.right_top(), tr), (r.left_bottom(), bl), (r.right_bottom(), br)] {
mesh.colored_vertex(p, c);
}
mesh.add_triangle(i, i + 1, i + 2);
mesh.add_triangle(i + 1, i + 2, i + 3);
}
/// Fills `r` with a vertical gradient from `top` to `bottom`.
pub fn vgradient(p: &Painter, r: Rect, top: Color32, bottom: Color32) {
let mut mesh = Mesh::default();
quad(&mut mesh, r, [top, top, bottom, bottom]);
p.add(mesh);
}
/// A rounded rectangle shaded from `top` to `bottom`, with egui's anti-aliased edges.
pub fn rounded_vgradient(p: &Painter, r: Rect, radius: f32, top: Color32, bottom: Color32) {
let options = p.ctx().tessellation_options(|o| *o);
let mut tessellator = egui::epaint::Tessellator::new(p.pixels_per_point(), options, [1, 1], Vec::new());
let mut mesh = Mesh::default();
tessellator.tessellate_rect(&egui::epaint::RectShape::filled(r, radius, Color32::WHITE), &mut mesh);
for v in &mut mesh.vertices {
let k = ((v.pos.y - r.min.y) / r.height()).clamp(0.0, 1.0);
v.color = top.lerp_to_gamma(bottom, k).gamma_multiply(v.color.a() as f32 / 255.0);
}
p.add(mesh);
}
/// A raised key as Pro Tools draws them: a dark rim, a face shaded from `faces[0]` to `faces[1]`
/// and a lit top edge. Returns the face's top colour.
pub fn raised_key(p: &Painter, r: Rect, faces: [Color32; 2], hovered: bool) -> Color32 {
let [top, bottom] = if hovered { faces.map(|c| shade(c, 0.06)) } else { faces };
p.rect_filled(r, 3.0, shade(Tokens::current().slot_well, -0.3));
rounded_vgradient(p, r.shrink(1.0), 2.0, top, bottom);
p.hline(r.min.x + 2.0..=r.max.x - 2.0, r.min.y + 1.5, Stroke::new(1.0, shade(top, 0.17)));
top
}
/// A groove across a raised key whose face starts at `top`: a dark line, then a light one.
pub fn groove(p: &Painter, x: f32, ys: impl Into<egui::Rangef>, top: Color32) {
let ys = ys.into();
p.vline(x, ys, Stroke::new(1.0, shade(top, -0.42)));
p.vline(x + 1.0, ys, Stroke::new(1.0, shade(top, 0.12)));
}
pub fn inset(p: &Painter, r: Rect, fill: Color32) {
let t = Tokens::current();
p.rect(r, 2.0, fill, Stroke::new(1.0, t.border), StrokeKind::Inside);
p.hline(r.min.x + 1.0..=r.max.x - 1.0, r.min.y + 1.5, Stroke::new(1.0, Color32::from_black_alpha(60)));
p.hline(r.min.x + 2.0..=r.max.x - 2.0, r.max.y + 0.5, Stroke::new(1.0, t.edge_light));
}
pub fn key_fill(resp: &Response) -> Color32 {
let t = Tokens::current();
if resp.hovered() { t.button_hi } else { t.button }
}
/// A key in `fill`, softly lit from above; it sinks while pressed.
pub fn key(p: &Painter, r: Rect, resp: &Response, fill: Color32) {
let face = r.shrink(1.0);
if resp.is_pointer_button_down_on() {
vgradient(p, face, shade(fill, -0.14), shade(fill, 0.02));
} else {
p.rect_filled(face, 0.0, fill);
key_light(p, face);
}
p.rect_stroke(r, 3.0, Stroke::new(1.0, Tokens::current().button_border), StrokeKind::Inside);
}
/// Light from above over a key face already filled with its colour.
fn key_light(p: &Painter, face: Rect) {
vgradient(p, face, Color32::from_white_alpha(26), Color32::from_black_alpha(31));
p.hline(face.min.x + 1.0..=face.max.x - 1.0, face.min.y + 0.5, Stroke::new(1.0, Color32::from_white_alpha(30)));
}
/// Local colors for shared icon-button interaction; transport-specific states stay in the toolbar.
fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> IconButtonStyle {
@ -13,7 +94,7 @@ fn button_style(selected_fill: Color32, normal_icon: Color32, radius: u8) -> Ico
IconButtonStyle {
normal: visuals(t.button, normal_icon),
hovered: visuals(t.button_hi, normal_icon),
pressed: None,
pressed: Some(visuals(shade(t.button, -0.1), normal_icon)),
selected: visuals(selected_fill, Color32::WHITE),
corner_radius: CornerRadius::same(radius),
focus_stroke: Stroke::new(1.0, t.accent),
@ -25,6 +106,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st
let t = Tokens::current();
let style = button_style(t.accent, t.text, 3);
let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| {
key_light(ui.painter(), r.shrink(1.0));
let s = r.height().min(r.width()) * 0.78;
icons::draw(ui.painter(), Rect::from_center_size(r.center(), vec2(s, s)), icon, tint);
});
@ -35,14 +117,7 @@ pub fn icon_button(ui: &mut Ui, size: egui::Vec2, icon: &str, on: bool, tip: &st
pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color: Color32, tip: &str) -> Response {
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let fill = if on {
on_color
} else if resp.hovered() {
t.button_hi
} else {
t.button
};
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.button_border), StrokeKind::Inside);
key(ui.painter(), r, &resp, if on { on_color } else { key_fill(&resp) });
let col = if on { t.text_dark } else { t.text };
ui.painter().text(r.center(), Align2::CENTER_CENTER, text, bold((size.y * 0.62).clamp(8.0, 13.0)), col);
resp.on_hover_text(tip)
@ -50,9 +125,23 @@ pub fn text_toggle(ui: &mut Ui, size: egui::Vec2, text: &str, on: bool, on_color
/// Record-enable button (red dot); the caller owns track arming and command dispatch.
pub fn rec_toggle(ui: &mut Ui, size: egui::Vec2, on: bool, tip: &str) -> Response {
let style = button_style(Tokens::current().rec, Color32::from_rgb(190, 190, 190), 2);
let t = Tokens::current();
let style = button_style(t.rec, Color32::from_rgb(218, 112, 106), 2);
let resp = IconButton::new(tip, size, &style).selected(on).single_shape_frame().show(ui, |ui, r, tint| {
ui.painter().circle_filled(r.center(), size.y * 0.24, tint);
key_light(ui.painter(), r.shrink(1.0));
let rad = size.y * 0.24;
if on {
ui.painter().circle_filled(r.center(), rad, tint);
} else {
rounded_vgradient(
ui.painter(),
Rect::from_center_size(r.center(), Vec2::splat(2.0 * rad)),
2.0 * rad,
tint,
Color32::from_rgb(176, 76, 72),
);
}
ui.painter().circle_stroke(r.center(), rad, Stroke::new(1.0, Color32::from_black_alpha(90)));
});
resp.on_hover_text(tip)
}
@ -66,13 +155,7 @@ pub fn selector_box(ui: &mut Ui, width: f32, height: f32, text: &str, color: Col
pub(crate) fn selector_box_with_tooltip(ui: &mut Ui, width: f32, height: f32, text: &str, color: Color32, tooltip: &str) -> Response {
let t = Tokens::current();
let (r, resp) = ui.allocate_exact_size(vec2(width, height), Sense::click());
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { t.button_hi } else { t.button },
Stroke::new(1.0, t.button_border),
StrokeKind::Inside,
);
key(ui.painter(), r, &resp, key_fill(&resp));
let tr = Rect::from_min_max(pos2(r.max.x - height * 0.8, r.min.y), r.max);
icons::draw(ui.painter(), tr.shrink(height * 0.15), "triangle_down", t.text_dim);
let text_rect = Rect::from_min_max(r.min + vec2(3.0, 1.0), pos2(tr.left() - 2.0, r.bottom() - 1.0));
@ -97,9 +180,9 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response
if resp.double_clicked() {
*value = 0.0;
}
let p = ui.painter();
let c = r.center();
let rad = size * 0.42;
ui.painter().circle(c, rad, t.knob_bg, Stroke::new(1.0, t.knob_ring));
let n = 24;
let start = std::f32::consts::PI * 0.75;
let sweep = std::f32::consts::PI * 1.5;
@ -114,9 +197,13 @@ pub fn pan_knob(ui: &mut Ui, size: f32, value: &mut f32, tip: &str) -> Response
let mid = start + sweep * 0.5;
let at = start + sweep * (*value * 0.5 + 0.5);
let (a0, a1) = if at < mid { (at, mid) } else { (mid, at) };
ui.painter().add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text)));
let tip_pos = c + vec2(at.cos(), at.sin()) * rad * 0.85;
ui.painter().line_segment([c, tip_pos], Stroke::new(2.0, t.knob_pointer));
p.add(egui::Shape::line(arc(start, start + sweep), Stroke::new(2.0, t.meter_bg)));
p.add(egui::Shape::line(arc(a0, a1), Stroke::new(2.0, t.counter_text)));
p.circle_filled(c + vec2(0.0, 1.0), rad, Color32::from_black_alpha(90));
rounded_vgradient(p, Rect::from_center_size(c, Vec2::splat(2.0 * rad)), 2.0 * rad, shade(t.knob_bg, 0.14), shade(t.knob_bg, -0.2));
p.circle_stroke(c, rad - 0.75, Stroke::new(1.5, t.knob_ring));
let d = vec2(at.cos(), at.sin());
p.line_segment([c + d * rad * 0.15, c + d * (rad - 2.5)], Stroke::new(2.0, t.knob_pointer));
resp.on_hover_text(tip)
}
@ -130,40 +217,42 @@ pub fn pan_text(v: f32) -> String {
}
}
/// Vertical peak meter with green/yellow/red zones; values are linear peaks.
/// Vertical LED peak meter with green/yellow/red zones; values are linear peaks.
pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) {
let t = Tokens::current();
ui.painter().rect_filled(r, 0.0, t.meter_bg);
let pos = |v: f32| -> f32 {
let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() };
meter_pos(db)
};
let p = ui.painter();
p.rect_filled(r, 0.0, t.meter_bg);
let h = r.height() - 4.0;
let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0));
let lv = pos(level);
if lv > 0.0 {
let top = inner.max.y - h * lv;
// Draw in zones.
let zones =
[(0.0, meter_pos(-12.0), t.meter_green), (meter_pos(-12.0), meter_pos(-3.0), t.meter_yellow), (meter_pos(-3.0), 1.0, t.meter_red)];
for (a, b, col) in zones {
let y0 = inner.max.y - h * a;
let y1 = (inner.max.y - h * b).max(top);
if y1 < y0 {
ui.painter().rect_filled(Rect::from_min_max(pos2(inner.min.x, y1), pos2(inner.max.x, y0)), 0.0, col);
}
}
let lv = level_pos(level);
let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0));
let mut leds = Mesh::default();
for i in 0..(h / 3.0).clamp(0.0, 1000.0) as usize {
let y = inner.max.y - i as f32 * 3.0;
let f = (i as f32 * 3.0 + 1.0) / h;
let zone = if f > red {
t.meter_red
} else if f > amber {
t.meter_yellow
} else {
t.meter_green
};
let col = if f <= lv { zone } else { zone.gamma_multiply(0.1) };
quad(&mut leds, Rect::from_x_y_ranges(inner.x_range(), y - 2.0..=y), [col; 4]);
}
let hv = pos(hold);
p.add(leds);
let hv = level_pos(hold);
if hv > 0.01 {
let y = inner.max.y - h * hv;
ui.painter().line_segment(
[pos2(inner.min.x, y), pos2(inner.max.x, y)],
Stroke::new(1.0, if hv > meter_pos(-3.0) { t.meter_red } else { t.meter_yellow }),
);
p.line_segment([pos2(inner.min.x, y), pos2(inner.max.x, y)], Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow }));
}
let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5));
ui.painter().rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off });
p.rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off });
}
/// A linear peak → 0..1 meter position.
pub fn level_pos(v: f32) -> f32 {
meter_pos(if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() })
}
/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters).
@ -199,28 +288,32 @@ pub fn clip_gain_to_pos(db: f32) -> f32 {
pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option<f32> {
let t = Tokens::current();
let resp = ui.interact(r, id, Sense::click_and_drag());
let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - 14.0));
ui.painter().rect_filled(slot, 2.0, t.fader_track);
// Scale marks.
let p = ui.painter();
let cap_size = vec2((r.width() * 0.44).clamp(16.0, 24.0), 34.0);
let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - cap_size.y));
p.rect_filled(slot, 2.0, t.fader_track);
p.vline(slot.min.x + 0.5, slot.min.y + 1.0..=slot.max.y - 1.0, Stroke::new(1.0, Color32::from_black_alpha(120)));
p.vline(slot.max.x + 0.5, slot.min.y + 2.0..=slot.max.y - 2.0, Stroke::new(1.0, Color32::from_white_alpha(18)));
for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] {
let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark);
ui.painter().line_segment([pos2(r.min.x + 2.0, y), pos2(slot.min.x - 3.0, y)], Stroke::new(1.0, Color32::from_rgb(110, 110, 110)));
ui.painter().text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), Color32::from_rgb(150, 150, 150));
let x = p.text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim).max.x + 2.0;
if x < slot.min.x - 3.0 {
p.hline(x..=slot.min.x - 3.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7)));
}
}
let pos = soundcraft_model::fader_db_to_pos(db);
let y = slot.max.y - slot.height() * pos;
let cap = Rect::from_center_size(pos2(slot.center().x, y), vec2(r.width() * 0.62, 22.0));
ui.painter().rect(cap, 2.0, Color32::from_rgb(70, 70, 72), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside);
ui.painter().line_segment(
[pos2(cap.min.x + 2.0, cap.center().y), pos2(cap.max.x - 2.0, cap.center().y)],
Stroke::new(2.0, Color32::from_rgb(230, 230, 230)),
);
for dy in [-6.0, -3.0, 3.0, 6.0] {
ui.painter().line_segment(
[pos2(cap.min.x + 3.0, cap.center().y + dy), pos2(cap.max.x - 3.0, cap.center().y + dy)],
Stroke::new(1.0, Color32::from_rgb(40, 40, 40)),
);
let cap = Rect::from_center_size(pos2(slot.center().x, y), cap_size);
p.rect_filled(cap.translate(vec2(0.0, 2.0)).expand(1.0), 4.0, Color32::from_black_alpha(80));
let g = Color32::from_gray;
vgradient(p, cap.shrink(1.0), g(226), g(146));
p.rect_stroke(cap, 3.0, Stroke::new(1.0, g(34)), StrokeKind::Inside);
for dy in [-10.0, -7.0, 7.0, 10.0] {
let x = cap.min.x + 3.0..=cap.max.x - 3.0;
p.hline(x.clone(), cap.center().y + dy, Stroke::new(1.0, Color32::from_black_alpha(50)));
p.hline(x, cap.center().y + dy + 1.0, Stroke::new(1.0, Color32::from_white_alpha(90)));
}
p.hline(cap.min.x + 1.0..=cap.max.x - 1.0, cap.center().y, Stroke::new(2.0, g(24)));
if resp.double_clicked() || (resp.clicked() && ui.input(|i| i.modifiers.alt)) {
return Some(0.0);
}

View File

@ -143,6 +143,8 @@ fn color_palette(app: &mut SoundApp, ctx: &egui::Context) {
}
}
});
ui.separator();
ui.add(egui::Slider::new(&mut app.ui.track_tint, 0.0..=1.0).show_value(false).text("Track tint in the Edit window"));
});
app.ui.show_color_palette = open;
}
@ -202,10 +204,7 @@ fn metadata(app: &mut SoundApp, ctx: &egui::Context) {
("Clips", s.tracks.iter().map(|t| t.clips().len()).sum::<usize>().to_string()),
("Audio files", s.sources.len().to_string()),
("Markers", s.markers.len().to_string()),
(
"Length",
soundcraft_time::format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs, s.sample_rate, &s.tempo, s.frame_rate, 0),
),
("Length", s.format_position(s.content_end(), soundcraft_time::TimeFormat::MinSecs)),
] {
ui.label(k);
ui.label(v);
@ -334,7 +333,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
}
let is_dir = e.file_type().is_ok_and(|t| t.is_dir());
let ext = std::path::Path::new(&name).extension().and_then(|x| x.to_str()).unwrap_or("").to_ascii_lowercase();
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
(is_dir || ["wav", "aif", "aiff", "flac", "mp3", "ogg", "m4a", "m4b", "caf", "mid", "midi", "scraft"].contains(&ext.as_str()))
.then_some((name, is_dir))
})
.collect()
@ -350,7 +349,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
if is_dir {
app.ui.workspace_dir = path;
} else if name.ends_with(".scraft") {
let _ = app.run("session.open", json!({"path": path}));
let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path}));
} else if name.ends_with(".mid") || name.ends_with(".midi") {
let _ = app.run("file.import_midi", json!({"path": path}));
} else {
@ -493,10 +492,13 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) {
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0)));
ui.label(c.label);
ui.label(egui::RichText::new(c.menu.join(" › ")).small());
let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › ")));
let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › ")));
for (shortcut, label, menu) in engine.chain(windows) {
let Some(shortcut) = shortcut else { continue };
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
ui.label(label);
ui.label(egui::RichText::new(menu).small());
ui.end_row();
}
});

View File

@ -0,0 +1,108 @@
//! Every user-facing engine command must be reachable from the GUI: through a menu, a shortcut,
//! or a widget that names it in `src/`. Commands that only work from the control channel / CLI /
//! MCP left users stuck (#282: `midi.new_clip` had no UI path, so an empty instrument track could
//! never get a MIDI clip).
use soundcraft_engine::{catalog, cmd::command_specs};
use soundcraft_ui_egui::menus::{self, MenuNode};
use std::collections::HashSet;
use std::path::Path;
/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today.
/// Give each one a UI path and remove it from this list; don't add to it.
const KNOWN_UNREACHED: &[&str] = &[
"automation.clear",
"edit.paste_clip_effects",
"edit.paste_clip_gain",
"event.midi_note",
"file.bounce_stems",
"markers.next",
"markers.previous",
"mix.clear_mutes",
"mix.clear_solos",
"mix.insert_move",
"mix.instrument_param",
"mix.phase_invert",
"mix.sampler_clear",
"mix.sampler_load",
"mix.send_surround_pan",
"mix.solo_safe",
"options.post_roll",
"options.pre_roll",
"setup.hardware", // menu opens the Playback Engine window, which doesn't run it
"setup.playback_engine", // same
"setup.reset_edit_state",
"track.load_preset",
"track.pin",
"track.trim_automation_write",
"track.vca",
"transport.pause",
"transport.status",
"video.offset",
"view.sub_counter",
];
fn ui_source() -> String {
fn walk(dir: &Path, out: &mut String) {
for entry in std::fs::read_dir(dir).unwrap() {
let p = entry.unwrap().path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|e| e == "rs") {
out.push_str(&std::fs::read_to_string(&p).unwrap());
}
}
}
let mut s = String::new();
walk(&Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut s);
s
}
/// Command ids behind the leaves of the menu bar actually built by the app. A spec's own `menu`
/// field is not enough: the bar is built from the catalog, so an off-catalog path never shows.
fn menu_commands() -> HashSet<String> {
fn walk(nodes: &[MenuNode], extra: &[(&str, &str)], out: &mut HashSet<String>) {
for n in nodes {
if n.children.is_empty() {
out.extend(catalog::implemented_by(&n.path, extra));
}
walk(&n.children, extra, out);
}
}
let mut out = HashSet::new();
walk(&menus::tree(), &menus::ui_aliases(), &mut out);
out
}
#[test]
fn every_command_has_a_ui_path() {
let src = ui_source();
let in_menus = menu_commands();
let mut unreached = Vec::new();
let mut stale = Vec::new();
for c in command_specs() {
// Queries and view-only commands are for agents / the UI's own reads.
if !c.journal || c.view {
continue;
}
let reached = in_menus.contains(c.id) || c.shortcut.is_some() || src.contains(&format!("\"{}\"", c.id));
let known = KNOWN_UNREACHED.contains(&c.id);
if !reached && !known {
unreached.push(c.id);
} else if reached && known {
stale.push(c.id);
}
}
assert!(unreached.is_empty(), "commands with no menu, shortcut or UI reference: {unreached:?}");
assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}");
}
#[test]
fn new_midi_clip_is_in_the_clip_menu() {
let extra = menus::ui_aliases();
assert_eq!(catalog::implemented_by("Clip > New MIDI Clip", &extra).as_deref(), Some("midi.new_clip"));
assert!(menu_commands().contains("midi.new_clip"));
let tree = menus::tree();
let clip = tree.iter().find(|n| n.label == "Clip").unwrap();
assert_eq!(clip.children[0].label, "New MIDI Clip", "creates rather than edits, so it comes first");
}

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