mirror of
https://github.com/aphrody-labs/soundcraft.git
synced 2026-10-13 19:26:53 +00:00
Merge origin/main into fix/mixer-routing-qol
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
b25a49ee5a
33 files changed
+1618
-136
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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 }}
|
||||
@@ -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
@@ -3418,6 +3418,8 @@ dependencies = [
|
||||
"egui",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"rfd",
|
||||
"serde_json",
|
||||
"soundcraft-engine",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -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()] {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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
|
||||
@@ -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()),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms).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(©_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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(¶m.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);
|
||||
}
|
||||
@@ -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
@@ -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,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 |
|
||||
|---|---|
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user