Merge origin/main into fix/mixer-routing-qol

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-11 20:34:16 -04:00
commit b25a49ee5a
33 files changed
+1618 -136

No files matched your search

+8 -4
View File
@@ -3,16 +3,20 @@
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "47 3 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/playback/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
+6
View File
@@ -34,3 +34,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+5 -7
View File
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "53 5 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
Generated
+2
View File
@@ -3418,6 +3418,8 @@ dependencies = [
"egui",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"soundcraft-engine",
+4
View File
@@ -277,6 +277,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"`
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
### macOS
| Build | File | Notes |
+55 -13
View File
@@ -20,11 +20,12 @@ use soundcraft_engine::Engine;
use std::io::Write;
use std::process::ExitCode;
/// Print without panicking on a closed pipe.
/// Print without panicking on a closed pipe. A write error must not skip later work
/// such as `--bounce` or `--save`, and must not by itself report success.
macro_rules! outln {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
let _ = writeln!(o, $($t)*);
}};
}
@@ -294,6 +295,39 @@ fn describe(args: &[String]) -> ExitCode {
}
}
/// Control-channel methods. Every other dotted id is an engine command.
fn is_control_method(method: &str) -> bool {
matches!(
method,
"engine.execute"
| "command"
| "engine.commands"
| "engine.parity"
| "session.inspect"
| "document.inspect"
| "ui.inspect"
| "ui.menu.list"
| "ui.menu.invoke"
| "ui.set"
| "ui.move"
| "ui.click"
| "ui.drag"
| "ui.key"
| "ui.text"
| "ui.screenshot"
| "app.quit"
)
}
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
fn app_request(method: &str, params: Value) -> (String, Value) {
if is_control_method(method) || !method.contains('.') {
(method.to_string(), params)
} else {
("engine.execute".to_string(), json!({"command": method, "params": params}))
}
}
fn app(args: &[String]) -> ExitCode {
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
let positional: Vec<&String> = args
@@ -311,17 +345,8 @@ fn app(args: &[String]) -> ExitCode {
None => json!({}),
};
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute.
let (m, p) = if method.contains('.')
&& !method.starts_with("ui.")
&& !method.starts_with("engine.")
&& !method.starts_with("session.")
&& !method.starts_with("app.")
{
("engine.execute".to_string(), json!({"command": method, "params": params}))
} else {
(method.to_string(), params)
};
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
let (m, p) = app_request(method, params);
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -380,6 +405,23 @@ fn parity(args: &[String]) -> ExitCode {
mod tests {
use super::*;
#[test]
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
let (m, p) = app_request("engine.history", json!({}));
assert_eq!(m, "engine.execute");
assert_eq!(p["command"], "engine.history");
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
assert_eq!(m, "engine.execute");
assert_eq!(p["params"]["mode"], "light");
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
assert_eq!(m, "engine.execute");
let (m, _) = app_request("track.new", json!({}));
assert_eq!(m, "engine.execute");
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
assert_eq!(app_request(method, json!({})).0, method);
}
}
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
+124
View File
@@ -0,0 +1,124 @@
//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`.
use std::process::{Command, Stdio};
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
/// Drop the read end so the child's stdout writes fail with `EPIPE`.
fn closed_stdout() -> std::process::Stdio {
let (reader, writer) = std::io::pipe().expect("pipe");
drop(reader);
Stdio::from(writer)
}
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn run_with_closed_stdout_still_bounces_and_saves() {
let dir = temp_dir("run");
let wav = dir.join("mix.wav");
let session = dir.join("Synthetic.scraft");
let output = bin()
.arg("run")
.arg("--cmd")
.arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#)
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file(), "bounce was skipped");
assert!(session.is_file(), "save was skipped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn script_with_closed_stdout_still_bounces_and_saves() {
let dir = temp_dir("script");
let script = dir.join("edit.scraft.txt");
std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
let wav = dir.join("mix.wav");
let session = dir.join("Synthetic.scraft");
let output = bin()
.arg("script")
.arg(&script)
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file());
assert!(session.is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bounce_then_save_with_closed_stdout_writes_the_wav() {
let dir = temp_dir("bounce");
let wav = dir.join("mix.wav");
let session = dir.join("Empty.scraft");
let output = bin()
.arg("run")
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file());
assert!(session.is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn invalid_bounce_destination_still_fails() {
let dir = temp_dir("bad-bounce");
let missing = dir.join("no-such-dir").join("mix.wav");
let output = bin()
.arg("run")
.arg("--bounce")
.arg(&missing)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(!output.status.success(), "a bounce that cannot be written must fail");
assert!(!missing.exists());
let _ = std::fs::remove_dir_all(&dir);
}
+2
View File
@@ -28,3 +28,5 @@ winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
+51 -5
View File
@@ -1,10 +1,14 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, Dx12Compiler},
};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
// Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships
// none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
// indirect-validation pipeline and loses the device before the first frame
// (issue #185). FXC uses the compiler that is part of Windows.
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
log::info!(
"Windows graphics: backends={:?}, DX12 compiler={:?}",
create.instance_descriptor.backends,
create.instance_descriptor.backend_options.dx12.shader_compiler
);
}
}
@@ -21,7 +35,7 @@ mod tests {
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
configure(&mut options, backend_override, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -38,6 +52,38 @@ mod tests {
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
let mut options = NativeOptions::default();
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #185).
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
}
configure(&mut options, None, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
#[test]
fn explicit_compiler_override_is_preserved() {
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
let mut options = NativeOptions::default();
configure(&mut options, None, Some(compiler.clone()));
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
assert_eq!(actual, expected);
}
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
}
}
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
+2 -2
View File
@@ -225,9 +225,9 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
eframe::run_native(
"SoundCraft",
options,
+12 -4
View File
@@ -1,5 +1,7 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
@@ -36,8 +38,9 @@ impl NativeMenu {
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::services(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
app_menu.append(&PredefinedMenuItem::hide_others(None))?;
// muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.
app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;
app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;
app_menu.append(&PredefinedMenuItem::show_all(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
@@ -82,7 +85,12 @@ impl NativeMenu {
while let Ok(event) = self.events.try_recv() {
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
self.last_sync = None;
if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
if (event.id == "hide" || event.id == "hide_others")
&& let Some(mtm) = MainThreadMarker::new()
{
let ns = NSApplication::sharedApplication(mtm);
if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) }
} else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
+5
View File
@@ -35,3 +35,8 @@ sync_github_name = false
[people.salignatmoandal]
real_name = "Mawen Salignat-Moandal"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
+14 -3
View File
@@ -127,11 +127,20 @@ impl<B: Backend> Server<B> {
}
"batch" => {
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
return error_result("`calls` must be an array");
};
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
failed = true;
if !keep {
break;
}
continue;
};
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
match r {
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
@@ -144,7 +153,9 @@ impl<B: Backend> Server<B> {
}
}
}
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
// Any failure sets isError so agents that only check that flag notice;
// keep_going still runs the rest and the payload lists every call.
if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
}
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
"new_session" => self.exec("session.new", args.clone()),
+36
View File
@@ -135,3 +135,39 @@ fn errors_are_reported_not_panics() {
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
}
#[test]
fn batch_keep_going_still_sets_is_error_on_partial_failure() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(
&mut s,
1,
"tools/call",
json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
{"command": "track.new", "params": {"count": 1, "name": "Kept"}},
{"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
{"command": "track.new", "params": {"count": 1, "name": "After"}},
]}}),
);
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
let results: Value = serde_json::from_str(text).unwrap();
let arr = results.as_array().unwrap();
assert_eq!(arr.len(), 3, "{results}");
assert_eq!(arr[0]["ok"], true);
assert_eq!(arr[1]["ok"], false);
assert_eq!(arr[2]["ok"], true);
let sess = session(&mut s);
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
}
#[test]
fn batch_rejects_non_array_calls() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("`calls` must be an array"), "{text}");
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
}
+46 -2
View File
@@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
nc.gain_db = 0.0;
nc.gain_env.clear();
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
// the same way its fades stay editable. Other processes bake a new file at unity gain.
if !matches!(name.as_str(), "invert" | "duplicate") {
nc.gain_db = 0.0;
nc.gain_env.clear();
}
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
pl.clips.retain(|c| c.id != id);
}
@@ -122,3 +126,43 @@ fn short(n: &str) -> String {
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
s.chars().take(8).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Session, TrackKind};
fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
let id = s.new_clip_id();
let mut c = Clip::audio(id, "tone", src, 0, 0, 64);
c.gain_db = -6.0;
c.gain_env = vec![(0, 0.0), (32, -12.0)];
crate::edit::place_clip(&mut s, t, c);
s.edit.selected_clips = vec![id];
(crate::Engine::new(s), id)
}
#[test]
fn invert_and_duplicate_keep_clip_gain() {
let (mut e, id) = gained_clip();
e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}");
let (mut e, id) = gained_clip();
e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
}
+65
View File
@@ -362,4 +362,69 @@ mod tests {
assert_eq!(c.length, 18_000);
assert_ne!(c.source(), Some(src));
}
fn peak_from(ch: &[f32], start: usize) -> f32 {
ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs()))
}
fn max_abs_diff(a: &[Vec<f32>], b: &[Vec<f32>]) -> f32 {
a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max)
}
/// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result.
#[test]
fn render_preserves_stretched_audio() {
for command in ["clip.gain_render", "clip.effects_render"] {
for ratio in [0.5_f64, 1.0, 2.0] {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
s.edit.selected_clips = vec![id];
let mut e = Engine::new(s);
e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap();
if command == "clip.effects_render" {
e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap();
}
let range = e.session().find_clip(id).unwrap().1.range();
let before = soundcraft_mix::render_clips(e.session(), t, range);
e.execute(command, &json!({"clip": id.0})).unwrap();
let c = e.session().find_clip(id).unwrap().1.clone();
assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch);
assert_eq!(c.gain_db, 0.0);
let after = soundcraft_mix::render_clips(e.session(), t, range);
let diff = max_abs_diff(&before, &after);
assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}");
if ratio < 1.0 {
let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0);
assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}");
}
}
}
// Varispeed on a non-elastic track does not change the clip length. Render still matches it.
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
s.edit.selected_clips = vec![id];
let mut e = Engine::new(s);
e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap();
e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
let range = e.session().find_clip(id).unwrap().1.range();
let before = soundcraft_mix::render_clips(e.session(), t, range);
e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap();
let c = e.session().find_clip(id).unwrap().1.clone();
assert_eq!(c.length, 9_600);
assert!((c.stretch - 1.0).abs() < 1e-9);
let after = soundcraft_mix::render_clips(e.session(), t, range);
assert!(max_abs_diff(&before, &after) < 1e-5);
}
}
+199 -6
View File
@@ -27,9 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
let keep = e.path.clone();
let written = crate::io::save_session(e, &path)?;
e.path = keep;
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| {
@@ -98,7 +96,8 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
let bytes = crate::io::export_midi(e)?;
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}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
@@ -234,6 +233,17 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result<Option<TrackId>> {
selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track"))
}
/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error.
fn bounce_bus(e: &Engine, p: &Value) -> Result<Option<soundcraft_model::BusId>> {
let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
if name.eq_ignore_ascii_case("main") {
return Ok(None);
}
e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`")))
}
/// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix.
fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
match str_param(p, "fold_down").map(str::trim) {
@@ -260,7 +270,8 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
let normalize = bool_or(p, "normalize", false);
let fold = fold_down_param(p, "file.bounce_mix")?;
let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?;
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}")))?;
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}),
@@ -365,6 +376,53 @@ mod tests {
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
#[test]
fn bounce_source_renders_the_named_bus() {
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
let track = e.session().tracks[0].id;
{
let tr = e.session_mut().track_mut(track).unwrap();
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
send.level_db = -12.0;
tr.mixer.sends[0] = Some(send);
}
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
let path = dir.join(name);
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
if let Some(source) = source {
params["source"] = json!(source);
}
let value = e.execute("file.bounce_mix", &params).unwrap();
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
(std::fs::read(&path).unwrap(), peak)
};
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
assert_eq!(main_bytes, named_main);
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
assert!(main_peak > -20.0, "{main_peak}");
assert_ne!(stem_bytes, main_bytes);
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
assert_ne!(empty_bytes, main_bytes);
assert!(empty_peak < -100.0, "{empty_peak}");
let missing = dir.join("missing.wav");
let err = e
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
.unwrap_err();
assert!(err.to_string().contains("unknown bus"), "{err}");
assert!(!missing.exists());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
@@ -390,7 +448,6 @@ mod tests {
assert!(res["missing"].as_array().is_some_and(|m| !m.is_empty()), "{res}");
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();
@@ -428,4 +485,140 @@ mod tests {
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
let mut keys_seq = Sequence::default();
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
let mut drum_seq = Sequence::default();
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
let a = s.new_clip_id();
let b = s.new_clip_id();
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
Engine::new(s)
}
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
let smf = soundcraft_midi::read_smf(bytes).unwrap();
let mut out = Vec::new();
for t in &smf.tracks {
for c in &t.sequence.ctrls {
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
out.push((t.name.clone(), n, c.value));
}
}
}
out
}
#[test]
fn midi_export_keeps_controllers_and_honours_tracks() {
let mut e = midi_engine();
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let all = dir.join("all.mid");
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
let keys = dir.join("keys.mid");
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
assert_eq!(ctrls[0].0, "Keys");
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
let src = e.session().sources[0].id;
let audio = e.session().pool.get(src).unwrap();
assert_eq!(audio.buffer.channels.len(), 6);
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
let id = e.session().tracks[0].id;
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
assert_eq!(e.session().tracks[0].clips().len(), 1);
}
#[test]
fn repeated_consolidate_keeps_distinct_audio_files() {
let (mut e, track) = tone_engine();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let cid = e.session().tracks[0].clips()[0].id;
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
assert_ne!(consolidated[0], consolidated[1]);
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tone.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut opened = Engine::default();
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
let a = std::fs::read(&files[0]).unwrap();
let b = std::fs::read(&files[1]).unwrap();
assert_ne!(a, b, "the second consolidation replaced the first file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_copy_leaves_the_open_session_alone() {
let (mut e, _) = tone_engine();
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("Original.scraft");
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
assert!(e.is_dirty());
let copy_dir = dir.join("copy");
std::fs::create_dir_all(&copy_dir).unwrap();
let copy = copy_dir.join("Copy.scraft");
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
assert_eq!(e.session().name, "Original");
assert!(e.is_dirty());
assert_eq!(e.session().tracks.len(), 2);
let mut from_original = Engine::default();
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
assert_eq!(from_original.session().tracks.len(), 1);
let mut from_copy = Engine::default();
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(from_copy.session().tracks.len(), 2);
assert!(from_copy.session().track_by_name("Extra").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
+189 -9
View File
@@ -536,25 +536,95 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
Ok(json!({}))
}
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
let mut prev: Option<(i64, f32)> = None;
for &(t, v) in env {
if t >= at {
return match prev {
Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
_ => v,
};
}
prev = Some((t, v));
}
prev.map_or(0.0, |(_, v)| v)
}
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
let s = e.session_mut();
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let lane = tr.lane(&AutoParam::Volume).cloned();
let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
let base = tr.mixer.volume_db;
let Some(lane) = lane else { continue };
let delta = |at: i64| lane.value_at(at, base) - base;
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
let mut env: Vec<(i64, f32)> =
lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect();
env.insert(0, (0, lane.value_at(c.start, base) - base));
env.push((c.length, lane.value_at(c.end(), base) - base));
let sel0 = r.start.max(c.start);
let sel1 = r.end.min(c.end());
if sel1 <= sel0 {
continue;
}
let rel = |at: i64| at - c.start;
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
env.push(p);
}
c.gain_env = env;
}
}
tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end);
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
// held edge values outside would fall back to the fader level.
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
let lane_m = tr.lane_mut(&AutoParam::Volume);
if covers_all {
lane_m.clear_range(r.start, r.end);
} else {
// Hold the fader across the selection and anchor the samples on either side, so the
// curve outside does not interpolate through the gap or keep the old level inside.
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
let after = lane.value_at(r.end, base);
lane_m.clear_range(r.start, r.end);
if let Some(v) = before {
lane_m.set_point(r.start - 1, v);
}
lane_m.set_point(r.start, base);
if r.end - 1 > r.start {
lane_m.set_point(r.end - 1, base);
}
lane_m.set_point(r.end, after);
}
}
Ok(json!({}))
}
@@ -611,8 +681,118 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
fn rms_db(before: &[f32], after: &[f32]) -> f32 {
let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::<f32>() / xs.len().max(1) as f32;
10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
}
fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
let mut s = Session::default();
let n = 8192;
let tone: Vec<f32> = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
s.track_mut(t).unwrap().mixer.volume_db = -3.0;
for &(at, value) in points {
s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
}
s.edit.selected_tracks = vec![t];
s.edit.selection = Range::new(2048, 6144);
(Engine::new(s), t)
}
fn rendered(s: &Session) -> Vec<f32> {
soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
}
fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
e.session_mut().edit.selection = Range::new(start, end);
e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
}
#[test]
fn partial_volume_to_clip_gain_keeps_each_region() {
let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
}
let tr = e.session().track(t).unwrap();
assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
let lane = tr.lane(&AutoParam::Volume).unwrap();
assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
let (mut e, t) = tone_session(&[]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-5, "no lane changed a sample by {diff}");
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 0, 8193);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-4, "full selection changed a sample by {diff}");
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
for points in [
[(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)],
[(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)],
] {
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
}
let delta = rms_db(&before[2048..6144], &after[2048..6144]);
assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
}
}
#[test]
fn lane_points_all_inside_the_selection_keep_the_outside_level() {
let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_points_inside_the_selection_are_kept() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn retargeting_a_send_keeps_its_settings() {
+98 -13
View File
@@ -3,7 +3,7 @@
use crate::{Engine, EngineError, Result};
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -11,6 +11,21 @@ use std::sync::Arc;
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// A relative `Audio Files/` path no other source in `s` already uses.
fn unique_audio_rel(s: &Session, stem: &str) -> String {
let stem = sanitize_name(stem);
let mut path = format!("Audio Files/{stem}.wav");
let mut n = 2u32;
while s.sources.iter().any(|src| src.path == path) {
path = format!("Audio Files/{stem}-{n}.wav");
n = n.saturating_add(1);
if n > 10_000 {
break;
}
}
path
}
/// Register decoded audio as a new source. Resamples to the session rate if needed.
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
let sr = s.sample_rate.hz();
@@ -20,10 +35,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
}
let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string();
let stored = match path {
Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem),
};
s.sources.push(Source {
id,
name: stem.clone(),
path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string),
path: stored,
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
frames: buf.frames() as u64,
sample_rate: sr,
@@ -56,7 +75,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
Some(t) => t,
None => {
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem))
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
}
};
let cid = s.new_clip_id();
@@ -180,8 +199,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
Ok(out)
}
/// Export all MIDI clips as a format-1 SMF.
pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks.
pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
let s = e.session();
let mut smf = soundcraft_midi::Smf {
format: 1,
@@ -191,16 +210,25 @@ pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(),
key_sigs: Vec::new(),
};
for t in s.tracks.iter().filter(|t| t.kind.is_midi()) {
let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect();
if only.is_some() && chosen.is_empty() {
return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into()));
}
for t in chosen {
let mut seq = soundcraft_midi::Sequence::default();
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
let mut n = *n;
n.start += base;
n.start = n.start.saturating_add(base);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
}
}
}
seq.sort();
@@ -217,11 +245,23 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool)
/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV
/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`.
pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
bounce_source(e, r, opts, normalize, fold_stereo, None)
}
/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named.
pub fn bounce_source(
e: &Engine,
r: Range,
opts: &EncodeOptions,
normalize: bool,
fold_stereo: bool,
bus: Option<BusId>,
) -> Result<(Vec<u8>, (f32, f32, f32))> {
let s = e.session();
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
}
let (mut ch, fmt) = render_main(s, r, fold_stereo);
let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus);
if normalize {
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
}
@@ -238,8 +278,19 @@ pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize:
/// The main mix over `r` and its format (folded to stereo on request).
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, ChannelFormat) {
let ch = soundcraft_mix::render_range(s, r, 1024);
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
render_bounce(s, r, fold_stereo, None)
}
/// The main mix, or one bus, over `r`.
fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option<BusId>) -> (Vec<Vec<f32>>, ChannelFormat) {
let (ch, fmt) = if let Some(id) = bus {
let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo);
(soundcraft_mix::render_bus(s, id, r, 1024), fmt)
} else {
let ch = soundcraft_mix::render_range(s, r, 1024);
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
(ch, fmt)
};
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
}
@@ -379,6 +430,9 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result<
cl.content = ClipContent::Audio { source: src, offset: 0 };
cl.gain_db = 0.0;
cl.gain_env.clear();
// The new source is the clip as it already sounds, stretch included.
// Keeping the old ratio would play that audio through the stretch again.
cl.stretch = 1.0;
}
Ok(true)
}
@@ -471,6 +525,12 @@ pub fn save_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, true).map(|(_, n, _)| n)
}
/// File › Save Session Copy In: a normal session folder that does not rename, retarget or
/// clean the session that stays open.
pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, false).map(|(_, n, _)| n)
}
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
/// files written and the final path.
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
@@ -480,10 +540,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
let audio_dir = dir.join("Audio Files");
let mut s = session.clone();
let mut written = 0;
for src in &mut s.sources {
let shared_paths: Vec<bool> =
s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect();
for (i, src) in s.sources.iter_mut().enumerate() {
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
if exists_rel && !src.unsaved {
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let Some(audio) = s.pool.get(src.id) else { continue };
@@ -499,7 +562,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
}
} else {
let mut k = 1;
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
while dir.join(&target).exists() && !(exists_rel && target == src.path && !shared) {
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
k += 1;
if k > 999 {
@@ -622,6 +685,28 @@ mod save_tests {
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_gives_sources_that_share_a_path_their_own_files() {
let mut s = Session::default();
let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] };
add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav);
add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav);
let mut e = Engine::new(s);
let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Shared.scraft");
save_session(&mut e, &path.to_string_lossy()).unwrap();
let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect();
assert_eq!(paths.len(), 2);
assert_ne!(paths[0], paths[1], "{paths:?}");
let a = std::fs::read(dir.join(&paths[0])).unwrap();
let b = std::fs::read(dir.join(&paths[1])).unwrap();
assert_ne!(a, b);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
+2 -4
View File
@@ -267,10 +267,8 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
match kind {
0x2F => break, // End of Track
0x03 => {
if name.is_none() {
name = Some(String::from_utf8_lossy(data).into_owned());
}
0x03 if name.is_none() => {
name = Some(String::from_utf8_lossy(data).into_owned());
}
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
0x51 => {
+18
View File
@@ -91,6 +91,24 @@ fn round_trip() {
assert_eq!(write_smf(&again).unwrap(), bytes);
}
#[test]
fn first_track_name_wins_and_ignored_meta_preserves_following_notes() {
let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat();
for first in [&b"First"[..], &b""[..], &[0xFF][..]] {
let body = [
meta(0x03, first),
meta(0x03, b"Second"),
meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored.
vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0],
]
.concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
assert_eq!(smf.tracks.len(), 1);
assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first));
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]);
}
}
#[test]
fn write_rejects_bad_values() {
let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() };
+205 -23
View File
@@ -293,6 +293,11 @@ pub struct MixEngine {
order: Vec<usize>,
/// Current total compensation delay (samples) of the mix.
pub latency: usize,
/// Delay already applied to audio written onto busses: the slowest track insert.
/// A bus render drops this so the bus lines up with the timeline. The extra delay
/// that lines direct outputs up with aux returns stays in `latency` and is removed
/// only from the main mix.
track_latency: usize,
/// Live input for the next block (planar), set by the audio host for input monitoring.
pub input: Vec<Vec<f32>>,
/// Transport stopped: only monitored inputs sound (no clips).
@@ -308,6 +313,8 @@ pub struct MixEngine {
adopt: HashMap<(TrackId, usize), PreparedInstance>,
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
retired: Vec<Box<dyn Plugin>>,
/// Ownership workspace reused for parallel strip processing.
work: Vec<(usize, Strip)>,
}
/// What `sync` needs to fill a slot.
@@ -363,6 +370,7 @@ impl MixEngine {
synced: (0, 0, 0),
order: Vec::new(),
latency: 0,
track_latency: 0,
input: Vec::new(),
monitor_only: false,
recording: false,
@@ -370,6 +378,7 @@ impl MixEngine {
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
work: Vec::new(),
}
}
@@ -568,16 +577,17 @@ impl MixEngine {
}
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
let order = std::mem::take(&mut self.order);
let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
let mut work: Vec<(&Track, Strip)> = order
.iter()
.filter_map(|&i| s.tracks.get(i))
.filter(live)
.filter(|t| !is_aux(t))
.filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st)))
.collect();
let mut work = std::mem::take(&mut self.work);
work.clear();
for &i in &order {
let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue };
if let Some(st) = self.strips.remove(&t.id) {
work.push((i, st));
}
}
for (_, st) in work.iter_mut() {
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -585,16 +595,30 @@ impl MixEngine {
}
let busses = std::mem::take(&mut self.busses);
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo));
run_strips(&mut work, |(i, st)| {
if let Some(t) = s.tracks.get(*i) {
process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
}
});
self.busses = busses;
// Delay compensation: align every strip to the slowest; direct-to-main outputs also wait
// for the slowest aux return (aux latencies are from the previous block; they are stable).
// Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
// for the slowest aux return. Aux latency has to be read before those outputs are routed,
// or the first block (and a one-block bounce probe) still sees zero.
let pdc = s.edit.delay_compensation;
if pdc {
for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) {
if let Some(st) = self.strips.get_mut(&t.id) {
note_insert_latency(t, st, pos);
}
}
}
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
let lmax_a =
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
self.track_latency = lmax_t;
self.latency = lmax_t + lmax_a;
for (t, st) in work.iter_mut() {
for (i, st) in work.iter_mut() {
let Some(t) = s.tracks.get(*i) else { continue };
let ch = st.buf.len();
st.align.set(lmax_t.saturating_sub(st.latency), ch);
st.align.process(&mut st.buf, frames);
@@ -604,15 +628,20 @@ impl MixEngine {
}
st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch);
}
for (t, st) in work.iter_mut() {
self.route_strip(t, st, pos, frames);
for (i, st) in work.iter_mut() {
if let Some(t) = s.tracks.get(*i) {
self.route_strip(t, st, pos, frames);
}
}
for (t, st) in work {
self.strips.insert(t.id, st);
for (i, st) in work.drain(..) {
if let Some(t) = s.tracks.get(i) {
self.strips.insert(t.id, st);
}
}
self.work = work;
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Phase 2: auxes in dependency order (they read busses).
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) {
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {
let Some(mut st) = self.strips.remove(&t.id) else { continue };
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -679,6 +708,24 @@ impl MixEngine {
self.main_meter.measure(&self.main, frames, 0.0);
}
/// Copy one rendered block of `bus` into `out`. A missing bus stays silent.
fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec<f32>]) {
for o in out.iter_mut() {
for x in o.iter_mut().take(frames) {
*x = 0.0;
}
}
let Some(src) = self.busses.get(&bus) else { return };
for (o, c) in out.iter_mut().zip(src.buf.iter()) {
for i in 0..frames {
let v = c.get(i).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(i) {
*d = if v.is_finite() { v } else { 0.0 };
}
}
}
}
/// Send, meter and pan a processed strip into the busses / main mix.
fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) {
self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr);
@@ -876,8 +923,11 @@ fn process_strip(
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
}
}
// Endpoints are the first and last sample of this block. The next block starts on its own
// first sample, so a breakpoint there does not ramp the block before it.
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let mut v0 = volume_db_at(t, pos);
let mut v1 = volume_db_at(t, pos + frames as i64);
let mut v1 = volume_db_at(t, last);
// Trim automation (an offset on top of the volume curve).
if let Some(l) = t
.automation
@@ -886,14 +936,14 @@ fn process_strip(
.filter(|l| !l.points.is_empty())
{
v0 += l.value_at(pos, 0.0);
v1 += l.value_at(pos + frames as i64, 0.0);
v1 += l.value_at(last, 0.0);
}
// VCA master: its fader offsets the member's, and its mute mutes the member.
if let Some(vid) = t.mixer.vca
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
{
v0 += volume_db_at(vca, pos);
v1 += volume_db_at(vca, pos + frames as i64);
v1 += volume_db_at(vca, last);
if vca.mixer.mute {
strip.muted = true;
}
@@ -902,7 +952,7 @@ fn process_strip(
for c in strip.buf.iter_mut() {
for i in 0..frames {
if let Some(x) = c.get_mut(i) {
*x *= lerp_gain(g0, g1, i, frames);
*x *= lerp_gain(g0, g1, i, frames.saturating_sub(1));
}
}
}
@@ -1202,10 +1252,12 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
while i < i1 {
let seg_end = (i + STEP).min(i1);
let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start);
let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start);
// The last sample of this step, not the first sample of the next one, so a breakpoint
// on the boundary does not ramp the audio before it.
let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start);
let g0 = clip.gain_at(rel0);
let g1 = clip.gain_at(rel1.min(clip.length));
let n = (seg_end - i) as f32;
let n = (seg_end - i).saturating_sub(1).max(1) as f32;
for k in i..seg_end {
let rel = pos.saturating_add(k as i64).saturating_sub(clip.start);
let v = if (stretch - 1.0).abs() < 1e-9 {
@@ -1278,6 +1330,20 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
}
/// 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;
for (i, ins) in t.mixer.inserts.iter().enumerate() {
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
if !ins.active || ins.bypass {
continue;
}
apply_params(slot, ins, t, i, pos);
latency = latency.saturating_add(slot.plugin.latency());
}
strip.latency = latency;
}
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);
let automated = t.mixer.automation_mode.reads()
@@ -1640,6 +1706,46 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
out
}
/// Offline render of one bus over `range` (planar, the bus format's channel count).
/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline.
/// An unknown bus is silent.
pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec<Vec<f32>> {
let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0);
if nch == 0 {
return Vec::new();
}
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch);
let latency = {
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
let b = probe.max_block();
probe.render(s, range.start, b.min(len.max(1)), &mut []);
probe.track_latency
};
let total = len.saturating_add(latency);
let mut out = vec![vec![0.0f32; total]; nch];
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
let b = eng.max_block();
let mut tmp = vec![vec![0.0f32; b]; nch];
let mut done = 0usize;
while done < total {
let n = (total - done).min(b);
eng.render(s, range.start + done as i64, n, &mut []);
eng.copy_bus(bus, n, &mut tmp);
for (o, t) in out.iter_mut().zip(tmp.iter()) {
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
d.copy_from_slice(sr);
}
}
done += n;
}
if latency > 0 {
for c in &mut out {
c.drain(..latency.min(c.len()));
}
}
out
}
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
@@ -1879,6 +1985,20 @@ mod tests {
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
}
#[test]
fn render_bus_is_the_bus_not_the_main_mix() {
let (mut s, t) = session_with_dc(0.5, 4800);
let stem = s.add_bus("Stem", ChannelFormat::Stereo);
let empty = s.add_bus("Empty", ChannelFormat::Stereo);
s.track_mut(t).unwrap().mixer.output = Route::Bus(stem);
let main = render_range(&s, Range::new(0, 4800), 512);
let bus = render_bus(&s, stem, Range::new(0, 4800), 512);
let silent = render_bus(&s, empty, Range::new(0, 4800), 512);
assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]);
assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]);
assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6)));
}
#[test]
fn volume_automation_ramps() {
let (mut s, t) = session_with_dc(0.5, 48_000);
@@ -2057,6 +2177,53 @@ mod tests {
assert_ne!(argmax(&out[0]), argmax(&out[1]));
}
fn impulse_at(ch: &[f32]) -> Vec<usize> {
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
}
/// An aux insert's lookahead is part of the mix latency on the first block, not the block after.
#[test]
fn delay_compensation_aligns_an_aux_insert() {
let mut s = Session::default();
let n = 8192;
let mut left = vec![0.0f32; n];
for at in [0usize, 2048, 8000] {
left[at] = 0.25;
}
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] })));
let bus = s.add_bus("Test Bus", ChannelFormat::Stereo);
let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
let cid = s.new_clip_id();
s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64));
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024);
let control = render(&s, 0, n as i64);
assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]);
assert_eq!(impulse_at(&control[1]), impulse_at(&control[0]));
let mut comp = Insert::new("compressor");
comp.params.insert("lookahead".into(), 5.0);
comp.params.insert("ratio".into(), 1.0);
s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone());
let on_source = render(&s, 0, n as i64);
assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0]));
s.track_mut(source).unwrap().mixer.inserts[0] = None;
s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp);
let on_aux = render(&s, 0, n as i64);
assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce");
assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0]));
assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]);
let partial = render(&s, 2048, 3072);
assert_eq!(impulse_at(&partial[0]), vec![0]);
s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0);
let zero = render(&s, 0, n as i64);
assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
}
#[test]
fn input_monitoring_replaces_clips() {
let (mut s, t) = session_with_dc(0.5, 4800);
@@ -2085,6 +2252,21 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
#[test]
fn strip_workspace_keeps_its_allocation_between_blocks() {
let mut s = Session::default();
for _ in 0..6 {
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
}
let mut eng = MixEngine::new(48_000.0, 512);
let mut out = vec![vec![0.0; 512]; 2];
eng.render(&s, 0, 512, &mut out);
let capacity = eng.work.capacity();
assert!(capacity >= 6);
eng.render(&s, 512, 512, &mut out);
assert_eq!(eng.work.capacity(), capacity);
}
#[test]
fn stopped_monitoring_does_not_click() {
let mut s = Session::default();
+92 -18
View File
@@ -86,7 +86,6 @@ struct AudioState {
playing: bool,
end: Option<Samples>,
looped: Option<Range>,
block: Vec<Vec<f32>>,
/// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index.
pending: Vec<Vec<f32>>,
/// Stereo fold-down gains per main channel (used when the device has fewer channels).
@@ -122,7 +121,6 @@ impl AudioState {
playing: false,
end: None,
looped: None,
block: vec![vec![0.0; BLOCK]; nch],
pending: vec![vec![0.0; BLOCK]; nch],
pending_len: 0,
read: 0.0,
@@ -151,8 +149,7 @@ impl AudioState {
self.mix.adopt(instances);
// The main format changed (a document edit, not steady state): resize.
let nch = main_channels(&s);
if self.block.len() != nch {
self.block = vec![vec![0.0; BLOCK]; nch];
if self.pending.len() != nch {
self.pending = vec![vec![0.0; BLOCK]; nch];
self.pending_len = 0;
self.read = 0.0;
@@ -171,6 +168,8 @@ impl AudioState {
}
Cmd::Stop => {
self.playing = false;
self.end = None;
self.looped = None;
self.mix.reset();
}
Cmd::Locate(at) => {
@@ -197,8 +196,8 @@ impl AudioState {
}
/// Render the next block at the session rate into `pending`.
fn render_next(&mut self) {
let mut n = BLOCK;
fn render_next(&mut self, quantum: usize) {
let mut n = quantum.clamp(1, BLOCK);
if let Some(l) = self.looped
&& self.pos >= l.end
{
@@ -209,6 +208,7 @@ impl AudioState {
} else if let Some(e) = self.end {
if self.pos >= e {
self.playing = false;
self.end = None;
self.shared.playing.store(false, Ordering::Relaxed);
n = 0;
} else {
@@ -231,19 +231,9 @@ impl AudioState {
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
self.mix.render(&self.session, self.pos, n, &mut self.block);
self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.send_garbage();
for (d, s) in self.pending.iter_mut().zip(self.block.iter()) {
if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) {
dd.copy_from_slice(ss);
}
}
self.pending_len = n;
if !self.playing {
return;
}
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main.copy_from(&self.mix.main_meter);
for (k, v) in &self.mix.meters {
@@ -255,6 +245,10 @@ impl AudioState {
}
}
}
if self.playing {
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
}
}
/// Fill an interleaved device buffer.
@@ -272,13 +266,15 @@ impl AudioState {
}
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
let callback_frames = out.len() / channels;
let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
for frame in out.chunks_mut(channels) {
while self.read >= self.pending_len as f64 {
self.read -= self.pending_len as f64;
if !self.playing && !monitoring {
break;
}
self.render_next();
self.render_next(quantum);
if self.pending_len == 0 {
break;
}
@@ -622,6 +618,11 @@ impl Player {
self.shared.speed.store(v, Ordering::Relaxed);
}
/// Playback rate last requested, 1.0 at full speed.
pub fn speed(&self) -> f32 {
self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0
}
pub fn position(&self) -> Samples {
self.shared.position.load(Ordering::Relaxed)
}
@@ -633,6 +634,21 @@ impl Player {
pub fn meters(&self) -> MeterSnapshot {
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
/// no allocation, unlike cloning a new snapshot every video frame.
pub fn copy_meters(&self, out: &mut MeterSnapshot) {
let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
copy_meter_snapshot(&source, out);
}
}
fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
out.tracks.retain(|id, _| source.tracks.contains_key(id));
for (id, meter) in &source.tracks {
out.tracks.entry(*id).or_default().copy_from(meter);
}
out.main.copy_from(&source.main);
}
impl Side {
@@ -730,6 +746,21 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre
mod tests {
use super::*;
#[test]
fn reusable_meter_snapshot_updates_and_drops_stale_tracks() {
let keep = TrackId(1);
let stale = TrackId(2);
let mut source = MeterSnapshot::default();
source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 });
source.main.peaks = vec![0.75, 0.5];
let mut out = MeterSnapshot::default();
out.tracks.insert(stale, StripMeter::default());
copy_meter_snapshot(&source, &mut out);
assert_eq!(out.tracks.len(), 1);
assert_eq!(out.tracks[&keep], source.tracks[&keep]);
assert_eq!(out.main, source.main);
}
#[test]
fn audio_state_plays_and_stops_at_end() {
let s = Arc::new(soundcraft_model::Session::default());
@@ -748,6 +779,23 @@ mod tests {
assert!(!shared.playing.load(Ordering::Relaxed));
}
#[test]
fn short_device_callbacks_render_short_mix_blocks() {
let s = Arc::new(soundcraft_model::Session::default());
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), s, 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
let mut out = vec![0.0f32; 2 * 64];
st.fill(&mut out, 2);
assert_eq!(shared.position.load(Ordering::Relaxed), 64);
}
#[test]
fn surround_main_maps_to_device_channels_or_folds_down() {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
@@ -896,4 +944,30 @@ mod tests {
assert!(p <= 1000, "position {p} should stay inside the loop");
assert!(shared.playing.load(Ordering::Relaxed));
}
#[test]
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
use soundcraft_model::{ChannelFormat, TrackKind};
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.track_mut(t).unwrap().mixer.record_arm = true;
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);
let ring = record::InputRing::new(1, 48_000);
tx.send(Cmd::Input(Arc::clone(&ring))).unwrap();
tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
let mut out = vec![0.0f32; 4096];
st.fill(&mut out, 2);
assert!(!shared.playing.load(Ordering::Relaxed));
ring.push(&[0.5; 4096]);
st.fill(&mut out, 2);
assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard");
assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered");
}
}
+3 -2
View File
@@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option<Value> {
}
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
apply_customization(app, ctx);
auto_update(app, ctx);
renderer(app, ctx);
ui_customization(app, ctx);
apply_customization(app, ctx);
}
fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {
let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);
if app.extra.applied == Some(want) {
// Rescaling mid-drag would move the Interface scale slider out from under the pointer.
if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) {
return;
}
// Only touch the zoom once the user has changed it, so platform scaling stays untouched.
+79 -3
View File
@@ -220,6 +220,8 @@ pub struct SoundApp {
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
/// Reused multichannel snapshot; populated at an adaptive rate by `logic`.
meter_snapshot: soundcraft_playback::MeterSnapshot,
pub gesture: Option<Gesture>,
pub edit_layout: edit_window::EditLayout,
pub midi: midi_editor::MidiEditorState,
@@ -247,6 +249,7 @@ pub struct SoundApp {
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,
}
@@ -280,6 +283,7 @@ impl SoundApp {
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
meter_snapshot: soundcraft_playback::MeterSnapshot::default(),
gesture: None,
edit_layout: edit_window::EditLayout::default(),
midi: midi_editor::MidiEditorState::default(),
@@ -302,6 +306,7 @@ impl SoundApp {
last_write_at: i64::MIN,
frame_ms: 0.0,
last_frame: None,
last_meter_poll: f64::NEG_INFINITY,
video: video_track::VideoPool::default(),
}
}
@@ -329,6 +334,9 @@ impl SoundApp {
// Re-selecting a scrolling mode resumes the playhead follow.
self.edit_layout.follow_hold = false;
}
if id == "track.clear_clip_indicators" {
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
}
Ok(v)
}
Err(e) => {
@@ -367,6 +375,8 @@ impl SoundApp {
fn stop_play(&mut self) {
if let Some(p) = &self.player {
// Half-speed is a separate command. Stop must not leave that rate for the next Play.
p.set_speed(1.0);
p.stop();
}
if self.engine.transport.recording {
@@ -512,6 +522,12 @@ impl SoundApp {
best.map(|(_, p)| p)
}
/// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard.
fn meters_live(&self) -> bool {
self.engine.transport.playing
|| (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm))
}
/// Open the input device once (for recording and input monitoring) and feed the player.
fn ensure_input(&mut self) {
if self.recorder.is_some() || self.recorder_failed || self.player.is_none() {
@@ -725,7 +741,13 @@ impl SoundApp {
if self.engine.transport.playing {
self.engine.transport.position = p.position();
}
let snap = p.meters();
let now = self.last_frame.unwrap_or_default();
let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 };
if now - self.last_meter_poll >= meter_interval {
p.copy_meters(&mut self.meter_snapshot);
self.last_meter_poll = now;
}
let snap = &self.meter_snapshot;
for (id, m) in &snap.tracks {
let d = self.meters.entry(*id).or_default();
feed_meter(d, m.peak, m.gain_reduction_db, dt);
@@ -749,7 +771,7 @@ impl SoundApp {
self.stop_play();
}
}
if !self.engine.transport.playing {
if !self.meters_live() {
for d in self.meters.values_mut() {
feed_meter(d, [0.0, 0.0], 0.0, dt);
}
@@ -805,7 +827,12 @@ impl SoundApp {
{
self.ensure_input();
}
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
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();
}
control::collect_screenshots(self, ctx);
@@ -979,6 +1006,16 @@ mod tests {
assert!(!app.engine.is_dirty());
}
#[test]
fn clear_all_clip_indicators_clears_every_meter() {
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.meters.entry(kick).or_default().clip = true;
app.main_meter.clip = true;
app.run("track.clear_clip_indicators", json!({})).unwrap();
assert!(!app.meters[&kick].clip && !app.main_meter.clip);
}
#[test]
fn automation_first_pass_after_start_is_due_without_overflow() {
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
@@ -997,4 +1034,43 @@ mod tests {
fn automation_write_fires_after_loop_wrap() {
assert!(automation_step_due(0, 1_000_000, 960));
}
#[test]
fn ordinary_play_after_stop_is_full_speed() {
use serde_json::json;
let engine = soundcraft_engine::Engine::default();
let player = soundcraft_playback::Player::new(engine.session_arc());
let mut app = super::SoundApp::new(engine, Some(player), super::Services::default());
app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap();
app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap();
app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap();
let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0);
assert!((speed(&app) - 1.0).abs() < 1e-3);
app.engine.execute("transport.half_speed", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!(app.is_playing());
assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback");
app.engine.execute("transport.stop", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!(!app.is_playing());
assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed");
app.engine.execute("transport.play", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!(app.is_playing());
assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed");
app.engine.execute("transport.stop", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap();
app.engine.execute("transport.half_speed", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!((speed(&app) - 0.5).abs() < 1e-3);
app.player.as_ref().unwrap().stop();
app.handle_transport(1.0 / 60.0);
assert!(!app.is_playing());
assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed");
}
}
+3 -7
View File
@@ -22,11 +22,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
// Per-channel peaks for multichannel meters (read once per frame).
let snap = app
.player
.as_ref()
.filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor))
.map(|p| p.meters());
let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
@@ -427,12 +423,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
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() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg };
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, Color32::from_rgb(16, 16, 16)),
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);
+2
View File
@@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
return;
}
+47
View File
@@ -0,0 +1,47 @@
use egui::accesskit::Role;
use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use soundcraft_ui_egui::{Services, SoundApp};
const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0);
fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option<bool>) {
let zoom = h.ctx.zoom_factor();
h.set_size(WINDOW_PX / zoom);
let pos = (px.to_vec2() / zoom).to_pos2();
h.input_mut().events.push(Event::PointerMoved(pos));
if let Some(pressed) = pressed {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
h.step();
}
#[test]
fn interface_scale_slider_follows_the_pointer() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.extra.show_ui_customization = true;
let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(6);
let rail = h.get_by_role(Role::Slider).rect();
let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y);
let end = pos2(rail.right() + 40.0, rail.center().y);
frame(&mut h, start, Some(true));
let mut scales = vec![];
for i in 1..=20 {
frame(&mut h, start.lerp(end, i as f32 / 20.0), None);
scales.push(h.state().extra.ui_scale);
}
assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}");
frame(&mut h, end, Some(false));
frame(&mut h, end, None);
assert_eq!(h.state().extra.ui_scale, 1.5);
assert_eq!(h.ctx.zoom_factor(), 1.5);
}
@@ -0,0 +1,172 @@
use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2};
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode};
#[derive(Debug)]
struct Slot {
rect: Rect,
fill: Color32,
text: Color32,
border: Color32,
}
// Read the rectangle and foreground emitted by the real Mix drawing path, rather than
// testing a palette token that a custom-painted control might never use.
fn slot(output: &FullOutput, label: &str) -> Slot {
let text = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.job.text == label => Some(text),
_ => None,
})
.unwrap_or_else(|| panic!("missing painted label {label}"));
let center = text.visual_bounding_rect().center();
let rect = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect),
_ => None,
})
.unwrap_or_else(|| panic!("missing painted slot for {label}"));
let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color);
assert_eq!(text.opacity_factor, 1.0);
assert_eq!(foreground.a(), 255);
assert_eq!(rect.fill.a(), 255);
Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color }
}
fn contrast(a: Color32, b: Color32) -> f64 {
let luminance = |color: Color32| {
let linear = |channel: u8| {
let s = f64::from(channel) / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
};
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
#[test]
fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
assert_eq!(app.ui.theme, ThemeMode::Dark);
// Keep demo audio loaded for the preservation check while isolating the new strips.
for track in &mut app.engine.session_mut().tracks {
track.hidden = true;
}
app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap();
let instrument = app.engine.session().edit.selected_tracks[0];
assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth");
app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap();
// An unassigned instrument is a supported model state; track.new normally supplies a synth.
let empty = app.engine.session().edit.selected_tracks[0];
app.engine.session_mut().track_mut(empty).unwrap().instrument = None;
app.run("window.mix", json!({})).unwrap();
app.run("view.mix_section", json!({"section": "minimal"})).unwrap();
for section in ["instruments", "inserts_ae"] {
app.run("view.mix_section", json!({"section": section})).unwrap();
}
let session = app.engine.session().clone();
assert!(!session.pool.is_empty(), "the fixture must include decoded audio");
let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from);
let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0);
if proof_dir.is_some() {
builder = builder.wgpu();
}
let mut harness = builder.build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
let mut failures = Vec::new();
for (name, mode, system, light) in [
("light", ThemeMode::Light, Some(egui::Theme::Dark), true),
("dark", ThemeMode::Dark, Some(egui::Theme::Light), false),
("system-light", ThemeMode::System, Some(egui::Theme::Light), true),
("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false),
] {
harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap();
harness.input_mut().system_theme = system;
harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0)));
for _ in 0..4 {
harness.step();
}
let ordinary = slot(harness.output(), "SubSynth");
let insert = slot(harness.output(), "EQ7");
let empty = slot(harness.output(), "no instrument");
let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK };
assert_eq!(insert.fill, palette.insert_on);
assert_eq!(empty.fill, palette.slot_bg);
assert_eq!(ordinary.text, palette.text);
if ordinary.fill != insert.fill || ordinary.border != insert.border {
failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}"));
}
for hovered in [false, true] {
if hovered {
harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center()));
harness.step();
harness.step();
}
let painted = slot(harness.output(), "SubSynth");
let ratio = contrast(painted.text, painted.fill);
println!(
"{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}",
painted.text.to_array(),
painted.fill.to_array(),
painted.rect
);
if ratio < 4.5 {
failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1"));
}
if !light {
let original = Color32::from_rgb(52, 62, 80);
assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original });
assert_eq!(painted.border, Color32::from_rgb(16, 16, 16));
}
if let Some(dir) = &proof_dir {
harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap();
}
}
assert_eq!(harness.state().ui.theme, mode);
assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
// Exercise the same painted control's ordinary click/picker path after palette changes.
let at = slot(harness.output(), "SubSynth").rect.center();
for pressed in [true, false] {
harness.input_mut().events.push(Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
});
harness.step();
}
harness.step(); // Let the popup finish its initial sizing pass.
harness.get_by_label_contains("Built-in").hover();
harness.step();
harness.step();
harness.get_by_label("Drum Synth").click();
harness.step();
harness.step();
let app = harness.state();
let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap();
assert_eq!(replacement.plugin, "drum_synth");
for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params {
assert_eq!(replacement.params.get(param.id), Some(&param.default));
}
assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks);
assert_eq!(app.engine.session().pool, session.pool);
assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker");
harness.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(harness.state().engine.session(), &session);
}
+21 -1
View File
@@ -1,7 +1,7 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
@@ -24,3 +24,23 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}");
}
}
#[test]
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(os);
ctx.set_zoom_factor(1.25);
press(&ctx, &mut app, Key::Equals, cmd);
assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}");
press(&ctx, &mut app, Key::Equals, cmd);
press(&ctx, &mut app, Key::Num0, cmd);
press(&ctx, &mut app, Key::Minus, cmd);
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}");
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|---|---|
| `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). |
| `execute` | Run one command with JSON params. |
| `batch` | Run several commands in order (`keep_going` optional). |
| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). |
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). |
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
+2 -2
View File
@@ -2,8 +2,8 @@
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder
scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
+48 -7
View File
@@ -109,6 +109,10 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing:
# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle.
xattr -cr "$APP"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft"
@@ -133,15 +137,52 @@ ln -s /Applications "$STAGE/Applications"
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
rm -f "$DMG"
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
# similar detritus" on the app in the image and on a copy installed from it (#176). `create`
# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so
# it gets a few tries. The volume name has no version: .DS_Store finds the background through an
# alias that includes it.
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
hdiutil_retry() {
local what="$1" attempt
shift
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
}
hdiutil_retry create create -srcfolder "$STAGE" -volname "SoundCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
# The app users get must still pass --strict: inside the finished image and after a ditto copy out
# of it, so a valid container signature can't hide a damaged app (or a makehybrid-style regression).
MOUNT="$WORK/dmg-check"
COPY="$WORK/dmg-copy"
rm -rf "$MOUNT" "$COPY"
mkdir -p "$MOUNT" "$COPY"
hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null
dmg_ok=1
codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0
if [ "$dmg_ok" = 1 ]; then
{ ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" &&
codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0
fi
if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then
spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0
fi
[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true
hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0
rm -rf "$COPY"
if [ "$dmg_ok" != 1 ]; then
echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2
exit 1
fi
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"