3 Commits

Author SHA1 Message Date
Marcos Paulo 09172264e9 feat(ui): Catppuccin themes
Adds Latte, Frappé, Macchiato and Mocha to the Appearance setting (ui.theme
ids catppuccinLatte|catppuccinFrappe|catppuccinMacchiato|catppuccinMocha),
listed in Setup > Appearance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 03:13:29 -03:00
Marcos Paulo 097fbd0c8a build: use the masi/craft-libs fork
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 02:57:33 -03:00
Marcos Paulo 30944cd0ab chore: debrand ArtCraft and Discord references
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 02:57:21 -03:00
80 changed files with 888 additions and 6803 deletions
+4 -8
View File
@@ -3,20 +3,16 @@
# 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: [release]
branches: [main]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/playback/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
-6
View File
@@ -34,9 +34,3 @@ 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 }}
+7 -5
View File
@@ -4,17 +4,19 @@
# 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: [release]
branches: [main]
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
+18 -56
View File
@@ -2142,18 +2142,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3424,14 +3412,12 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"soundcraft-engine",
@@ -3442,7 +3428,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"log",
"serde",
@@ -3452,7 +3438,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3464,7 +3450,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"log",
"serde_json",
@@ -3474,7 +3460,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3486,7 +3472,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3497,7 +3483,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3508,7 +3494,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"log",
"proptest",
@@ -3525,12 +3511,11 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3540,7 +3525,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"log",
"rayon",
@@ -3556,7 +3541,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3569,7 +3554,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"cpal",
"log",
@@ -3582,7 +3567,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"proptest",
"serde",
@@ -3592,7 +3577,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"craft-ui",
"egui",
@@ -3618,7 +3603,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"image",
"proptest",
@@ -3629,7 +3614,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"libloading",
"log",
@@ -3644,16 +3629,12 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"soundcraft-engine",
"soundcraft-model",
"soundcraft-playback",
"soundcraft-ui-egui",
"wasm-bindgen",
@@ -4113,12 +4094,6 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5201,7 +5176,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.6.2"
version = "0.5.0"
dependencies = [
"serde_json",
]
@@ -5409,19 +5384,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [
"crc32fast",
"indexmap",
"memchr",
"nix",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
+1 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.6.2"
version = "0.5.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -38,8 +38,6 @@ proptest = "1"
# audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
cpal = "0.16"
# ui
+2 -8
View File
@@ -83,8 +83,7 @@ where things are.
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
effects, and automatic plugin delay compensation.
@@ -93,8 +92,7 @@ where things are.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
@@ -255,10 +253,6 @@ 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 |
+14 -56
View File
@@ -20,12 +20,11 @@ use soundcraft_engine::Engine;
use std::io::Write;
use std::process::ExitCode;
/// 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.
/// Print without panicking on a closed pipe.
macro_rules! outln {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
let _ = writeln!(o, $($t)*);
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
}};
}
@@ -188,7 +187,7 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
}
}
if let Some(path) = arg_value(args, "--save") {
match e.execute("session.save", &json!({"path": path, "replace": true})) {
match e.execute("session.save", &json!({"path": path})) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
@@ -295,39 +294,6 @@ 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
@@ -345,8 +311,17 @@ fn app(args: &[String]) -> ExitCode {
None => json!({}),
};
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
let (m, p) = app_request(method, params);
// 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)
};
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -405,23 +380,6 @@ fn parity(args: &[String]) -> ExitCode {
mod tests {
use super::*;
#[test]
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
let (m, p) = app_request("engine.history", json!({}));
assert_eq!(m, "engine.execute");
assert_eq!(p["command"], "engine.history");
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
assert_eq!(m, "engine.execute");
assert_eq!(p["params"]["mode"], "light");
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
assert_eq!(m, "engine.execute");
let (m, _) = app_request("track.new", json!({}));
assert_eq!(m, "engine.execute");
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
assert_eq!(app_request(method, json!({})).0, method);
}
}
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
-124
View File
@@ -1,124 +0,0 @@
//! 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);
}
+1 -5
View File
@@ -17,14 +17,10 @@ path = "src/main.rs"
soundcraft-engine = { workspace = true }
soundcraft-ui-egui = { workspace = true }
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
js-sys = "0.3"
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
+1 -4
View File
@@ -7,10 +7,7 @@
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
//! download (the demo session is still loaded on start);
//! - no native file dialogs: the demo session is loaded on start;
//! - browsers only start audio after a user gesture, so the first click may be needed before
//! playback is heard.
//!
+6 -175
View File
@@ -1,11 +1,7 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use serde_json::json;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::JsCast as _;
@@ -14,7 +10,6 @@ const LOADING_ID: &str = "soundcraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
io::set_deliver(download);
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@@ -41,7 +36,7 @@ pub fn start() {
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
let app = SoundApp::new(engine, Some(player), Services::default());
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
Ok(Box::new(WebShell(app)))
}),
)
.await;
@@ -58,182 +53,18 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// What a file read from the browser is for.
#[derive(Debug, Clone, Copy)]
enum Purpose {
Session,
Audio,
Midi,
}
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
impl Purpose {
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
fn of(name: &str) -> Self {
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
if SESSION_EXTS.contains(&ext.as_str()) {
Self::Session
} else if MIDI_EXTS.contains(&ext.as_str()) {
Self::Midi
} else {
Self::Audio
}
}
/// The command whose path prompt this replaces in the browser.
fn for_command(cmd: &str) -> Option<Self> {
match cmd {
"session.open" => Some(Self::Session),
"file.import_audio" => Some(Self::Audio),
"file.import_midi" => Some(Self::Midi),
_ => None,
}
}
fn filter(self) -> (&'static str, &'static [&'static str]) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS),
Self::Audio => ("Audio", AUDIO_EXTS),
Self::Midi => ("MIDI", MIDI_EXTS),
}
}
}
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
/// handled on the next frame. The browser has no paths: files travel as bytes.
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
struct WebShell {
app: SoundApp,
inbox: Inbox,
}
impl WebShell {
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
/// browser can't read (#63, #64). Show the browser's file picker instead.
fn take_path_prompt(&mut self, ctx: &egui::Context) {
let purpose = match &self.app.dialogs.open {
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
_ => None,
};
let Some(purpose) = purpose else { return };
self.app.dialogs.open = None;
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let (label, exts) = purpose.filter();
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
let bytes = file.read().await;
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
ctx.request_repaint();
});
}
fn take_drops(&mut self, ctx: &egui::Context) {
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {
Purpose::Session => {
let _ = self.app.run("transport.stop", json!({}));
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
})
}
// Imports are edits: one undo step each, like the import commands.
Purpose::Audio => {
let before = e.session_arc();
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
e.push_undo("Import Audio", before);
format!("Imported {name}")
})
}
Purpose::Midi => {
let before = e.session_arc();
io::import_midi_bytes(e, bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
}
};
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
}
}
struct WebShell(SoundApp);
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.take_drops(ctx);
self.take_path_prompt(ctx);
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
for (purpose, name, bytes) in pending {
self.handle(purpose, &name, &bytes);
}
self.app.logic(ctx);
// A prompt opened during this frame becomes a picker on the next one.
self.take_path_prompt(ctx);
self.0.logic(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.app.raw_input_hook(raw);
self.0.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.app.ui(ui);
self.0.ui(ui);
}
}
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
/// hook on the web).
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
Some("scraftz") => "application/zip",
Some("wav") => "audio/wav",
Some("aif" | "aiff") => "audio/aiff",
Some("flac") => "audio/flac",
Some("mid" | "midi") => "audio/midi",
Some("svg") => "image/svg+xml",
Some("txt") => "text/plain",
Some("json" | "scraft") => "application/json",
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
_ => "application/octet-stream",
});
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click was dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
-2
View File
@@ -28,5 +28,3 @@ 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"] }
+5 -51
View File
@@ -1,14 +1,10 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, Dx12Compiler},
};
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::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>) {
/// 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>) {
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
@@ -16,16 +12,6 @@ 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
);
}
}
@@ -35,7 +21,7 @@ mod tests {
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override, None);
configure(&mut options, backend_override);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -52,38 +38,6 @@ 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()] {
+9 -54
View File
@@ -20,20 +20,11 @@ mod logging;
mod native_menu;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use std::sync::Arc;
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
fn set_title(ctx: &egui::Context, title: String) {
let id = egui::Id::new("window_title");
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
@@ -57,16 +48,7 @@ impl eframe::App for App {
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
soundcraft_ui_egui::fit_window_to_screen(ctx);
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
self.0.ui.window_maximized = maximized;
if let Some(r) = inner.filter(|_| !maximized) {
self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into();
}
self.0.ui.ui_scale = self.0.extra.ui_scale;
// With the system menu bar there is no egui bar showing the window and dirty state, so the
// title carries them; elsewhere the egui menu bar already does.
let title = if self.0.native_menu_bar {
@@ -79,7 +61,7 @@ impl eframe::App for App {
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
set_title(ctx, title);
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@@ -102,7 +84,7 @@ impl eframe::App for App {
fn open_path(app: &mut SoundApp, p: &str) {
let lower = p.to_ascii_lowercase();
let r = if lower.ends_with(".scraft") {
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
app.run("session.open", serde_json::json!({"path": p}))
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
@@ -175,9 +157,6 @@ fn services() -> Services {
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
})),
pick_folder: Some(Box::new(|start: &str| {
rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned())
})),
}
}
@@ -234,12 +213,10 @@ fn main() -> eframe::Result {
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let prefs = load_prefs().unwrap_or_default();
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size(sane_window_size(prefs.window_size))
.with_maximized(prefs.window_maximized)
.with_inner_size([1600.0, 1000.0])
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_app_id("ai.storyteller.soundcraft"),
@@ -248,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 with FXC (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
eframe::run_native(
"SoundCraft",
options,
@@ -259,8 +236,9 @@ fn main() -> eframe::Result {
let mut app = SoundApp::new(engine, player, services());
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;
if let Some(ui) = load_prefs() {
app.ui = ui;
}
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
@@ -289,26 +267,3 @@ fn main() -> eframe::Result {
}),
)
}
/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny).
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
}
#[cfg(test)]
mod tests {
use super::set_title;
#[test]
fn the_title_is_sent_only_when_it_changes() {
let ctx = egui::Context::default();
let titles = |title: &str| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
out.textures_delta.clear();
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
};
assert_eq!(titles("Song — SoundCraft"), 1);
assert_eq!(titles("Song — SoundCraft"), 0);
assert_eq!(titles("Song * — SoundCraft"), 1);
}
}
+4 -17
View File
@@ -1,7 +1,5 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
@@ -38,9 +36,8 @@ impl NativeMenu {
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::services(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
// 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::hide(Some("Hide SoundCraft")))?;
app_menu.append(&PredefinedMenuItem::hide_others(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()))?;
@@ -85,12 +82,7 @@ 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 (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)
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
{
@@ -113,11 +105,6 @@ impl NativeMenu {
self.last_sync = Some(now);
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if let Some(label) = menus::live_label(app, entry.command.as_deref())
&& entry.item.text() != label
{
entry.item.set_text(label);
}
if enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;
-5
View File
@@ -35,8 +35,3 @@ 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
-11
View File
@@ -8,7 +8,6 @@
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
//! encoder), with optional TPDF dither.
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -290,13 +289,3 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
wav::read_channel_mask(bytes)
}
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
/// switches to RF64 above 4 GiB.
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
if channels == 0 || sample_rate == 0 {
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
}
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
}
+54 -69
View File
@@ -276,8 +276,60 @@ pub(crate) fn encode(
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&(total as u64).to_le_bytes());
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
@@ -300,70 +352,3 @@ pub(crate) fn encode(
}
Ok(out)
}
/// Everything before the sample data of a WAV holding `frames` frames.
pub(crate) fn header(
channels: usize,
sample_rate: u32,
(bits, is_float): (u16, bool),
bwf: Option<&BwfInfo>,
mask: Option<u32>,
frames: u64,
force_rf64: bool,
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&total.to_le_bytes());
ds64.extend_from_slice(&data_len.to_le_bytes());
ds64.extend_from_slice(&frames.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
Ok(head)
}
+1 -17
View File
@@ -1,5 +1,5 @@
use soundcraft_audio_io::{
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
};
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
@@ -268,19 +268,3 @@ fn multichannel_wav_speaker_masks() {
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
}
#[test]
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
let take = sine(48_000, 2, 1_000);
let mut file = wav_float_header(2, 48_000, 0).unwrap();
let len = file.len();
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}
+4 -20
View File
@@ -54,12 +54,7 @@ pub fn tool_definitions() -> Vec<Value> {
&[],
),
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
tool(
"save_session",
"Save the session (path required the first time; replace: true to save over another existing file).",
json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}),
&[],
),
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
tool(
"import_audio",
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",
@@ -132,20 +127,11 @@ impl<B: Backend> Server<B> {
}
"batch" => {
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
return error_result("`calls` must be an array");
};
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
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 cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
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})),
@@ -158,9 +144,7 @@ impl<B: Backend> Server<B> {
}
}
}
// 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)) }
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
}
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
"new_session" => self.exec("session.new", args.clone()),
-36
View File
@@ -135,39 +135,3 @@ 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);
}
+1 -61
View File
@@ -1,7 +1,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! SoundCraft DSP.
//!
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
//! AudioSuite-style whole-buffer processing.
//!
@@ -28,8 +28,6 @@ pub use offline::FadeShape;
pub use osc::Waveform;
pub use plugins::eq::{eq1_response, eq7_response};
use std::sync::Arc;
/// Plugin category, used to group the insert menu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Category {
@@ -199,13 +197,6 @@ pub trait Plugin: Send {
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
fn note_off(&mut self, _offset: usize, _note: u8) {}
fn all_notes_off(&mut self) {}
/// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it.
/// `false` when the plugin does not play samples. Does not allocate; sounding voices stop.
/// The previous sample is dropped here, so hosts keep their own reference to it (the
/// session's source pool does) to keep deallocation off the audio thread.
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
false
}
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
/// the plugin refused. May allocate: never call it in steady-state audio processing.
@@ -249,40 +240,6 @@ pub trait PluginEditor: Send {
fn set_param(&mut self, _id: &str, _value: f32) {}
}
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
/// thread: no locks, no I/O.
pub trait SampleSource: Send + Sync {
/// Sample rate of the audio in Hz.
fn sample_rate(&self) -> f32;
fn num_channels(&self) -> usize;
/// One channel's frames (`None` past the last channel).
fn channel(&self, ch: usize) -> Option<&[f32]>;
/// Frames per channel (the shortest channel, so every index below it is readable).
fn frames(&self) -> usize {
(0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0)
}
}
/// A sample held in memory (tests, generated audio).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SampleBuffer {
pub sample_rate: f32,
pub channels: Vec<Vec<f32>>,
}
impl SampleSource for SampleBuffer {
fn sample_rate(&self) -> f32 {
self.sample_rate
}
fn num_channels(&self) -> usize {
self.channels.len()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.channels.get(ch).map(Vec::as_slice)
}
}
/// The plugin registry.
pub fn plugins() -> &'static [&'static PluginInfo] {
plugins::REGISTRY
@@ -341,21 +298,4 @@ mod trait_default_tests {
assert!(p.editor().is_none(), "{}", info.id);
}
}
#[test]
fn only_the_sampler_takes_samples() {
for info in plugins() {
let mut p = create(info.id).unwrap();
let s: Arc<dyn SampleSource> = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] });
assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id);
assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id);
}
}
#[test]
fn sample_buffer_frames_is_the_shortest_channel() {
let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] };
assert_eq!(s.frames(), 7);
assert_eq!(SampleBuffer::default().frames(), 0);
}
}
-3
View File
@@ -7,7 +7,6 @@ pub mod harmonic;
pub mod modulation;
pub mod pitch;
pub mod reverb;
pub mod sampler;
pub mod strip;
pub mod synth;
pub mod utility;
@@ -42,7 +41,6 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&utility::DITHER_INFO,
&synth::SYNTH_INFO,
&drums::DRUM_INFO,
&sampler::SAMPLER_INFO,
];
/// Constructs an (unprepared) plugin by id.
@@ -74,7 +72,6 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"dither" => Box::new(utility::Dither::new()),
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
"drum_synth" => Box::new(drums::DrumSynth::new()),
"sampler" => Box::new(sampler::Sampler::new()),
_ => return None,
})
}
-817
View File
@@ -1,817 +0,0 @@
//! Sampler: plays one recorded sample across the keyboard, 32 voices.
//!
//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with
//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and
//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or
//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with
//! [`Plugin::set_sample`]; without one the Sampler is silent.
use std::sync::Arc;
use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain};
const PLAY_MODES: &[&str] = &["Gate", "One Shot"];
const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"];
const ROOT: usize = 0;
const TRANSPOSE: usize = 1;
const FINE: usize = 2;
const KEY_TRACK: usize = 3;
const KEY_LOW: usize = 4;
const KEY_HIGH: usize = 5;
const VEL_LOW: usize = 6;
const VEL_HIGH: usize = 7;
const VEL_SENS: usize = 8;
const ATTACK: usize = 9;
const DECAY: usize = 10;
const SUSTAIN: usize = 11;
const RELEASE: usize = 12;
const PLAY_MODE: usize = 13;
const START: usize = 14;
const LOOP_MODE: usize = 15;
const LOOP_START: usize = 16;
const LOOP_END: usize = 17;
const LEVEL: usize = 18;
static SAMPLER_PARAMS: [ParamInfo; 19] = [
lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None),
lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones),
lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents),
toggle("key_track", "Key Tracking", true),
lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None),
lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None),
lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None),
lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None),
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms),
log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms),
lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent),
log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms),
choice("play_mode", "Play Mode", PLAY_MODES, 0),
lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent),
choice("loop_mode", "Loop", LOOP_MODES, 0),
lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent),
lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent),
lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db),
];
pub static SAMPLER_INFO: PluginInfo = PluginInfo {
id: "sampler",
name: "Sampler",
short_name: "Sampler",
category: Category::Instrument,
params: &SAMPLER_PARAMS,
is_instrument: true,
audiosuite: false,
};
const VOICES: usize = 32;
/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded).
const MAX_STEP: f64 = 256.0;
#[derive(Debug, Clone, Copy, PartialEq)]
enum LoopMode {
Off,
Forward,
PingPong,
}
#[derive(Debug, Clone, Copy)]
struct Voice {
note: u8,
/// Key still down (gate mode only; one-shot voices are never held).
held: bool,
gain: f32,
/// Read position in sample frames.
pos: f64,
step: f64,
forward: bool,
looping: bool,
env: Adsr,
}
impl Voice {
const fn new() -> Self {
Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() }
}
fn active(&self) -> bool {
self.env.stage != Stage::Idle
}
fn stop(&mut self) {
*self = Voice::new();
}
}
/// Values derived from the parameters (refreshed on every change).
#[derive(Debug, Clone, Copy)]
struct Derived {
root: f64,
/// Transpose plus fine tune, in semitones.
offset: f64,
key_track: bool,
keys: (u8, u8),
vels: (u8, u8),
sens: f32,
one_shot: bool,
start: f64,
loop_mode: LoopMode,
/// Loop start and end as fractions of the sample (start <= end).
loop_span: (f64, f64),
}
/// A MIDI key or velocity parameter as a whole number in `lo..=127`.
fn whole(v: f32, lo: u8) -> u8 {
if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo }
}
fn ordered(a: u8, b: u8) -> (u8, u8) {
(a.min(b), a.max(b))
}
/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads
/// outside the slice are silence.
#[inline]
fn hermite(x: &[f32], pos: f64) -> f32 {
let i = pos as usize;
let t = (pos - i as f64) as f32;
let at = |k: usize| x.get(k).copied().unwrap_or(0.0);
let x0 = at(i);
if t == 0.0 {
return if x0.is_finite() { x0 } else { 0.0 };
}
let xm1 = at(i.saturating_sub(1));
let x1 = at(i.saturating_add(1));
let x2 = at(i.saturating_add(2));
let c1 = 0.5 * (x1 - xm1);
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
let y = ((c3 * t + c2) * t + c1) * t + x0;
// A float file can hold NaN or infinities; they play as silence.
if y.is_finite() { y } else { 0.0 }
}
/// Plays a sample across the keyboard.
pub struct Sampler {
p: Params,
d: Derived,
sr: f32,
ch: usize,
sample: Option<Arc<dyn SampleSource>>,
/// Frames in the sample (0 = nothing to play).
len: usize,
/// The sample's own rate.
sample_sr: f32,
voices: [Voice; VOICES],
events: EventQueue,
rates: AdsrRates,
level: Smoother,
}
impl Default for Sampler {
fn default() -> Self {
Self::new()
}
}
impl Sampler {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SAMPLER_PARAMS),
d: Derived {
root: 60.0,
offset: 0.0,
key_track: true,
keys: (0, 127),
vels: (1, 127),
sens: 0.7,
one_shot: false,
start: 0.0,
loop_mode: LoopMode::Off,
loop_span: (0.0, 1.0),
},
sr: 48_000.0,
ch: 0,
sample: None,
len: 0,
sample_sr: 48_000.0,
voices: [Voice::new(); VOICES],
events: EventQueue::new(),
rates: AdsrRates::default(),
level: Smoother::new(1.0),
};
s.update();
s
}
fn update(&mut self) {
let v = |i| self.p.v(i);
let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0);
let (ls, le) = (pct(LOOP_START), pct(LOOP_END));
self.d = Derived {
root: f64::from(whole(v(ROOT), 0)),
offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0,
key_track: self.p.on(KEY_TRACK),
keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)),
vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)),
sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0),
one_shot: self.p.choice(PLAY_MODE) == 1,
start: pct(START),
loop_mode: match self.p.choice(LOOP_MODE) {
1 => LoopMode::Forward,
2 => LoopMode::PingPong,
_ => LoopMode::Off,
},
loop_span: (ls.min(le), ls.max(le)),
};
self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr);
self.level.set(db_to_gain(v(LEVEL)));
}
/// The loop region in sample frames, when it is long enough to loop.
fn loop_frames(&self) -> Option<(f64, f64)> {
if self.d.loop_mode == LoopMode::Off {
return None;
}
let len = self.len as f64;
let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len);
(b - a >= 1.0).then_some((a, b))
}
fn start_note(&mut self, note: u8, vel: u8) {
if vel == 0 {
self.stop_note(note);
return;
}
let d = self.d;
let (note, vel) = (note.min(127), vel.min(127));
if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 {
return;
}
let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset;
let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0);
if !step.is_finite() || step <= 0.0 {
return;
}
if !d.one_shot {
// Retriggering a held key releases the earlier voice instead of stacking it.
self.stop_note(note);
}
let idx = self
.voices
.iter()
.position(|v| !v.active())
.or_else(|| {
self.voices
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
})
.unwrap_or(0);
let start = d.start * self.len.saturating_sub(1) as f64;
let looping = self.loop_frames().is_some_and(|(_, end)| start < end);
let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0;
if let Some(v) = self.voices.get_mut(idx) {
*v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() };
v.env.gate_on();
}
}
fn stop_note(&mut self, note: u8) {
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
v.held = false;
v.env.gate_off();
}
}
fn apply(&mut self, kind: EventKind) {
match kind {
EventKind::On(n, v) => self.start_note(n, v),
EventKind::Off(n) => self.stop_note(n),
EventKind::AllOff => {
for v in &mut self.voices {
v.held = false;
v.env.gate_off();
}
}
}
}
/// Adds every voice's output for frame `n` into `io` and advances the voices.
#[inline]
fn render_frame(&mut self, io: &mut [Vec<f32>], ch: usize, n: usize, level: f32) {
let Some(sample) = self.sample.as_deref() else { return };
let nch = sample.num_channels();
let len = self.len as f64;
let mode = self.d.loop_mode;
let region = self.loop_frames();
for v in &mut self.voices {
if !v.active() {
continue;
}
let a = v.env.next(&self.rates) * v.gain * level;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let src = if nch == 1 { 0 } else { c };
if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) {
*x += hermite(data, v.pos) * a;
}
}
let r = if v.looping { region } else { None };
advance(v, len, r, mode);
}
}
}
/// Moves a voice's read position one output frame on, looping or ending it.
#[inline]
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
match region {
Some((ls, le)) if mode == LoopMode::PingPong => {
if v.forward && v.pos >= le {
v.pos = (le - (v.pos - le)).max(ls);
v.forward = false;
} else if !v.forward && v.pos <= ls {
v.pos = (ls + (ls - v.pos)).min(le);
v.forward = true;
}
}
Some((ls, le)) => {
if v.pos >= le {
v.pos = ls + (v.pos - ls) % (le - ls);
}
}
None => {
if v.pos >= len || !v.pos.is_finite() {
v.stop();
}
}
}
if !v.pos.is_finite() || v.pos < 0.0 {
v.stop();
}
}
impl Plugin for Sampler {
param_plumbing!(SAMPLER_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = sane_channels(channels);
self.level.set_time(20.0, self.sr);
self.update();
self.reset();
}
fn reset(&mut self) {
for v in &mut self.voices {
v.stop();
}
self.events.clear();
self.level.snap();
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.min(io.len());
let frames = frames_in(io, frames, ch);
for b in io.iter_mut().take(ch) {
b.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
let count = {
let sorted = self.events.sorted();
for (d, s) in evs.iter_mut().zip(sorted) {
*d = *s;
}
sorted.len().min(MAX_EVENTS)
};
self.events.clear();
let mut next = 0;
for n in 0..frames {
while let Some(e) = evs.get(next).filter(|_| next < count) {
if e.offset > n && n + 1 < frames {
break;
}
let kind = e.kind;
self.apply(kind);
next += 1;
}
let level = self.level.next();
self.render_frame(io, ch, n, level);
}
// Events for an empty block take effect immediately.
for e in evs.iter().take(count).skip(next) {
self.apply(e.kind);
}
}
fn tail_samples(&self) -> usize {
let release = ms_to_samples(self.p.v(RELEASE), self.sr);
// A one-shot keeps playing its sample after the note ends.
let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 };
let t = release + rest;
if t.is_finite() { t.min(1.0e9) as usize } else { 0 }
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
self.events.push(offset, EventKind::On(note, velocity));
}
fn note_off(&mut self, offset: usize, note: u8) {
self.events.push(offset, EventKind::Off(note));
}
fn all_notes_off(&mut self) {
self.events.push(0, EventKind::AllOff);
}
fn set_sample(&mut self, sample: Option<Arc<dyn SampleSource>>) -> bool {
for v in &mut self.voices {
v.stop();
}
self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() });
let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate());
// An unusable rate plays at the host rate rather than not at all.
self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr };
self.sample = sample;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SampleBuffer;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
fn sampler(sample: SampleBuffer) -> Sampler {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
assert!(s.set_sample(Some(Arc::new(sample))));
s
}
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
SampleBuffer { sample_rate, channels: vec![data] }
}
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
}
/// Renders `len` frames in 512-frame blocks; returns both channels.
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
let mut out = [Vec::new(), Vec::new()];
let mut buf = vec![vec![1.0f32; 512]; 2];
let mut done = 0;
while done < len {
let n = 512.min(len - done);
s.process(&mut buf, n);
out[0].extend_from_slice(&buf[0][..n]);
out[1].extend_from_slice(&buf[1][..n]);
done += n;
}
out
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
/// Frequency from upward zero crossings.
fn freq(x: &[f32], sr: f32) -> f32 {
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
(ups.len() - 1) as f32 * sr / (b - a) as f32
}
#[test]
fn silent_without_a_sample_and_replaces_the_buffer() {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
s.note_on(0, 60, 127);
let out = run(&mut s, 4800);
assert_eq!(peak(&out[0]), 0.0);
assert_eq!(peak(&out[1]), 0.0);
}
#[test]
fn root_note_plays_the_sample_verbatim() {
let data = sine(440.0, SR, 9600);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.note_on(0, 60, 100);
let out = run(&mut s, 9600);
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
for i in 100..9600 {
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
}
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
}
#[test]
fn keys_transpose_from_the_root_note() {
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
s.note_on(0, 72, 127);
let up = run(&mut s, 24_000);
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
s.reset();
s.note_on(0, 53, 127);
let down = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
s.reset();
s.set_param("root_note", 48.0);
s.set_param("transpose", -12.0);
s.set_param("fine_tune", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(1.0 / 12.0);
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
}
#[test]
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
s.set_param("key_track", 0.0);
s.note_on(0, 90, 127);
let out = run(&mut s, 24_000);
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
}
#[test]
fn converts_the_sample_rate() {
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
s.note_on(0, 60, 127);
let out = run(&mut s, 60_000);
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
// And it lasts as long as the recording: one second.
assert!(peak(&out[0][46_000..47_500]) > 0.1);
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
// A 96 kHz recording is read faster, not cut short.
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
}
#[test]
fn key_and_velocity_ranges_gate_notes() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("key_low", 48.0);
s.set_param("key_high", 60.0);
s.set_param("vel_low", 40.0);
s.set_param("vel_high", 100.0);
for (note, vel, sounds) in
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
{
s.reset();
s.note_on(0, note, vel);
let out = run(&mut s, 960);
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
}
// Reversed ranges are read in order rather than muting everything.
s.set_param("key_low", 60.0);
s.set_param("key_high", 48.0);
s.reset();
s.note_on(0, 50, 80);
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
}
#[test]
fn velocity_sensitivity_scales_level() {
let level = |sens: f32, vel: u8| {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("velocity", sens);
s.note_on(0, 60, vel);
peak(&run(&mut s, 2400)[0][1000..])
};
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
assert!(level(70.0, 30) < level(70.0, 127));
}
#[test]
fn adsr_shapes_the_note() {
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
s.set_param("velocity", 0.0);
s.set_param("attack", 100.0);
s.set_param("decay", 100.0);
s.set_param("sustain", 50.0);
s.set_param("release", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
assert!(peak(&out[0][4700..4900]) > 0.49);
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
s.note_off(0, 60);
let out = run(&mut s, 24_000);
assert!(out[0][100] > 0.2, "release starts from the sustain level");
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
}
#[test]
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 24_000);
assert!(peak(&out[0][..900]) > 0.1);
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 30_000);
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
// Stopping the transport still silences one-shots.
s.reset();
s.note_on(0, 60, 127);
run(&mut s, 512);
s.all_notes_off();
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
}
#[test]
fn retriggering_a_held_key_releases_the_old_voice() {
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
s.set_param("velocity", 0.0);
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
// One-shots overlap.
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
}
#[test]
fn forward_loop_sustains_past_the_end() {
let data = sine(480.0, SR, 4800);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
for i in 0..2400 {
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
}
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
// Without the loop the note ends with the sample.
s.set_param("loop_mode", 0.0);
s.reset();
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
}
#[test]
fn ping_pong_loop_reverses_direction() {
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 2.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 3000);
assert!(out[0][900] > out[0][500], "rising");
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
}
#[test]
fn a_loop_too_short_to_loop_plays_once() {
let mut s = sampler(mono(SR, vec![0.5; 1000]));
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 40.0);
s.set_param("loop_end", 40.0);
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
}
#[test]
fn sample_start_skips_into_the_sample() {
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("attack", 0.1);
s.set_param("start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2000);
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
}
#[test]
fn stereo_samples_keep_their_sides() {
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
let mut s = sampler(sample);
s.set_param("velocity", 0.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2400);
assert!((out[0][1000] - 0.5).abs() < 1e-6);
assert!((out[1][1000] + 0.25).abs() < 1e-6);
}
#[test]
fn note_offsets_are_sample_accurate() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.note_on(300, 60, 127);
let out = run(&mut s, 512);
assert!(out[0][..300].iter().all(|&v| v == 0.0));
assert!(out[0][301..].iter().any(|&v| v != 0.0));
}
#[test]
fn many_notes_steal_voices_and_stay_bounded() {
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
s.set_param("level", 12.0);
for n in 0..100u8 {
s.note_on(usize::from(n), n, 127);
}
let out = run(&mut s, 9600);
assert!(out[0].iter().all(|v| v.is_finite()));
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
}
#[test]
fn a_new_sample_stops_the_old_voices() {
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
s.note_on(0, 60, 127);
run(&mut s, 512);
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
assert!(s.set_sample(None));
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
}
#[test]
fn hostile_samples_and_params_never_panic() {
let samples = [
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: SR, channels: vec![] },
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
];
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
for sample in samples {
let mut s = Sampler::new();
s.prepare(f32::NAN, 0, 99);
s.set_sample(Some(Arc::new(sample)));
for (id, v) in &params {
assert!(s.set_param(id, *v));
for n in [0u8, 60, 127, 255] {
s.note_on(0, n, 255);
s.note_on(7, n, 1);
}
let mut io = vec![vec![0.0f32; 64]; 3];
s.process(&mut io, 1000);
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
s.process(&mut [], 64);
let _ = s.tail_samples();
}
}
// Loops with every mode at the extremes of pitch.
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
for mode in [0.0, 1.0, 2.0] {
s.set_param("loop_mode", mode);
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
s.set_param("transpose", t);
s.set_param("root_note", root);
s.note_on(0, 127, 127);
s.note_on(0, 0, 127);
let out = run(&mut s, 4096);
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
}
}
}
}
+10 -10
View File
@@ -97,7 +97,7 @@ const VOICES: usize = 16;
const SILENT: f32 = 1.0e-5;
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Stage {
enum Stage {
Idle,
Attack,
Decay,
@@ -107,13 +107,13 @@ pub(crate) enum Stage {
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
#[derive(Debug, Clone, Copy)]
pub(crate) struct Adsr {
pub(crate) stage: Stage,
pub(crate) level: f32,
struct Adsr {
stage: Stage,
level: f32,
}
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct AdsrRates {
struct AdsrRates {
attack: f32,
decay: f32,
sustain: f32,
@@ -121,29 +121,29 @@ pub(crate) struct AdsrRates {
}
impl AdsrRates {
pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
}
}
impl Adsr {
pub(crate) const fn new() -> Self {
const fn new() -> Self {
Self { stage: Stage::Idle, level: 0.0 }
}
pub(crate) fn gate_on(&mut self) {
fn gate_on(&mut self) {
self.stage = Stage::Attack;
}
pub(crate) fn gate_off(&mut self) {
fn gate_off(&mut self) {
if self.stage != Stage::Idle {
self.stage = Stage::Release;
}
}
#[inline]
pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 {
fn next(&mut self, r: &AdsrRates) -> f32 {
match self.stage {
Stage::Idle => self.level = 0.0,
Stage::Attack => {
-1
View File
@@ -22,7 +22,6 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
zip.workspace = true
[dev-dependencies]
proptest.workspace = true
+2 -46
View File
@@ -107,12 +107,8 @@ 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);
// 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();
}
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);
}
@@ -126,43 +122,3 @@ 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}");
}
}
+8 -27
View File
@@ -185,7 +185,10 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");
// Start from the unwarped original if this clip was warped before.
let key = |k: &str| format!("elastic.{}.{k}", id.0);
let orig = warp_original(s, *id);
let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) {
(Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)),
_ => None,
};
let mut base = c.clone();
if let Some((src, off, len)) = orig {
base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
@@ -226,12 +229,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
let mut n = 0;
for id in &ids {
let key = |k: &str| format!("elastic.{}.{k}", id.0);
let orig = warp_original(s, *id);
let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied());
if let Some(c) = s.find_clip_mut(*id) {
c.stretch = 1.0;
if let Some((src, off, len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
c.length = len.max(1);
if let (Some(src), Some(off), Some(len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 };
c.length = (len as i64).max(1);
c.fade_out.len = c.fade_out.len.min(c.length);
n += 1;
}
@@ -243,14 +246,6 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"restored": n}))
}
/// The audio a warped clip was rendered from, while that file is still in the session.
fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> {
let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied();
let src = soundcraft_model::SourceId(v("src")? as u64);
s.source(src)?;
Some((src, v("off")? as i64, v("len")? as i64))
}
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
@@ -378,20 +373,6 @@ mod tests {
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();
let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap();
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
let clip = e.session().track(pad).unwrap().clips()[0].clone();
e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap();
e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap();
let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap();
assert_eq!(r["restored"], 0);
let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap();
assert!(e.session().source(src).is_some());
}
#[test]
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
let mut e = crate::demo::demo_engine();
-160
View File
@@ -1,160 +0,0 @@
//! Clip List commands.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::SourceId;
use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"clip.clear",
"Clear Clips",
[],
Some("Cmd+Shift+B"),
"{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too",
has_list_selection,
clear
)]
}
fn has_list_selection(e: &Engine) -> std::result::Result<(), String> {
let ed = &e.session().edit;
if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) }
}
fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose()
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let (clips, sources): (BTreeSet<ClipId>, BTreeSet<SourceId>) = match (ids(p, "clips")?, ids(p, "sources")?) {
(None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()),
(c, f) => (c.unwrap_or_default(), f.unwrap_or_default()),
};
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
return Err(bad(CLEAR, format!("no clip with id {c}")));
}
let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::<Result<Vec<_>>>()?;
if clips.is_empty() && files.is_empty() {
return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List"));
}
if !bool_or(p, "with_clips", false)
&& let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0)
{
let plural = if n == 1 { "" } else { "s" };
return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name)));
}
let gone: BTreeSet<ClipId> = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect();
let s = e.session_mut();
for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) {
pl.clips.retain(|c| !gone.contains(&c.id));
}
s.sources.retain(|f| !sources.contains(&f.id));
for f in &sources {
s.pool.remove(*f);
}
s.edit.selected_clips.retain(|c| !gone.contains(c));
s.edit.selected_sources.retain(|f| !sources.contains(f));
Ok(json!({"clips": gone.len(), "sources": sources.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
let s = e.session();
let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap();
(f, s.clips_using(f).map(|c| c.id).collect())
}
#[test]
fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() {
let mut e = crate::demo::demo_engine();
let (f, clips) = kick(&e);
let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap();
assert_eq!(r, json!({"clips": 1, "sources": 0}));
assert!(e.session().find_clip(clips[0]).is_none());
assert!(e.session().source(f).is_some());
assert!(e.undo());
assert!(e.session().find_clip(clips[0]).is_some());
}
#[test]
fn a_file_in_use_stays_unless_its_clips_go_too() {
let mut e = crate::demo::demo_engine();
e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap();
let (f, clips) = kick(&e);
assert_eq!(clips.len(), 6);
let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err();
assert!(err.to_string().contains("used by 6 clips"), "{err}");
let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap();
assert_eq!(r, json!({"clips": 6, "sources": 1}));
let s = e.session();
assert!(s.source(f).is_none() && !s.pool.contains(f));
assert_eq!(s.clips_using(f).count(), 0);
assert_eq!(e.undo_label(), Some("Clear Clips"));
assert!(e.undo());
assert!(e.session().pool.contains(f));
assert_eq!(e.session().clips_using(f).count(), 6);
}
#[test]
fn listing_a_files_clips_too_is_enough() {
let mut e = crate::demo::demo_engine();
let (f, clips) = kick(&e);
let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap();
assert_eq!(r, json!({"clips": 3, "sources": 1}));
}
#[test]
fn the_clip_list_selection_is_the_default_target() {
let mut e = crate::demo::demo_engine();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] };
let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav);
let (_, clips) = kick(&e);
e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap();
assert_eq!(r["sources"], json!([f]));
assert_eq!(r["clips"], json!([]));
let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
assert_eq!(r["sources"], json!([]));
e.execute("clip.clear", &json!({})).unwrap();
assert!(e.session().find_clip(clips[0]).is_none());
assert!(e.session().edit.selected_clips.is_empty());
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
e.execute("clip.clear", &json!({})).unwrap();
assert!(e.session().source(f).is_none());
assert!(e.session().edit.selected_sources.is_empty());
}
#[test]
fn bad_targets_are_errors() {
let mut e = crate::demo::demo_engine();
for p in [
json!({}),
json!({"clips": []}),
json!({"clips": [999_999]}),
json!({"sources": [999_999]}),
json!({"sources": "Kick"}),
json!({"clips": [-1]}),
] {
assert!(e.execute("clip.clear", &p).is_err(), "{p}");
}
}
#[test]
fn deselect_all_clears_the_clip_list_selection() {
let mut e = crate::demo::demo_engine();
let (f, _) = kick(&e);
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.session().edit.selected_sources.is_empty());
}
}
-65
View File
@@ -362,69 +362,4 @@ 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);
}
}
+13 -44
View File
@@ -49,12 +49,11 @@ pub fn specs() -> Vec<CommandSpec> {
repeat_to_fill
),
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
let s = e.session_mut();
s.edit.selected_tracks.clear();
s.edit.selected_clips.clear();
s.edit.selected_sources.clear();
s.edit.selection.end = s.edit.selection.start;
Ok(json!({}))
}),
@@ -352,12 +351,8 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let en = position_param(e, "edit.select", p, "end")?;
let clips =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
let sources =
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
let exact = bool_or(p, "exact", false);
let s = e.session();
let mut ed = s.edit.clone();
let s = e.session_mut();
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
@@ -371,11 +366,11 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
ed.selected_tracks = t;
s.edit.selected_tracks = t;
}
match (st, en) {
(Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)),
(Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)),
_ => {}
}
if let Some(c) = clips {
@@ -391,25 +386,17 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
if let Some(r) = range {
ed.selection = r;
ed.selected_tracks = trs;
s.edit.selection = r;
s.edit.selected_tracks = trs;
}
ed.selected_clips = c;
} else if list_changes {
ed.selected_clips.clear();
s.edit.selected_clips = c;
} else if st.is_some() {
s.edit.selected_clips.clear();
}
if list_changes {
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
if s.edit.link_timeline_edit {
s.edit.timeline_selection = s.edit.selection;
}
if ed.link_timeline_edit {
ed.timeline_selection = ed.selection;
}
let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources});
if ed != s.edit {
e.session_mut().edit = ed;
}
Ok(out)
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}))
}
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
@@ -765,21 +752,3 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
}
Ok(json!({"consolidated": n}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
let mut e = Engine::default();
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200});
e.execute("edit.select", &sel).unwrap();
let rev = e.revision;
assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]]));
assert_eq!(e.revision, rev);
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
assert_ne!(e.revision, rev);
}
}
+19 -259
View File
@@ -15,41 +15,34 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing}))
}),
cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }),
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| {
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| {
let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?;
refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| {
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string();
refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| {
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();
refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session_copy(e, &path)?;
let keep = e.path.clone();
let written = crate::io::save_session(e, &path)?;
e.path = keep;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| {
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string();
let file = crate::io::session_file(&path);
refuse_replacing(e, "session.save_template", &file, p)?;
let mut s = e.session().clone();
for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } }
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?;
}
Ok(json!({"path": file}))
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(json!({"path": path}))
}),
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| {
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| {
let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?;
crate::io::open_session(e, &path)?;
Ok(json!({}))
@@ -105,30 +98,25 @@ 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 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())?;
crate::io::write_file(&path, &bytes)?;
let bytes = crate::io::export_midi(e)?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") {
crate::io::write_file(path, text.as_bytes())?;
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
}
Ok(json!({"text": text}))
}),
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| {
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string();
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("file.export_clips", "no clips selected"));
}
let n = crate::io::export_clips(e, &ids, &dir)?;
Ok(json!({"written": n, "dir": dir}))
Ok(json!({"written": n}))
}),
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| {
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string();
refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?;
let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?;
let mut s = e.session().clone();
s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master);
@@ -192,14 +180,6 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Writing a session over an existing file needs `replace: true` (see `io::replaces`).
fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> {
if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) {
return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display())));
}
Ok(())
}
fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Untitled").to_string();
let sr =
@@ -254,17 +234,6 @@ 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) {
@@ -291,9 +260,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 bus = bounce_bus(e, p)?;
let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
crate::io::write_file(&path, &bytes)?;
let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?;
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}),
)
@@ -312,7 +280,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
};
crate::io::write_file(&path, text.as_bytes())?;
std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
}
@@ -397,53 +365,6 @@ mod tests {
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
#[test]
fn bounce_source_renders_the_named_bus() {
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
let track = e.session().tracks[0].id;
{
let tr = e.session_mut().track_mut(track).unwrap();
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
send.level_db = -12.0;
tr.mixer.sends[0] = Some(send);
}
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
let path = dir.join(name);
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
if let Some(source) = source {
params["source"] = json!(source);
}
let value = e.execute("file.bounce_mix", &params).unwrap();
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
(std::fs::read(&path).unwrap(), peak)
};
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
assert_eq!(main_bytes, named_main);
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
assert!(main_peak > -20.0, "{main_peak}");
assert_ne!(stem_bytes, main_bytes);
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
assert_ne!(empty_bytes, main_bytes);
assert!(empty_peak < -100.0, "{empty_peak}");
let missing = dir.join("missing.wav");
let err = e
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
.unwrap_err();
assert!(err.to_string().contains("unknown bus"), "{err}");
assert!(!missing.exists());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
@@ -459,7 +380,6 @@ mod tests {
fn session_open_reports_missing_media() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
@@ -471,16 +391,6 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_a_session_without_audio_creates_its_folder() {
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song").join("Song.scraft");
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn tone_engine() -> (Engine, u64) {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
@@ -518,154 +428,4 @@ mod tests {
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
let p = json!({"dir": dir.to_string_lossy()});
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.execute("file.export_clips", &p).is_err());
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
let r = e.execute("file.export_clips", &p).unwrap();
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
let _ = std::fs::remove_dir_all(&dir);
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
let mut keys_seq = Sequence::default();
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
let mut drum_seq = Sequence::default();
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
let a = s.new_clip_id();
let b = s.new_clip_id();
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
Engine::new(s)
}
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
let smf = soundcraft_midi::read_smf(bytes).unwrap();
let mut out = Vec::new();
for t in &smf.tracks {
for c in &t.sequence.ctrls {
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
out.push((t.name.clone(), n, c.value));
}
}
}
out
}
#[test]
fn midi_export_keeps_controllers_and_honours_tracks() {
let mut e = midi_engine();
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let all = dir.join("all.mid");
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
let keys = dir.join("keys.mid");
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
assert_eq!(ctrls[0].0, "Keys");
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
let src = e.session().sources[0].id;
let audio = e.session().pool.get(src).unwrap();
assert_eq!(audio.buffer.channels.len(), 6);
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
let id = e.session().tracks[0].id;
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
assert_eq!(e.session().tracks[0].clips().len(), 1);
}
#[test]
fn repeated_consolidate_keeps_distinct_audio_files() {
let (mut e, track) = tone_engine();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let cid = e.session().tracks[0].clips()[0].id;
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
assert_ne!(consolidated[0], consolidated[1]);
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tone.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut opened = Engine::default();
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
let a = std::fs::read(&files[0]).unwrap();
let b = std::fs::read(&files[1]).unwrap();
assert_ne!(a, b, "the second consolidation replaced the first file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_copy_leaves_the_open_session_alone() {
let (mut e, _) = tone_engine();
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("Original.scraft");
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
assert!(e.is_dirty());
let copy_dir = dir.join("copy");
std::fs::create_dir_all(&copy_dir).unwrap();
let copy = copy_dir.join("Copy.scraft");
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
assert_eq!(e.session().name, "Original");
assert!(e.is_dirty());
assert_eq!(e.session().tracks.len(), 2);
let mut from_original = Engine::default();
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
assert_eq!(from_original.session().tracks.len(), 1);
let mut from_copy = Engine::default();
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(from_copy.session().tracks.len(), 2);
assert!(from_copy.session().track_by_name("Extra").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
+9 -52
View File
@@ -8,26 +8,15 @@ use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
),
cmd!(
"mix.instrument_param",
"Set Instrument Parameter",
[],
None,
"{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",
has_selection,
set_instrument_param
),
]
vec![cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
)]
}
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -50,20 +39,6 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"track": t, "plugin": info.id}))
}
fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.instrument_param";
let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();
let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;
// Hosted instruments keep their parameters in their own state.
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;
let pi = info.param(&param).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;
let v = pi.clamp(value);
ins.params.insert(param.clone(), v);
Ok(json!({"track": t, "param": param, "value": v}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
@@ -93,24 +68,6 @@ mod tests {
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
}
#[test]
fn sets_a_built_in_instruments_parameters() {
let mut e = Engine::default();
let t = track(&mut e, "instrument");
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 5000})).unwrap();
assert_eq!(r["value"], 5000.0);
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 1.0e9})).unwrap();
assert_eq!(r["value"], 20000.0, "clamped");
let v = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied());
assert_eq!(v, Some(20000.0));
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "nope", "value": 1})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff"})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": "loud"})).is_err());
let audio = track(&mut e, "audio");
assert!(e.execute("mix.instrument_param", &json!({"track": audio.0, "param": "cutoff", "value": 1})).is_err());
e.execute("edit.undo", &json!({})).unwrap();
}
#[test]
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
let mut e = Engine::default();
+13 -222
View File
@@ -105,15 +105,7 @@ pub fn specs() -> Vec<CommandSpec> {
insert_params
),
cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move),
cmd!(
"mix.send",
"Assign Send",
[],
None,
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
has_selection,
send
),
cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send),
cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| {
let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?;
if let Some(tr) = e.session_mut().track_mut(t)
@@ -398,17 +390,15 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = slot_of(p, "slot", SEND_SLOTS);
let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0);
let pre = bool_or(p, "pre_fader", false);
let s = e.session_mut();
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
let mut sl = match tr.mixer.sends.get(slot).cloned().flatten() {
Some(old) => SendSlot { target: soundcraft_model::Route::Bus(bid), ..old },
None => SendSlot::new(soundcraft_model::Route::Bus(bid)),
};
sl.level_db = f32_or(p, "level_db", sl.level_db).clamp(-144.0, 12.0);
sl.pre_fader = bool_or(p, "pre_fader", sl.pre_fader);
let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid));
sl.level_db = level;
sl.pre_fader = pre;
if let Some(x) = tr.mixer.sends.get_mut(slot) {
*x = Some(sl);
}
@@ -544,95 +534,25 @@ 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 Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
let lane = tr.lane(&AutoParam::Volume).cloned();
let base = tr.mixer.volume_db;
let delta = |at: i64| lane.value_at(at, base) - base;
let Some(lane) = lane else { continue };
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
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);
}
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));
c.gain_env = env;
}
}
// 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);
}
tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end);
}
Ok(json!({}))
}
@@ -686,132 +606,3 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
}
Ok(json!({}))
}
#[cfg(test)]
mod tests {
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() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
}
+9 -13
View File
@@ -10,7 +10,6 @@ mod audio_midi;
mod audiosuite;
mod clap;
mod clip;
mod clip_list;
mod clip_more;
mod edit;
mod edit_more;
@@ -25,7 +24,6 @@ mod more_util;
mod options;
mod plugin_state;
mod query;
mod sampler;
mod setup_more;
mod surround;
mod track;
@@ -53,8 +51,6 @@ pub struct CommandSpec {
pub run: Run,
pub journal: bool,
pub undoable: bool,
/// Only touches `edit.zoom`, through `Engine::zoom_mut`.
pub view: bool,
}
/// Serializable description of a command.
@@ -84,16 +80,18 @@ impl CommandSpec {
}
}
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants.
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants.
#[macro_export]
macro_rules! cmd {
(query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) };
(noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) };
(view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) };
(@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view }
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
};
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
};
($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) };
}
pub fn command_specs() -> &'static [CommandSpec] {
@@ -118,7 +116,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view_more::specs());
v.extend(track_more::specs());
v.extend(clip_more::specs());
v.extend(clip_list::specs());
v.extend(event_more::specs());
v.extend(setup_more::specs());
v.extend(clap::specs());
@@ -126,7 +123,6 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(sampler::specs());
v.extend(video::specs());
v
})
-1
View File
@@ -17,7 +17,6 @@ pub fn specs() -> Vec<CommandSpec> {
run: insert_state,
journal: false,
undoable: false,
view: false,
}]
}
-321
View File
@@ -1,321 +0,0 @@
//! The built-in Sampler's sample: `mix.sampler_load` decodes an audio file (WAV, AIFF, FLAC, MP3,
//! OGG, anything `file.import_audio` reads) into a session source, resampled to the session rate,
//! and makes it the sample an instrument track's Sampler plays; `mix.sampler_clear` removes it.
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
use std::path::Path;
const SAMPLER: &str = "sampler";
/// Largest sample accepted (frames × channels): ten minutes of 192 kHz stereo.
const MAX_SAMPLE_VALUES: usize = 192_000 * 600 * 2;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mix.sampler_load",
"Load Sampler Sample",
[],
None,
"{track?, path, params?: {root_note, key_low, key_high, attack, loop_mode, …}}: an instrument track plays the file through the built-in Sampler (made its instrument if it is not already) → {track, source, name, frames, channels, sample_rate}",
has_selection,
load
),
cmd!("mix.sampler_clear", "Clear Sampler Sample", [], None, "{track?}: the track's Sampler stops playing its sample", has_selection, clear),
]
}
/// The instrument track a sampler command acts on.
fn instrument_track(e: &Engine, id: &str, p: &Value) -> Result<TrackId> {
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
return Err(bad(id, "only instrument tracks have a sampler"));
}
Ok(t)
}
fn load(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_load";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
let t = instrument_track(e, id, p)?;
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
// Validate the parameters before touching the session.
let mut values = Vec::new();
if let Some(v) = p.get("params") {
let obj = v.as_object().ok_or_else(|| bad(id, "`params` must be an object"))?;
for (k, v) in obj {
let pi = info.param(k).ok_or_else(|| bad(id, format!("the sampler has no parameter `{k}`")))?;
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(id, format!("`{k}` must be a number")))?;
values.push((k.clone(), pi.clamp(x as f32)));
}
}
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
}
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
if !keep {
let mut ins = Insert::new(SAMPLER);
for pi in info.params {
ins.params.insert(pi.id.to_string(), pi.default);
}
tr.instrument = Some(ins);
}
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = Some(src);
for (k, v) in values {
ins.params.insert(k, v);
}
}
Ok(json!({"track": t, "source": src, "name": stem, "frames": frames, "channels": channels, "sample_rate": rate}))
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_clear";
let t = instrument_track(e, id, p)?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).filter(|i| i.plugin == SAMPLER);
let ins = ins.ok_or_else(|| bad(id, "the track's instrument is not the sampler"))?;
let had = ins.sample.take();
Ok(json!({"track": t, "cleared": had}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, TrackId};
use soundcraft_time::{Range, TICKS_PER_QUARTER};
use std::path::PathBuf;
/// A scratch folder for one test.
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sc-sampler-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Writes a generated tone (no recorded or third-party audio) as a 16-bit WAV.
fn tone_wav(path: &std::path::Path, freq: f32, rate: u32, secs: f32, channels: usize) {
let n = (rate as f32 * secs) as usize;
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
}
fn instrument_track(e: &mut Engine) -> TrackId {
e.execute("track.new", &json!({"kind": "instrument", "format": "stereo"})).unwrap();
e.session().tracks.last().unwrap().id
}
/// One MIDI note (pitch, start in quarter notes, length in quarters) on the track at 120 bpm.
fn add_notes(e: &mut Engine, t: TrackId, notes: &[(u8, i64, i64)]) {
let q = TICKS_PER_QUARTER;
let mut seq = Sequence::default();
for &(pitch, start, len) in notes {
seq.notes.push(Note { pitch, velocity: 127, release_velocity: 64, channel: 0, start: start * q, length: len * q });
}
let s = e.session_mut();
let cid = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::midi(cid, "notes", 0, 48_000 * 8, seq));
}
fn render(e: &Engine, secs: i64) -> Vec<Vec<f32>> {
soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512)
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
fn crossings(x: &[f32]) -> usize {
x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count()
}
#[test]
fn loads_a_sample_plays_it_and_undoes() {
let d = dir("play");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 44_100, 2.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1), (72, 2, 1)]);
assert!(peak(&render(&e, 2)[0]) > 0.0, "the default synth plays the notes first");
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert_eq!(r["frames"], 96_000, "resampled to the 48 kHz session: {r}");
assert_eq!(r["sample_rate"], 48_000);
assert_eq!(r["channels"], 1);
assert_eq!(r["name"], "tone");
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let out = render(&e, 2);
// Middle C plays the tone at its pitch for the first quarter (0.5 s), key 72 an octave up.
let first = &out[0][2400..21_600];
let second = &out[0][50_400..69_600];
assert!(peak(first) > 0.05, "the sample plays");
let (a, b) = (crossings(first), crossings(second));
assert!((a as f32 - 176.0).abs() <= 2.0, "440 Hz over 0.4 s: {a}");
assert!((b as f32 - 352.0).abs() <= 3.0, "an octave up: {b}");
assert!(peak(&out[0][36_000..48_000]) < 1e-3, "released between the notes");
e.execute("edit.undo", &json!({})).unwrap();
let back = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(back.plugin, "subtractive_synth");
assert!(back.sample.is_none());
assert!(e.session().sources.is_empty(), "undo removes the source too");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn loads_flac_and_aiff_too() {
let d = dir("formats");
let n = 4800;
let tone: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * 440.0 * i as f32 / 96_000.0).sin()).collect();
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
let mut e = Engine::default();
let t = instrument_track(&mut e);
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
let path = d.join(format!("tone.{ext}"));
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["channels"], 2, "{ext}");
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn instrument_params_reach_the_sampler() {
let d = dir("params");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 2.0, 2);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"key_low": 61}})).unwrap();
assert_eq!(peak(&render(&e, 1)[0]), 0.0, "key 60 is below the key range");
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "key_low", "value": 0})).unwrap();
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "root_note", "value": 48})).unwrap();
let out = render(&e, 1);
assert!((crossings(&out[0][2400..21_600]) as f32 - 352.0).abs() <= 3.0, "root note 48: key 60 plays an octave up");
assert!(peak(&out[1]) > 0.05, "a stereo sample plays on both sides");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"nope": 1}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": "C3"}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": 3})).is_err());
assert_eq!(e.session().sources.len(), 1, "rejected loads add nothing");
// Reloading keeps the sampler's settings and swaps the sample.
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.params.get("root_note"), Some(&48.0));
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn clear_silences_the_sampler() {
let d = dir("clear");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert!(peak(&render(&e, 1)[0]) > 0.05);
let r = e.execute("mix.sampler_clear", &json!({"track": t.0})).unwrap();
assert!(r["cleared"].is_u64());
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
assert!(e.execute("mix.sampler_clear", &json!({"track": t.0})).is_err(), "not a sampler");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_sampled_session_saves_and_reopens_with_its_sample() {
let d = dir("save");
let wav = d.join("pluck.wav");
tone_wav(&wav, 330.0, 22_050, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(64, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": 64, "loop_mode": 1}})).unwrap();
let before = render(&e, 1);
let path = d.join("song.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
std::fs::remove_file(&wav).unwrap();
let mut e2 = Engine::default();
e2.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let ins = e2.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.params.get("loop_mode"), Some(&1.0));
let after = render(&e2, 1);
assert!(peak(&after[0]) > 0.05, "the saved copy in Audio Files plays");
let diff = before[0].iter().zip(&after[0]).fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
assert!(diff < 1e-5, "same audio after reopening: {diff}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn refuses_bad_input() {
let d = dir("bad");
let junk = d.join("junk.wav");
std::fs::write(&junk, b"definitely not audio").unwrap();
let empty = d.join("empty.wav");
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
let wav = d.join("ok.wav");
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
e.execute("track.new", &json!({"kind": "audio"})).unwrap();
let audio = e.session().tracks.last().unwrap().id;
let load = |e: &mut Engine, track: u64, path: &std::path::Path| {
e.execute("mix.sampler_load", &json!({"track": track, "path": path.to_string_lossy()}))
};
assert!(load(&mut e, t.0, &junk).is_err(), "not audio");
assert!(load(&mut e, t.0, &empty).is_err(), "no frames");
assert!(load(&mut e, t.0, &d.join("missing.wav")).is_err(), "missing file");
assert!(load(&mut e, audio.0, &wav).is_err(), "an audio track");
assert!(load(&mut e, 999_999, &wav).is_err(), "no such track");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0})).is_err(), "no path");
assert!(e.execute("mix.sampler_clear", &json!({"track": audio.0})).is_err());
assert!(e.session().sources.is_empty());
assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn missing_media_plays_silence() {
let d = dir("missing");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let src = soundcraft_model::SourceId(r["source"].as_u64().unwrap());
e.session_mut().pool.remove(src);
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
let _ = std::fs::remove_dir_all(d);
}
}
-28
View File
@@ -436,8 +436,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
let mut needed: Vec<SourceId> =
picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect();
// A Sampler's sample is session audio too.
needed.extend(picked.iter().filter_map(|t| t.instrument.as_ref().and_then(|i| i.sample)));
needed.sort_by_key(|x| x.0);
needed.dedup();
let sr = e.session().sample_rate.hz();
@@ -483,10 +481,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = ins.sample.and_then(|sid| map.get(&sid.0).copied());
}
// Busses are matched by name (created when missing).
let remap = |s: &mut Session, r: &Route| -> Route {
match r {
@@ -609,26 +603,4 @@ mod tests {
assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_a_samplers_sample() {
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("hit.wav");
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let mut donor = Engine::default();
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = crate::demo::demo_engine();
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Pads"]})).unwrap();
let sample = e.session().track_by_name("Pads").and_then(|t| t.instrument.as_ref()).and_then(|i| i.sample).unwrap();
let src = e.session().source(sample).unwrap();
assert_eq!(src.name, "hit");
assert!(e.session().pool.get(sample).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
"comments": tr.comments,
});
let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?;
crate::io::write_file(&path, text.as_bytes())?;
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(json!({"path": path, "track": first}))
}
+41 -98
View File
@@ -7,59 +7,54 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)),
cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| {
let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?;
zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64))
}),
cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP);
e.zoom_mut().samples_per_px = v;
cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)),
cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)),
cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0);
e.session_mut().edit.zoom.samples_per_px = v;
Ok(json!({"samples_per_px": v}))
}),
cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize;
let z = e.zoom_mut();
let s = e.session_mut();
if bool_or(p, "store", false) {
if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px }
} else if let Some(v) = z.presets.get(i) {
z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP);
if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px }
} else if let Some(z) = s.edit.zoom.presets.get(i) {
s.edit.zoom.samples_per_px = *z;
}
Ok(json!({"samples_per_px": z.samples_per_px}))
Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px}))
}),
cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let r = e.session().edit.selection;
let s = e.session_mut();
let r = s.edit.selection;
if !r.is_empty() {
let z = e.zoom_mut();
z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = r.start;
s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25);
s.edit.zoom.scroll = r.start;
}
Ok(json!({}))
}),
cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session();
let s = e.session_mut();
let end = s.content_end().max(s.sample_rate.samples(30.0));
let z = e.zoom_mut();
z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = 0;
s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25);
s.edit.zoom.scroll = 0;
Ok(json!({}))
}),
cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
let to = position_param(e, "view.scroll", p, "to")?;
let by = f64_or(p, "by_px", 0.0);
let z = e.zoom_mut();
let by = soundcraft_time::to_samples(by * z.samples_per_px);
z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION);
Ok(json!({"scroll": z.scroll}))
let s = e.session_mut();
let spp = s.edit.zoom.samples_per_px;
s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0);
Ok(json!({"scroll": s.edit.zoom.scroll}))
}),
cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let z = e.zoom_mut();
let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) };
z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": z.waveform_zoom}))
cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let s = e.session_mut();
let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) };
s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom}))
}),
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
@@ -93,19 +88,18 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
const MIN_SPP: f64 = 0.25;
const MAX_SPP: f64 = 1_000_000.0;
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
let insertion = e.session().edit.selection.start as f64;
let z = e.zoom_mut();
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
let s = e.session_mut();
let anchor = s.edit.selection.start;
let old = s.edit.zoom.samples_per_px;
let new = (old * f).clamp(0.25, 1_000_000.0);
// Keep the insertion point at the same screen x when it is visible.
let x = (anchor - s.edit.zoom.scroll) as f64 / old;
if (0.0..4000.0).contains(&x) {
s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0);
}
z.samples_per_px = new;
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
s.edit.zoom.samples_per_px = new;
Ok(json!({"samples_per_px": new}))
}
fn ruler(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -163,55 +157,4 @@ mod tests {
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
#[test]
fn zoom_by_holds_the_position_under_the_pointer() {
let mut e = Engine::default();
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
e.execute("view.scroll", &json!({"to": 48_000})).unwrap();
e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap();
assert_eq!(view(&e), (50.0, 78_000 - 300 * 50));
e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap();
assert_eq!(view(&e), (100.0, 48_000));
}
#[test]
fn zoom_and_scroll_stay_in_range() {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap();
e.execute("view.zoom_to_selection", &json!({})).unwrap();
assert_eq!(view(&e).0, MAX_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap();
assert_eq!(view(&e).0, MIN_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap();
assert_eq!(view(&e), (MAX_SPP, 0));
for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] {
assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}");
}
e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap();
assert_eq!(view(&e).1, MAX_POSITION);
e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap();
assert_eq!(view(&e).1, 0);
}
#[test]
fn view_commands_skip_undo_the_journal_and_session_copies() {
let mut e = Engine::default();
let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone();
let steps = e.undo_history().len();
for db in [-1.0, -2.0] {
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap();
}
assert_eq!(e.undo_history().len(), steps + 1);
let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len());
let before = state(&e);
e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap();
e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap();
assert_eq!(state(&e), before);
}
}
-1
View File
@@ -86,7 +86,6 @@ pub fn session(e: &Engine, full: bool) -> Value {
})).collect::<Vec<_>>(),
"selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips},
"edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(),
"zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll},
"transport": e.transport,
"undo": e.undo_label(), "redo": e.redo_label(),
})
+71 -733
View File
@@ -3,7 +3,7 @@
use crate::{Engine, EngineError, Result};
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -11,21 +11,6 @@ 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();
@@ -35,14 +20,10 @@ 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: stored,
path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string),
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
frames: buf.frames() as u64,
sample_rate: sr,
@@ -75,7 +56,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), Some(&stem))
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem))
}
};
let cid = s.new_clip_id();
@@ -199,8 +180,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
Ok(out)
}
/// 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>> {
/// Export all MIDI clips as a format-1 SMF.
pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
let s = e.session();
let mut smf = soundcraft_midi::Smf {
format: 1,
@@ -210,25 +191,16 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> 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(),
};
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 {
for t in s.tracks.iter().filter(|t| t.kind.is_midi()) {
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 = n.start.saturating_add(base);
n.start += 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();
@@ -245,23 +217,11 @@ 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_bounce(s, r, fold_stereo, bus);
let (mut ch, fmt) = render_main(s, r, fold_stereo);
if normalize {
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
}
@@ -278,19 +238,8 @@ pub fn bounce_source(
/// The main mix over `r` and its format (folded to stereo on request).
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, ChannelFormat) {
render_bounce(s, r, fold_stereo, None)
}
/// 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)
};
let ch = soundcraft_mix::render_range(s, r, 1024);
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
}
@@ -430,9 +379,6 @@ 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)
}
@@ -510,13 +456,7 @@ pub fn sanitize_name(n: &str) -> String {
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
///
/// In the browser there are no folders: the session and all its audio go out as one bundle file
/// (`<name>.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]).
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
if cfg!(target_arch = "wasm32") {
return save_session_bundle(e, path);
}
let (fresh, written, p) = write_session(e.session(), path, false)?;
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
*e.session_mut() = fresh;
@@ -526,186 +466,66 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
}
/// Write a copy of the session (and any audio it needs) without touching the open document —
/// used for autosave / crash recovery. Audio already written for a source is reused.
/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place.
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)> {
let p = session_file(path);
let p = PathBuf::from(path);
let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p };
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let audio_dir = dir.join("Audio Files");
let mut s = session.clone();
let from = s.folder.clone();
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
let mut written = 0;
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() {
if !same_dir
&& s.pool.get(src.id).is_none()
&& Path::new(&src.path).is_relative()
&& let Some(f) = &from
{
src.path = f.join(&src.path).to_string_lossy().into_owned();
}
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
src.path = format!("Audio Files/{f}");
for src in &mut s.sources {
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 {
continue;
}
let Some(audio) = s.pool.get(src.id) else { continue };
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
let mut stem = sanitize_name(&src.name);
let mut target = rel_target.clone();
if overwrite {
// One file per source id so autosaves never collide or pile up.
stem = format!("{stem}_{}", src.id.0);
if audio_dir.join(format!("{stem}.wav")).exists() {
src.path = format!("Audio Files/{stem}.wav");
target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0);
if dir.join(&target).exists() {
src.path = target;
src.unsaved = false;
continue;
}
} else {
let mut k = 1;
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
k += 1;
if k > 999 {
break;
}
}
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
src.path = format!("Audio Files/{name}");
std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?;
src.path = target;
src.unsaved = false;
src.format = FileFormat::Wav;
written += usize::from(new);
written += 1;
}
if written > 0 {
sync_dir(&audio_dir);
}
s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
// Atomic-ish write: temp file then rename.
let tmp = p.with_extension("scraft.tmp");
if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) {
back_up(&p);
}
replace_file(&tmp, &p, text.as_bytes())?;
std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?;
std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
Ok((s, written, p))
}
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
/// old or the new contents, never a mix.
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) {
let _ = std::fs::remove_file(tmp);
return Err(EngineError::Io(format!("{}: {err}", file.display())));
}
sync_dir(file.parent().unwrap_or(Path::new("")));
Ok(())
}
fn sync_dir(dir: &Path) {
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let _ = std::fs::File::open(dir).and_then(|d| d.sync_all());
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
/// newest ten.
fn back_up(file: &Path) {
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str()))
else {
return;
};
let backups = dir.join("Backups");
let prefix = format!("{stem} backup ");
let suffix = format!(".{ext}");
let numbers: Vec<u32> = std::fs::read_dir(&backups)
.into_iter()
.flatten()
.flatten()
.filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok())
.collect();
let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1));
let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}"))));
if let Err(err) = copied {
log::warn!("no backup of {}: {err}", file.display());
return;
}
for n in numbers.into_iter().filter(|n| n.saturating_add(10) <= next) {
let _ = std::fs::remove_file(backups.join(format!("{prefix}{n}{suffix}")));
}
}
/// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no
/// extension and isn't an existing file.
pub fn session_file(path: &str) -> PathBuf {
let p = PathBuf::from(path);
if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
}
/// True when `command` would write a session over the existing `file`. Save, Save As and Save
/// Copy In may write the open session's own file; other commands never ask.
pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
match command {
"session.save" | "session.save_as" | "session.save_copy" => {
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file))
}
"session.save_template" | "file.export_selected_tracks_as_session" => file.exists(),
_ => false,
}
}
fn same_path(a: &Path, b: &Path) -> bool {
let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() };
a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y)
}
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
/// written.
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
use std::io::Write;
for k in 0..10_000u32 {
let name = if k == 0 { format!("{stem}.wav") } else { format!("{stem}_{k:02}.wav") };
let path = dir.join(&name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
if let Err(err) = f.write_all(bytes).and_then(|()| f.sync_all()) {
let _ = std::fs::remove_file(&path);
return Err(EngineError::Io(format!("{}: {err}", path.display())));
}
return Ok((name, true));
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
return Ok((name, false));
}
}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
}
Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display())))
}
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
@@ -715,16 +535,20 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
for src in s.sources.clone() {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
match std::fs::read(&p) {
Ok(bytes) => {
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
missing.push(format!("{}: {err}", p.display()));
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
Ok((_, mut buf)) => {
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
buf.sample_rate = s.sample_rate.hz();
}
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
}
}
Err(err) => missing.push(format!("{}: {err}", p.display())),
},
Err(_) => missing.push(p.display().to_string()),
}
}
missing.extend(relink_videos(&mut s, &dir));
s.folder = Some(dir);
e.replace_session(s);
e.path = Some(path.to_string());
for m in &missing {
@@ -733,181 +557,17 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
Ok(missing)
}
/// Decode one source's audio file into the session pool, at the session rate.
fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> {
let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
buf.sample_rate = s.sample_rate.hz();
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
/// input route names ("In 3" → channel 2), defaulting to the first channels.
pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
let frames = channels.first().map_or(0, Vec::len);
if frames == 0 {
return Ok(Vec::new());
}
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
Ok(())
}
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
/// saves and opens sessions as bundles.
pub const BUNDLE_EXTENSION: &str = "scraftz";
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
const MAX_BUNDLE_ENTRIES: usize = 10_000;
const MAX_BUNDLE_BYTES: u64 = 1 << 30;
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
/// installs one that starts a download. Native builds write files and never install it.
pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>;
static DELIVER: std::sync::OnceLock<DeliverHook> = std::sync::OnceLock::new();
/// Install the delivery hook (once, at start-up of the web app).
pub fn set_deliver(h: DeliverHook) {
let _ = DELIVER.set(h);
}
/// Write one output file: to `path` natively; in the browser, delivered under its file name.
/// Without this, every export on the web failed with «operation not supported on this platform».
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io);
}
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
/// session with its source paths pointing into the bundle.
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
use std::io::Write as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
let mut s = session.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
let mut taken = std::collections::HashSet::new();
for src in &mut s.sources {
let Some(audio) = s.pool.get(src.id) else { continue };
let base = sanitize_name(&src.name);
let mut target = format!("Audio Files/{base}.wav");
let mut k = 1;
while taken.contains(&target) && k <= 9_999 {
target = format!("Audio Files/{base}_{k:02}.wav");
k += 1;
}
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(target.as_str(), opts).map_err(zerr)?;
w.write_all(&bytes).map_err(ioerr)?;
taken.insert(target.clone());
src.path = target;
src.unsaved = false;
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
let bytes = w.finish().map_err(zerr)?.into_inner();
Ok((bytes, s))
}
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone();
if let Some(stem) = stem {
s.name = stem;
}
let (bytes, fresh) = session_bundle(&s)?;
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
write_file(&file, &bytes)?;
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
*e.session_mut() = fresh;
e.path = Some(file);
e.mark_clean();
Ok(written)
}
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
/// [`open_session`].
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
for src in s.sources.clone() {
match files.remove(&src.path) {
Some(audio) => {
let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string);
if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) {
missing.push(format!("{}: {err}", src.path));
}
}
None => missing.push(src.path.clone()),
}
}
// Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing {
e.message(format!("missing media: {m}"));
}
Ok(missing)
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
use std::io::Read as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
if z.len() > MAX_BUNDLE_ENTRIES {
return Err(EngineError::Io("bundle: too many files".into()));
}
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
for i in 0..z.len() {
let mut f = z.by_index(i).map_err(zerr)?;
if f.is_dir() {
continue;
}
let rel = f.name().map_err(zerr)?.to_string();
let size = f.size();
total = total.saturating_add(size);
if total > MAX_BUNDLE_BYTES {
return Err(EngineError::Io("bundle: too large".into()));
}
let mut data = Vec::new();
// The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else {
files.insert(rel, data);
}
}
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
Ok((text, files))
}
/// The record-enabled audio tracks, each with the input channels it records (those its input
/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file
/// `<track>_<NN>.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved).
pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec<usize>, PathBuf)> {
let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir);
let s = e.session();
let take_no = s.sources.len() + 1;
s.tracks
let armed: Vec<(TrackId, usize, usize, String)> = e
.session()
.tracks
.iter()
.filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive)
.map(|t| {
@@ -917,118 +577,28 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
}
_ => 0,
};
let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect();
let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name)));
let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no));
(t.id, inputs, path)
(t.id, first, t.channels(), t.name.clone())
})
.collect()
}
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
/// 1000), each pass after the first on a new playlist.
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
.collect();
if armed.is_empty() {
return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into()));
}
let before = e.session_arc();
let mut out = Vec::new();
for (tid, path) in takes {
let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string()));
let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue };
let take_no = e.session().sources.len() + 1;
for (tid, first, n, name) in armed {
let chans: Vec<Vec<f32>> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: chans };
let clip_name = format!("{name}_{take_no:02}");
let s = e.session_mut();
if s.track(tid).is_none() {
continue;
}
let resampled = buf.sample_rate != s.sample_rate.hz();
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take");
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
let Some(x) = s.sources.last_mut() else { continue };
x.unsaved = resampled;
let len = i64::try_from(x.frames).unwrap_or(0);
let pass = pass.unwrap_or(len).max(1);
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
let n = pass.min(len - offset);
if offset > 0 && n < pass / 8 {
break;
}
if offset > 0
&& let Some(t) = s.track_mut(tid)
{
t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1)));
t.active_playlist = t.playlists.len() - 1;
}
let cid = s.new_clip_id();
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n));
out.push(cid);
}
}
if !out.is_empty() {
e.push_undo("Record", before);
}
out
}
#[cfg(test)]
mod record_tests {
use super::*;
use serde_json::json;
fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId {
let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap();
e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap();
TrackId(id)
}
fn write_take(path: &Path, frames: usize) {
let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap();
bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes()));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[test]
fn armed_tracks_get_new_take_files_used_in_place() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 2");
let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4");
write_take(&dir.join("Audio Files/Vox_01.wav"), 10);
let plan = plan_takes(&e, &dir, 4);
assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]);
assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]);
let session = dir.join("Song.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": session})).unwrap();
write_take(&plan[0].2, 4_800);
let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None);
let clip = e.session().find_clip(clips[0]).unwrap().1.clone();
assert_eq!((clip.start, clip.length), (96_000, 4_800));
assert_eq!(save_session(&mut e, &session).unwrap(), 0);
let src = e.session().sources.last().unwrap().clone();
assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800));
e.execute("edit.undo", &json!({})).unwrap();
assert!(e.session().find_clip(clips[0]).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() {
let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id()));
let path = dir.join("Audio Files/Vox_01.wav");
write_take(&path, 1_050);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 1");
assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3);
assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0);
let t = e.session().track(vox).unwrap();
let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect();
assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]);
assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03"));
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
let src = add_source(s, &clip_name, buf, None, FileFormat::Wav);
let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
let cid = s.new_clip_id();
crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len));
out.push(cid);
}
e.push_undo("Record", before);
Ok(out)
}
#[cfg(test)]
@@ -1052,166 +622,6 @@ mod save_tests {
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
let _ = std::fs::remove_dir_all(&dir);
}
fn with_take(level: f32) -> Engine {
let mut e = Engine::default();
let s = e.session_mut();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let src = add_source(s, "Audio 1_02", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_02", src, 0, 0, 4_800));
e
}
fn reopen(path: &str) -> Engine {
let mut r = Engine::default();
assert_eq!(open_session(&mut r, path).unwrap(), Vec::<String>::new());
r
}
#[test]
fn saving_never_replaces_audio_of_another_session() {
let dir = std::env::temp_dir().join(format!("soundcraft-no-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
save_session(&mut with_take(0.5), &path("A/A.scraft")).unwrap();
save_session(&mut with_take(-0.25), &path("A/A v2.scraft")).unwrap();
save_session(&mut with_take(0.75), &path("B/B.scraft")).unwrap();
save_session(&mut reopen(&path("B/B.scraft")), &path("A/B.scraft")).unwrap();
let levels = ["A/A.scraft", "A/A v2.scraft", "B/B.scraft", "A/B.scraft"].map(|p| {
let r = reopen(&path(p));
r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0]
});
assert_eq!(levels, [0.5, -0.25, 0.75, 0.75]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn consolidating_twice_keeps_both_renders() {
let dir = std::env::temp_dir().join(format!("soundcraft-consolidate-twice-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut e = crate::demo::demo_engine();
let kick = e.session().track_by_name("Kick").unwrap().id;
let end = e.session().content_end();
let render = |e: &Engine| soundcraft_mix::render_clips(e.session(), kick, Range::new(0, end));
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 500_000, "end": 560_000})).unwrap();
save_session(&mut e, &path).unwrap();
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 1_400_000, "end": 1_500_000})).unwrap();
save_session(&mut e, &path).unwrap();
assert!(render(&reopen(&path)) == render(&e));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_after_undo_reuses_the_saved_audio() {
let dir = std::env::temp_dir().join(format!("soundcraft-undo-resave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut e = with_take(0.5);
e.execute("track.new", &json!({})).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 1);
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 0);
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saved_audio_is_trusted_only_in_the_folder_it_was_saved_in() {
let dir = std::env::temp_dir().join(format!("soundcraft-own-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
let level = |r: &Engine| r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0];
save_session(&mut with_take(-0.25), &path("B/Other.scraft")).unwrap();
let mut e = with_take(0.5);
save_session(&mut e, &path("A/A.scraft")).unwrap();
e.execute("track.new", &json!({})).unwrap();
save_session(&mut e, &path("B/B.scraft")).unwrap();
e.execute("edit.undo", &json!({})).unwrap();
e.execute("session.save", &json!({})).unwrap();
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
e.execute("session.save_copy", &json!({"path": path("A/Copy.scraft")})).unwrap();
e.execute("session.save", &json!({})).unwrap();
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap();
let mut missing = Engine::default();
open_session(&mut missing, &path("A/A.scraft")).unwrap();
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
let mut r = Engine::default();
assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_over_another_session_needs_replace() {
let dir = std::env::temp_dir().join(format!("soundcraft-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut first = with_take(0.5);
first.execute("session.save_as", &json!({"path": path})).unwrap();
first.execute("session.save", &json!({})).unwrap();
first.execute("session.save_as", &json!({"path": dir.join("Song")})).unwrap();
assert!(first.execute("session.save_template", &json!({"path": path})).is_err());
let mut second = with_take(-0.25);
let track = second.session().tracks[0].id.0;
for id in ["session.save", "session.save_as", "session.save_copy", "session.save_template", "file.export_selected_tracks_as_session"] {
let err = second.execute(id, &json!({"path": path, "tracks": [track]})).unwrap_err();
assert!(err.to_string().contains("`replace: true`"), "{id}: {err}");
}
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap();
std::fs::copy(&path, dir.join("Song")).unwrap();
let mut bare = Engine::default();
open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap();
bare.execute("session.save", &json!({})).unwrap();
second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap();
let r = reopen(&path);
assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_keeps_the_last_ten_versions_as_backups() {
let dir = std::env::temp_dir().join(format!("soundcraft-backups-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Song.scraft");
let mut e = Engine::default();
let mut versions = Vec::new();
for i in 0..12 {
e.execute("track.new", &json!({"name": format!("T{i}")})).unwrap();
save_session(&mut e, &path.to_string_lossy()).unwrap();
save_session(&mut e, &path.to_string_lossy()).unwrap();
versions.push(std::fs::read(&path).unwrap());
}
let backup = |n: usize| std::fs::read(dir.join(format!("Backups/Song backup {n}.scraft"))).ok();
assert_eq!(std::fs::read_dir(dir.join("Backups")).unwrap().count(), 10);
assert_eq!((backup(1), backup(2), backup(11)), (None, Some(versions[1].clone()), Some(versions[10].clone())));
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)]
@@ -1228,75 +638,3 @@ mod stem_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod bundle_tests {
use super::*;
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let (bytes, fresh) = session_bundle(e.session()).unwrap();
assert!(bytes.starts_with(b"PK\x03\x04"));
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
assert!(missing.is_empty(), "{missing:?}");
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
for src in &e.session().sources {
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
}
assert!(!r.is_dirty());
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
}
#[test]
fn same_named_sources_get_distinct_files() {
let mut e = crate::demo::demo_engine();
let name = e.session().sources[0].name.clone();
let s = e.session_mut();
let n = s.sources.len();
s.sources[1].name = name;
let (bytes, fresh) = session_bundle(e.session()).unwrap();
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
assert_eq!(paths.len(), n);
let mut r = Engine::default();
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
}
#[test]
fn bare_session_opens_with_its_audio_reported_missing() {
let e = crate::demo::demo_engine();
let (_, fresh) = session_bundle(e.session()).unwrap();
let text = fresh.to_json().unwrap();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
assert_eq!(missing.len(), fresh.sources.len());
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
}
#[test]
fn hostile_bundles_are_errors_not_panics() {
let mut r = Engine::default();
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
}
// A valid zip without a session inside.
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
let only_audio = w.finish().unwrap().into_inner();
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
}
#[test]
fn write_file_writes_to_disk_natively() {
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
write_file(&p.to_string_lossy(), b"hello").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let _ = std::fs::remove_file(&p);
}
}
+9 -88
View File
@@ -16,9 +16,8 @@ pub mod io;
pub mod score;
use serde_json::{Value, json};
use soundcraft_model::{Clip, Session, TrackId, ZoomState};
use soundcraft_model::{Clip, Session, TrackId};
use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
@@ -109,13 +108,9 @@ pub struct Engine {
pub journal: Vec<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
/// Revision counter; bumps on every document change (UI caches key on it).
pub revision: u64,
dirty: bool,
/// The document as last opened or saved, for `is_dirty`.
saved: Arc<Session>,
/// `is_dirty` as of a revision.
dirty_at: Cell<(u64, bool)>,
/// Gesture key for undo coalescing (see `execute_merged`).
merge_key: Option<String>,
/// Messages for the UI status line / agents.
@@ -131,10 +126,8 @@ impl Default for Engine {
impl Engine {
pub fn new(session: Session) -> Self {
install_panic_hook();
let doc = Arc::new(session);
Engine {
saved: Arc::clone(&doc),
doc,
doc: Arc::new(session),
undo: Vec::new(),
redo: Vec::new(),
history_limit: 200,
@@ -145,7 +138,6 @@ impl Engine {
path: None,
revision: 1,
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
messages: Vec::new(),
}
@@ -166,42 +158,23 @@ impl Engine {
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
&mut Arc::make_mut(&mut self.doc).edit.zoom
}
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
self.transport_requests.clear();
self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
self.saved = Arc::clone(&self.doc);
self.undo.clear();
self.redo.clear();
self.revision = self.revision.wrapping_add(1);
self.dirty = false;
}
/// Whether the document changed since it was opened or saved. Selecting, locating and zooming
/// don't count, and undoing back to the saved state makes it clean again.
pub fn is_dirty(&self) -> bool {
if !self.dirty {
return false;
}
let (rev, dirty) = self.dirty_at.get();
if rev == self.revision {
return dirty;
}
let dirty = !same_document(&self.saved, &self.doc);
self.dirty_at.set((self.revision, dirty));
dirty
self.dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
self.saved = Arc::clone(&self.doc);
}
pub fn can_undo(&self) -> bool {
@@ -254,19 +227,16 @@ impl Engine {
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
/// document is restored.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
self.merge_key = None;
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(reason) = (spec.enabled)(self) {
// A call that names its targets (tracks, clips, a range) does not need a selection;
// the command itself validates them.
let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
if !names_targets {
return Err(EngineError::Disabled(id.to_string(), reason));
}
}
if spec.view {
return self.execute_view(spec, params);
}
self.merge_key = None;
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
@@ -274,10 +244,12 @@ impl Engine {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
self.doc = before;
self.revision = rev.wrapping_add(1);
self.dirty = dirty;
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
return Err(EngineError::Internal(id.to_string(), msg));
}
};
match result {
@@ -302,16 +274,6 @@ impl Engine {
}
}
fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result<Value> {
let zoom = self.doc.edit.zoom;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)))
.unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p))));
if result.is_err() && self.doc.edit.zoom != zoom {
Arc::make_mut(&mut self.doc).edit.zoom = zoom;
}
result
}
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
/// with the same `key` collapse into a single undo step.
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<Value> {
@@ -356,19 +318,6 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@@ -380,10 +329,6 @@ fn install_panic_hook() {
});
}
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
mod merge_tests {
use super::*;
@@ -401,27 +346,3 @@ mod merge_tests {
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
}
}
#[cfg(test)]
mod dirty_tests {
use super::*;
#[test]
fn selecting_locating_and_zooming_leave_the_session_clean() {
let mut e = crate::demo::demo_engine();
for (id, p) in [
("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})),
("edit.select_none", json!({})),
("transport.locate", json!({"at": 96_000})),
("view.zoom_in", json!({})),
("view.scroll", json!({"to": 48_000})),
] {
e.execute(id, &p).unwrap();
assert!(!e.is_dirty(), "{id}");
}
e.execute("mix.mute", &json!({"track": "Kick"})).unwrap();
assert!(e.is_dirty());
e.undo();
assert!(!e.is_dirty());
}
}
-3
View File
@@ -45,9 +45,6 @@ const KEYS: &[&str] = &[
"numerator",
"denominator",
"factor",
"x",
"by_px",
"samples_per_px",
"sensitivity",
"rating",
"direction",
+4 -2
View File
@@ -267,8 +267,10 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
match kind {
0x2F => break, // End of Track
0x03 if name.is_none() => {
name = Some(String::from_utf8_lossy(data).into_owned());
0x03 => {
if name.is_none() {
name = Some(String::from_utf8_lossy(data).into_owned());
}
}
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
0x51 => {
-18
View File
@@ -91,24 +91,6 @@ 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() };
+33 -325
View File
@@ -43,22 +43,14 @@
//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session);
//! it calls into the plugins and allocates, so a realtime host runs it only on request.
//!
//! A built-in instrument's parameter values are applied the same way. A sample-playing
//! instrument (the Sampler) is handed the decoded audio of its insert's
//! [`Insert::sample`] source straight from the session's pool, shared and never copied.
//!
//! [`Insert::state`]: soundcraft_model::Insert::state
//! [`Insert::sample`]: soundcraft_model::Insert::sample
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
use soundcraft_dsp::{Plugin, SampleSource, db_to_gain};
use soundcraft_model::{
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
};
use soundcraft_dsp::{Plugin, db_to_gain};
use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::sync::Arc;
/// Default (stereo) main mix width. The actual width is [`main_channels`].
pub const MAIN_CHANNELS: usize = 2;
@@ -195,8 +187,6 @@ struct Strip {
scratch: Vec<f32>,
plugins: Vec<Option<PluginSlot>>,
instrument: Option<PluginSlot>,
/// The pool audio handed to the instrument with `set_sample` (sample-playing instruments).
sample: Option<Arc<SourceAudio>>,
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
held: Vec<u8>,
muted: bool,
@@ -271,7 +261,6 @@ impl Strip {
scratch: Vec::new(),
plugins: Vec::new(),
instrument: None,
sample: None,
held: Vec::new(),
muted: false,
gr: 0.0,
@@ -304,11 +293,6 @@ 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).
@@ -324,8 +308,6 @@ 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.
@@ -381,7 +363,6 @@ impl MixEngine {
synced: (0, 0, 0),
order: Vec::new(),
latency: 0,
track_latency: 0,
input: Vec::new(),
monitor_only: false,
recording: false,
@@ -389,7 +370,6 @@ impl MixEngine {
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
work: Vec::new(),
}
}
@@ -529,9 +509,7 @@ impl MixEngine {
let old = strip.instrument.take();
cx.retire(old);
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
strip.sample = None;
}
sync_sample(strip, want, s);
}
// Instances nobody claimed (the session changed again before they arrived).
let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect();
@@ -590,17 +568,16 @@ 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 = 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));
}
}
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();
for (_, st) in work.iter_mut() {
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -608,30 +585,16 @@ 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, |(i, st)| {
if let Some(t) = s.tracks.get(*i) {
process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
}
});
run_strips(&mut work, |(t, st)| 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 latency has to be read before those outputs are routed,
// or the first block (and a one-block bounce probe) still sees zero.
// 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).
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 (i, st) in work.iter_mut() {
let Some(t) = s.tracks.get(*i) else { continue };
for (t, st) in work.iter_mut() {
let ch = st.buf.len();
st.align.set(lmax_t.saturating_sub(st.latency), ch);
st.align.process(&mut st.buf, frames);
@@ -641,20 +604,15 @@ impl MixEngine {
}
st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch);
}
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.iter_mut() {
self.route_strip(t, st, pos, frames);
}
for (i, st) in work.drain(..) {
if let Some(t) = s.tracks.get(i) {
self.strips.insert(t.id, st);
}
for (t, st) in work {
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(|t| live(t)).filter(|t| is_aux(t)) {
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).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);
@@ -721,24 +679,6 @@ 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);
@@ -886,14 +826,6 @@ fn process_strip(
}
TrackKind::Instrument | TrackKind::Midi => {
if let Some(inst) = &mut strip.instrument {
// #152: push the instrument's stored parameter values into the live plugin
// before processing, the same way inserts do below. Without this, the values
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
// `level = -12 dB` sounds the same as `level = -6 dB`.
if let Some(ins) = &t.instrument {
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
}
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
if strip.buf.len() >= 2 {
inst.plugin.process(&mut strip.buf, frames);
@@ -944,11 +876,8 @@ 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, last);
let mut v1 = volume_db_at(t, pos + frames as i64);
// Trim automation (an offset on top of the volume curve).
if let Some(l) = t
.automation
@@ -957,14 +886,14 @@ fn process_strip(
.filter(|l| !l.points.is_empty())
{
v0 += l.value_at(pos, 0.0);
v1 += l.value_at(last, 0.0);
v1 += l.value_at(pos + frames as i64, 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, last);
v1 += volume_db_at(vca, pos + frames as i64);
if vca.mixer.mute {
strip.muted = true;
}
@@ -973,7 +902,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.saturating_sub(1));
*x *= lerp_gain(g0, g1, i, frames);
}
}
}
@@ -1073,10 +1002,6 @@ fn structure_fingerprint(s: &Session) -> usize {
snd.target.hash(&mut h);
}
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
let sample = t.instrument.as_ref().and_then(|x| x.sample);
sample.hash(&mut h);
// Which decoded audio it is, so new audio under the same id (another session) re-syncs.
sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h);
}
for b in &s.busses {
b.id.hash(&mut h);
@@ -1277,12 +1202,10 @@ 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);
// 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 rel1 = pos.saturating_add(seg_end 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).saturating_sub(1).max(1) as f32;
let n = (seg_end - i) 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 {
@@ -1355,80 +1278,21 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
}
/// Session audio shared with a sample-playing instrument.
struct PoolSample(Arc<SourceAudio>);
impl SampleSource for PoolSample {
fn sample_rate(&self) -> f32 {
self.0.buffer.sample_rate as f32
}
fn num_channels(&self) -> usize {
self.0.buffer.num_channels()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.0.channel(ch)
}
}
/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new
/// sample, a cleared one, or a new instance). Missing media plays nothing.
fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) {
let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id));
let same = match (strip.sample.as_ref(), audio) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if same {
return;
}
if let Some(inst) = strip.instrument.as_mut() {
let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc<dyn SampleSource>);
inst.plugin.set_sample(shared);
}
strip.sample = audio.cloned();
}
/// Record insert latency without processing audio, so this block can compensate for it.
fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
let mut latency = 0usize;
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) {
// Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it
// must not pick up slot A's lanes.
let Ok(slot_u8) = u8::try_from(i) else {
return apply_values(slot, ins, |_, v| v);
};
let slot_u8 = u8::try_from(i).unwrap_or(0);
let automated = t.mixer.automation_mode.reads()
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
apply_values(slot, ins, |k, val| {
if automated
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
&& !l.points.is_empty()
{
return l.value_at(pos, val);
}
val
});
}
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
let val = value(k, *v);
let mut val = *v;
if automated
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
&& !l.points.is_empty()
{
val = l.value_at(pos, val);
}
if val.to_bits() != last.to_bits() {
slot.plugin.set_param(k, val);
*last = val;
@@ -1776,46 +1640,6 @@ 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>> {
@@ -1976,46 +1800,6 @@ mod tests {
use soundcraft_model::{Insert, SourceAudio, SourceId};
use std::sync::Arc;
fn synth_session(level_db: f32) -> Session {
use soundcraft_midi::{Note, Sequence};
let mut s = Session::default();
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
let mut seq = Sequence::default();
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 });
let cid = s.new_clip_id();
let tr = s.track_mut(t).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq));
let mut ins = Insert::new("subtractive_synth");
ins.params.insert("level".into(), level_db);
tr.instrument = Some(ins);
s
}
#[test]
fn stored_instrument_params_reach_the_plugin() {
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
// RMS over the held part of the note, past any parameter smoothing.
let rms = |db: f32| {
let out = render_range(&synth_session(db), Range::new(0, 12_000), 512);
let tail = out[0].get(6_000..).unwrap_or_default();
(tail.iter().map(|x| x * x).sum::<f32>() / tail.len().max(1) as f32).sqrt()
};
let (loud, quiet) = (rms(-6.0), rms(-12.0));
assert!(loud > 0.0, "the synth should sound");
assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
}
#[test]
fn insert_slot_a_automation_does_not_drive_the_instrument() {
let mut s = synth_session(-6.0);
let reference = render_range(&s, Range::new(0, 12_000), 512);
let tr = &mut s.tracks[0];
let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() });
lane.set_point(0, -48.0);
tr.automation.push(lane);
assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference);
}
fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
@@ -2095,20 +1879,6 @@ 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);
@@ -2287,53 +2057,6 @@ 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);
@@ -2362,21 +2085,6 @@ 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();
+3 -31
View File
@@ -1,6 +1,6 @@
//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo.
use crate::{AutomationMode, BusId, SourceId};
use crate::{AutomationMode, BusId};
use std::collections::BTreeMap;
pub const INSERT_SLOTS: usize = 10;
@@ -425,7 +425,7 @@ pub enum Route {
pub struct Insert {
/// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`).
pub plugin: String,
#[serde(default, deserialize_with = "finite_params")]
#[serde(default)]
pub params: BTreeMap<String, f32>,
#[serde(default)]
pub bypass: bool,
@@ -438,9 +438,6 @@ pub struct Insert {
/// state), standard base64. Restored into a new instance before it first processes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// The session audio source a sample-playing instrument (the built-in Sampler) plays.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample: Option<SourceId>,
}
fn yes() -> bool {
@@ -449,15 +446,7 @@ fn yes() -> bool {
impl Insert {
pub fn new(plugin: impl Into<String>) -> Self {
Insert {
plugin: plugin.into(),
params: BTreeMap::new(),
bypass: false,
active: true,
preset: String::from("<factory default>"),
state: None,
sample: None,
}
Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("<factory default>"), state: None }
}
/// The decoded plugin state (`None` when absent or malformed).
@@ -588,13 +577,6 @@ pub(crate) fn finite_or(v: f32, d: f32) -> f32 {
if v.is_finite() { v } else { d }
}
/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so
/// they keep their defaults instead of failing the whole session.
fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f32>, D::Error> {
let params: BTreeMap<String, Option<f64>> = serde::Deserialize::deserialize(d)?;
Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect())
}
/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12).
pub fn fader_pos_to_db(pos: f32) -> f32 {
let p = finite_or(pos, 0.0).clamp(0.0, 1.0);
@@ -642,16 +624,6 @@ mod tests {
assert_eq!(bad.state_bytes(), None);
}
#[test]
fn params_saved_as_null_or_out_of_range_are_dropped() {
let mut i = Insert::new("eq_7band");
i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]);
let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)]));
let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap();
assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)]));
}
#[test]
fn fader_taper_round_trips() {
for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] {
-17
View File
@@ -160,9 +160,6 @@ pub struct EditState {
/// Clips explicitly selected (Grabber selection).
#[serde(default)]
pub selected_clips: Vec<ClipId>,
/// Audio files selected in the Clip List.
#[serde(default)]
pub selected_sources: Vec<SourceId>,
/// Playhead position when stopped.
pub playhead: Samples,
pub grid: GridValue,
@@ -237,7 +234,6 @@ impl Default for EditState {
selection: Range::default(),
selected_tracks: Vec::new(),
selected_clips: Vec::new(),
selected_sources: Vec::new(),
playhead: 0,
grid: GridValue::default(),
nudge: GridValue::Seconds(1.0),
@@ -312,9 +308,6 @@ pub struct Session {
pub midi_originals: std::collections::BTreeMap<u64, soundcraft_midi::Sequence>,
#[serde(skip)]
pub pool: SourcePool,
/// The folder relative source paths point into: where the session was opened or last saved.
#[serde(skip)]
pub folder: Option<std::path::PathBuf>,
}
impl Default for Session {
@@ -348,7 +341,6 @@ impl Session {
comments: String::new(),
midi_originals: std::collections::BTreeMap::new(),
pool: SourcePool::default(),
folder: None,
};
s.edit.pre_roll = sample_rate.samples(2.0);
s.edit.post_roll = sample_rate.samples(2.0);
@@ -407,15 +399,6 @@ impl Session {
self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id)))
}
/// Clips on every playlist, alternates included.
pub fn all_clips(&self) -> impl Iterator<Item = &Clip> {
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips))
}
pub fn clips_using(&self, src: SourceId) -> impl Iterator<Item = &Clip> {
self.all_clips().filter(move |c| c.source() == Some(src))
}
/// End of the last clip on any track (session length).
pub fn content_end(&self) -> Samples {
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
+3 -1
View File
@@ -9,7 +9,6 @@ repository.workspace = true
[dependencies]
soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-dsp.workspace = true
@@ -21,5 +20,8 @@ cpal.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
cpal = { workspace = true, features = ["wasm-bindgen"] }
[dev-dependencies]
soundcraft-audio-io.workspace = true
[lints]
workspace = true
+18 -96
View File
@@ -86,6 +86,7 @@ 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).
@@ -121,6 +122,7 @@ 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,
@@ -149,7 +151,8 @@ impl AudioState {
self.mix.adopt(instances);
// The main format changed (a document edit, not steady state): resize.
let nch = main_channels(&s);
if self.pending.len() != nch {
if self.block.len() != nch {
self.block = vec![vec![0.0; BLOCK]; nch];
self.pending = vec![vec![0.0; BLOCK]; nch];
self.pending_len = 0;
self.read = 0.0;
@@ -168,8 +171,6 @@ impl AudioState {
}
Cmd::Stop => {
self.playing = false;
self.end = None;
self.looped = None;
self.mix.reset();
}
Cmd::Locate(at) => {
@@ -196,8 +197,8 @@ impl AudioState {
}
/// Render the next block at the session rate into `pending`.
fn render_next(&mut self, quantum: usize) {
let mut n = quantum.clamp(1, BLOCK);
fn render_next(&mut self) {
let mut n = BLOCK;
if let Some(l) = self.looped
&& self.pos >= l.end
{
@@ -208,7 +209,6 @@ 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,9 +231,19 @@ impl AudioState {
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.mix.render(&self.session, self.pos, n, &mut self.block);
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 {
@@ -245,10 +255,6 @@ impl AudioState {
}
}
}
if self.playing {
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
}
}
/// Fill an interleaved device buffer.
@@ -266,15 +272,13 @@ 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(quantum);
self.render_next();
if self.pending_len == 0 {
break;
}
@@ -580,10 +584,6 @@ impl Player {
self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open())
}
pub fn any_editor_open(&self) -> bool {
self.side().editors.values().any(|e| e.is_open())
}
/// Shows a parameter change made elsewhere in the plugin's open editor.
pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) {
if let Some(e) = self.side().editors.get_mut(&(track, slot)) {
@@ -622,11 +622,6 @@ 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)
}
@@ -638,21 +633,6 @@ 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 {
@@ -750,21 +730,6 @@ 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());
@@ -783,23 +748,6 @@ 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};
@@ -948,30 +896,4 @@ 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");
}
}
+74 -237
View File
@@ -1,78 +1,22 @@
//! Audio input capture for recording.
//!
//! A [`Recorder`] opens the default input device. While armed, the input callback copies each
//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread
//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves
//! a playable file.
//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared
//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is
//! kept in a small local backlog and appended next time, so nothing blocks the audio thread.
use std::fs::File;
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use soundcraft_model::TrackId;
/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met.
pub type Takes = (Vec<(TrackId, PathBuf)>, Option<String>);
const RING_SECONDS: usize = 4;
/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots
/// are atomics, so it needs no lock and no `unsafe`.
struct CaptureRing {
slots: Box<[AtomicU32]>,
mask: usize,
head: AtomicUsize,
tail: AtomicUsize,
}
impl CaptureRing {
fn new(capacity: usize) -> CaptureRing {
let n = capacity.max(2).next_power_of_two();
CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) }
}
/// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit.
fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize {
let head = self.head.load(Ordering::Relaxed);
let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire))));
for i in 0..n {
if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) {
slot.store(sample(i).to_bits(), Ordering::Relaxed);
}
}
self.head.store(head.wrapping_add(n), Ordering::Release);
n
}
fn pop_all(&self, out: &mut Vec<f32>) {
let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire));
out.extend(
(0..head.wrapping_sub(tail))
.map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))),
);
self.tail.store(head, Ordering::Release);
}
/// Captured audio: planar f32 at `sample_rate`.
#[derive(Debug, Clone, Default)]
pub struct Take {
pub sample_rate: u32,
pub channels: Vec<Vec<f32>>,
}
struct Shared {
armed: AtomicBool,
ring: CaptureRing,
/// Samples lost while the ring was full.
dropped: AtomicUsize,
}
impl Shared {
/// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued
/// as silence before the next block, so the take keeps its timing.
fn capture(&self, data: &[f32], owed: &mut usize) {
*owed -= self.ring.push(*owed, |_| 0.0);
let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 };
*owed = owed.saturating_add(data.len() - kept);
self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed);
}
data: Mutex<Vec<f32>>,
}
pub struct Recorder {
@@ -84,9 +28,12 @@ pub struct Recorder {
pub device_name: String,
pub sample_rate: u32,
pub channels: usize,
writer: Option<std::thread::JoinHandle<Takes>>,
}
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`.
const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 };
impl Recorder {
/// Open the default input device. Errors when there is none (the caller shows a message).
#[cfg(not(target_os = "freebsd"))]
@@ -101,23 +48,29 @@ impl Recorder {
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
}
let config = cfg.config();
let channels = usize::from(config.channels).max(1);
let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) });
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
let sh = Arc::clone(&shared);
let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
let mon = Arc::clone(&monitor);
let mut owed = 0;
let mut backlog: Vec<f32> = Vec::new();
let stream = device
.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
mon.push(data);
if sh.armed.load(Ordering::Acquire) {
sh.capture(data, &mut owed);
} else {
owed = 0;
if !sh.armed.load(Ordering::Relaxed) {
backlog.clear();
return;
}
if let Ok(mut d) = sh.data.try_lock() {
if d.len() < MAX_SAMPLES {
d.extend_from_slice(&backlog);
d.extend_from_slice(data);
}
backlog.clear();
} else if backlog.len() < 1 << 20 {
backlog.extend_from_slice(data);
}
},
|e| {
@@ -129,7 +82,14 @@ impl Recorder {
)
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None })
Ok(Recorder {
monitor,
shared,
_stream: Some(stream),
device_name: name,
sample_rate: config.sample_rate.0,
channels: usize::from(config.channels).max(1),
})
}
#[cfg(target_os = "freebsd")]
@@ -137,174 +97,51 @@ impl Recorder {
Err("recording is not supported on this platform yet".into())
}
/// Create each take's file and start recording into them: a track's take records the given
/// input channels into a new file.
pub fn arm(&mut self, takes: Vec<(TrackId, Vec<usize>, PathBuf)>) -> Result<(), String> {
if self.writer.is_some() {
return Err("already recording".into());
}
let takes =
takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::<Result<Vec<_>, _>>()?;
let ring = &self.shared.ring;
ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release);
self.shared.armed.store(true, Ordering::Release);
let (shared, channels) = (Arc::clone(&self.shared), self.channels);
match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) {
Ok(w) => self.writer = Some(w),
Err(e) => {
self.shared.armed.store(false, Ordering::Release);
return Err(e.to_string());
/// Start capturing (clears any previous take).
pub fn arm(&self) {
self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear();
self.shared.armed.store(true, Ordering::Relaxed);
}
pub fn is_armed(&self) -> bool {
self.shared.armed.load(Ordering::Relaxed)
}
/// Stop capturing and return the take.
pub fn take(&self) -> Take {
self.shared.armed.store(false, Ordering::Relaxed);
let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner));
deinterleave(&data, self.channels, self.sample_rate)
}
/// Samples captured so far (per channel).
pub fn captured_frames(&self) -> usize {
self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.max(1))
}
}
pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take {
let ch = channels.max(1);
let frames = data.len() / ch;
let mut out = vec![Vec::with_capacity(frames); ch];
for frame in data.chunks_exact(ch) {
for (c, v) in frame.iter().enumerate() {
if let Some(o) = out.get_mut(c) {
o.push(*v);
}
}
Ok(())
}
pub fn finish(&mut self) -> Takes {
self.shared.armed.store(false, Ordering::Release);
let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default();
let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1);
let slow =
(lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize));
(takes, problem.or(slow))
}
}
impl Drop for Recorder {
fn drop(&mut self) {
self.finish();
}
}
fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) -> Takes {
let (mut block, mut header_written) = (Vec::new(), Instant::now());
loop {
let stopping = !shared.armed.load(Ordering::Acquire);
// The ring holds interleaved samples and a pop can end mid-frame: write whole frames and
// keep the remainder for the next pop, so channels never shift.
shared.ring.pop_all(&mut block);
let whole = block.len() - block.len() % channels.max(1);
takes.iter_mut().for_each(|t| t.write(&block[..whole], channels));
block.drain(..whole);
if stopping {
break;
}
if header_written.elapsed() >= Duration::from_secs(1) {
header_written = Instant::now();
takes.iter_mut().for_each(TakeWriter::write_header);
}
std::thread::sleep(Duration::from_millis(10));
}
let (mut done, mut problem) = (Vec::new(), None);
for mut t in takes {
t.write_header();
if t.frames == 0 {
let _ = std::fs::remove_file(&t.path);
} else {
done.push((t.id, t.path));
}
problem = problem.or(t.error);
}
(done, problem)
}
/// One take's file. After an error it stops writing; its header keeps covering what was written.
struct TakeWriter {
id: TrackId,
inputs: Vec<usize>,
path: PathBuf,
file: File,
rate: u32,
frames: u64,
error: Option<String>,
}
impl TakeWriter {
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path));
let file = file.map_err(|e| format!("{}: {e}", path.display()))?;
let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None };
take.write_header();
take.error.take().map_or(Ok(take), Err)
}
/// Append this take's channels of `block` (interleaved, `channels` wide).
fn write(&mut self, block: &[f32], channels: usize) {
let channels = channels.max(1);
if self.error.is_some() {
return;
}
let bytes: Vec<u8> = block
.chunks_exact(channels)
.flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes()))
.collect();
match self.file.write_all(&bytes) {
Ok(()) => self.frames += (block.len() / channels) as u64,
Err(e) => self.fail(e),
}
}
fn write_header(&mut self) {
let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) {
Ok(h) => h,
Err(e) => return self.fail(e),
};
let f = &mut self.file;
if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) {
self.fail(e);
}
}
fn fail(&mut self, e: impl std::fmt::Display) {
self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display()));
}
Take { sample_rate, channels: out }
}
#[cfg(test)]
mod tests {
use super::*;
fn decode(path: &PathBuf) -> Vec<Vec<f32>> {
soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels
}
#[test]
fn a_full_ring_owes_the_lost_samples_as_silence() {
let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) };
let (mut owed, mut out) = (0, Vec::new());
shared.capture(&[1.0; 6], &mut owed);
shared.capture(&[2.0; 4], &mut owed);
shared.ring.pop_all(&mut out);
shared.capture(&[3.0; 2], &mut owed);
shared.ring.pop_all(&mut out);
assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat());
assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2));
}
#[test]
fn takes_stream_into_their_files() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap();
let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap();
assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file");
vox.write(&[0.5; 300], 3);
vox.write_header();
vox.write(&[0.25; 150], 3);
assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen");
let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) });
let sh = Arc::clone(&shared);
let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3));
let input: Vec<f32> = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect();
let mut owed = 0;
input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed));
shared.armed.store(false, Ordering::Release);
let (done, problem) = writer.join().unwrap();
let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::<Vec<f32>>();
assert_eq!((done.len(), problem), (2, None));
assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]);
assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]);
let _ = std::fs::remove_dir_all(&dir);
fn deinterleaves() {
let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000);
assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]);
let t = super::deinterleave(&[], 0, 48_000);
assert_eq!(t.channels.len(), 1);
}
}
+5 -26
View File
@@ -7,7 +7,6 @@
//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect`
//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs)
//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}`
//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch)
//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`
//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`
//! - `app.quit`
@@ -92,8 +91,6 @@ fn mods(p: &Value) -> egui::Modifiers {
fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option<Value> {
let p = &req.params;
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let pos = egui::pos2(f("x"), f("y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
Some(match req.method.as_str() {
"engine.execute" | "command" => {
let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or("");
@@ -150,10 +147,11 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
Err(e) => err(e),
},
"ui.move" => {
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
ok(json!({}))
}
"ui.click" => {
let pos = egui::pos2(f("x"), f("y"));
let button = match p.get("button").and_then(Value::as_str) {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
@@ -168,7 +166,9 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
ok(json!({}))
}
"ui.drag" => {
let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y")));
let a = egui::pos2(f("x"), f("y"));
let b = egui::pos2(f("to_x"), f("to_y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
let m = mods(p);
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
@@ -179,27 +179,6 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
ok(json!({}))
}
"ui.scroll" => {
let line = p.get("unit").and_then(Value::as_str) == Some("line");
let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point };
let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32;
let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p));
let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers };
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO)));
app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta)));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO)));
ok(json!({}))
}
"ui.zoom" => {
let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else {
return Some(err("ui.zoom needs a positive factor (> 1 zooms in)"));
};
let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32;
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each)));
ok(json!({}))
}
"ui.key" => {
let name = p.get("key").and_then(Value::as_str).unwrap_or("");
let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) };
+11 -235
View File
@@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
use soundcraft_model::{ClipId, TrackId};
use soundcraft_time::Samples;
#[derive(Debug, Clone)]
@@ -15,7 +15,6 @@ pub enum Dialog {
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
RenameTrack { id: TrackId, name: String },
RenameClip { id: ClipId, name: String },
ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
@@ -24,14 +23,11 @@ pub enum Dialog {
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
Session { frame_rate: String, bit_depth: String },
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
SaveChanges { then: (String, Value) },
}
#[derive(Default)]
pub struct Dialogs {
pub open: Option<Dialog>,
/// Runs once the session has been saved from `SaveChanges`.
after_save: Option<(String, Value)>,
}
impl Dialogs {
@@ -42,7 +38,6 @@ impl Dialogs {
Dialog::Bounce { .. } => "bounce",
Dialog::RenameTrack { .. } => "rename_track",
Dialog::RenameClip { .. } => "rename_clip",
Dialog::ClearClips { .. } => "clear_clips",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
Dialog::TempoChange { .. } => "tempo_change",
@@ -51,7 +46,6 @@ impl Dialogs {
Dialog::Group { .. } => "group",
Dialog::Session { .. } => "session",
Dialog::ScoreSetup { .. } => "score_setup",
Dialog::SaveChanges { .. } => "save_changes",
})
}
@@ -63,23 +57,11 @@ impl Dialogs {
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
}
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
self.open = Some(Dialog::ClearClips { clips, sources, message });
true
}
/// Open the dialog for a command; false when the command has no dialog.
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
self.after_save = None;
let home = default_dir();
let name = e.session().name.clone();
self.open = Some(match id {
"clip.clear" => {
let ed = &e.session().edit;
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
}
"track.new" => {
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
@@ -90,16 +72,10 @@ impl Dialogs {
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
"session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() },
"session.save" if e.path.is_some() => return false,
"session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(),
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: id.into(),
title: "Save Session As".into(),
// The browser has no folders: the name becomes the bundle file's name.
path: if home.is_empty() {
format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION)
} else {
format!("{home}/{name}/{name}.scraft")
},
path: format!("{home}/{name}/{name}.scraft"),
key: "path".into(),
},
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
@@ -124,11 +100,6 @@ impl Dialogs {
let s = soundcraft_engine::score::ScoreSetup::from_session(e.session());
Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names }
}
"clip.rename" => {
let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id));
let Some((_, clip)) = target else { return false };
Dialog::RenameClip { id: clip.id, name: clip.name.clone() }
}
_ => match path_dialog(id, &home, &name) {
Some(d) => d,
None => return false,
@@ -138,32 +109,6 @@ impl Dialogs {
}
}
/// When `id` would close a session with unsaved changes, ask first; true when the question is
/// open instead. A take in progress is stopped first, so it counts as a change.
pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
{
return false;
}
if app.engine.transport.recording {
app.stop_play();
}
if !app.engine.is_dirty() {
return false;
}
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
true
}
/// Runs `id`, unless it asks to save first (`ask_to_save`).
pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value, String> {
if ask_to_save(app, id, &params) {
return Ok(Value::Null);
}
app.run(id, params)
}
/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus
/// Print Score, whose path is optional), with a default file name from the command.
pub fn takes_path(id: &str) -> bool {
@@ -201,41 +146,6 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option<Dialog> {
Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() })
}
fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<String> {
let listed: std::collections::BTreeSet<ClipId> = clips.iter().copied().collect();
let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect();
let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect();
let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") };
let what = match (names.as_slice(), files.as_slice()) {
([], []) => return None,
([name], []) => format!("the clip \"{name}\""),
([], [f]) => format!("the file \"{}\"", f.name),
(c, []) => count(c.len(), "clip"),
([], f) => count(f.len(), "file"),
(c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")),
};
let mut text = format!("Remove {what} from the session?");
let used: Vec<(&str, usize)> =
files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect();
let n: usize = used.iter().map(|u| u.1).sum();
let users = if n == 1 {
"a clip on the timeline, which is removed too.".to_string()
} else {
format!("{n} clips on the timeline, which are removed too.")
};
match used.as_slice() {
[] => {}
[_] if files.len() == 1 => text += &format!(" It is used by {users}"),
u if u.len() == files.len() => text += &format!(" They are used by {users}"),
[(name, _)] => text += &format!(" \"{name}\" is used by {users}"),
many => text += &format!(" {} of the files are used by {users}", many.len()),
}
if !files.is_empty() && files.iter().all(|f| !f.unsaved) {
text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." };
}
Some(text)
}
fn default_dir() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -253,15 +163,12 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut save_first: Option<(String, Value)> = None;
let confirmed = matches!(d, Dialog::SaveChanges { .. });
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewSession { .. } => "New Session",
Dialog::Bounce { .. } => "Bounce Mix",
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::ClearClips { .. } => "Clear",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
Dialog::Fades { .. } => "Fades",
@@ -269,8 +176,6 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Group { .. } => "Create Group",
Dialog::Session { .. } => "Session Setup",
Dialog::ScoreSetup { .. } => "Score Setup",
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
Dialog::SaveChanges { .. } => "Unsaved Changes",
}
.to_string();
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
@@ -368,42 +273,29 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
Dialog::RenameTrack { id, name } => {
name_field(ui, name);
let r = ui.text_edit_singleline(name);
r.request_focus();
if buttons(ui, "OK", enter) {
action = Some(("track.rename".into(), json!({"track": id.0, "name": name})));
}
}
Dialog::RenameClip { id, name } => {
name_field(ui, name);
let r = ui.text_edit_singleline(name);
r.request_focus();
if buttons(ui, "OK", enter) {
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
}
}
Dialog::ClearClips { clips, sources, message } => {
ui.set_max_width(340.0);
ui.label(message.as_str());
if buttons(ui, "Remove", enter) {
action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true})));
}
}
Dialog::PathPrompt { cmd, path, key, .. } => {
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
if let Some(pick) = &app.services.pick_open
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
&& key != "dir"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(cmd, &[])
{
*path = p;
}
if let Some(pick) = &app.services.pick_folder
&& key == "dir"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(path)
{
*path = p;
}
if let Some(pick) = &app.services.pick_save
&& (cmd == "session.save_as" || cmd == "session.save_copy")
&& ui.button("Browse…").clicked()
@@ -411,17 +303,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
{
*path = p;
}
let file = soundcraft_engine::io::session_file(path);
let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file);
if replaces {
ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy()));
}
if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) {
if buttons(ui, "OK", enter) {
let mut v = json!({});
v[key.as_str()] = json!(path);
if replaces {
v["replace"] = json!(true);
}
action = Some((cmd.clone(), v));
}
}
@@ -520,71 +404,18 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
));
}
}
Dialog::SaveChanges { then } => {
let name = &app.engine.session().name;
let revert = then.0 == "session.revert";
if revert || cfg!(target_arch = "wasm32") {
let (question, ok) = if revert {
(format!("Revert “{name}” to its last saved version?"), "Revert")
} else {
(format!("Close “{name}” without saving?"), "Don't Save")
};
ui.label(format!("{question} Your changes will be lost."));
if buttons(ui, ok, false) {
action = Some(then.clone());
}
} else {
ui.label(format!("Save changes to “{name}” before closing?"));
ui.separator();
ui.horizontal(|ui| {
if ui.button("Don't Save").clicked() {
action = Some(then.clone());
}
if buttons(ui, "Save", enter) {
save_first = Some(then.clone());
}
});
}
}
}
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
keep = false;
app.dialogs.after_save = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
}
if let Some(then) = save_first {
keep = false;
if !app.dialogs.open_for_command(&app.engine, "session.save") {
action = Some(("session.save".into(), json!({})));
}
app.dialogs.after_save = Some(then);
}
if let Some((cmd, params)) = &action
&& !confirmed
&& ask_to_save(app, cmd, params)
{
keep = false;
action = None;
}
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
if let Some((cmd, params)) = action {
keep = false;
match app.run(&cmd, params) {
Ok(v) if cmd == "file.export_clips" => {
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
}
Ok(_) => {}
Err(e) => app.ui.status = e,
if let Err(e) = app.run(&cmd, params) {
app.ui.status = e;
}
}
if saving
&& let Some((then, params)) = app.dialogs.after_save.take()
&& !app.engine.is_dirty()
&& let Err(e) = app.run(&then, params)
{
app.ui.status = e;
}
if keep {
app.dialogs.open = Some(d);
}
@@ -605,17 +436,6 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
pressed
}
/// A name field that takes the focus with its text selected, so typing replaces the old name.
fn name_field(ui: &mut egui::Ui, name: &mut String) {
let mut out = egui::TextEdit::singleline(name).show(ui);
if !out.response.has_focus() {
out.response.request_focus();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
out.state.cursor.set_char_range(Some(all));
out.state.store(ui.ctx(), out.response.id);
}
}
/// The command the tempo-change dialog runs on OK.
fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
("event.tempo".into(), json!({"bpm": bpm, "at": at}))
@@ -695,18 +515,6 @@ mod tests {
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
#[test]
fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() {
let mut e = soundcraft_engine::demo::demo_engine();
let mut d = Dialogs::default();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(!d.open_for_command(&e, "clip.rename"));
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap();
assert!(d.open_for_command(&e, "clip.rename"));
assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name));
}
#[test]
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
let (cmd, params) = tempo_change_action(96_000, 133.5);
@@ -714,36 +522,4 @@ mod tests {
assert_eq!(params["at"], 96_000);
assert_eq!(params["bpm"], 133.5);
}
#[test]
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
let mut e = soundcraft_engine::demo::demo_engine();
let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap();
let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats"));
let kick_clips: Vec<ClipId> = e.session().clips_using(kick).map(|c| c.id).collect();
let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id;
let message = |d: &Dialogs| match &d.open {
Some(Dialog::ClearClips { message, .. }) => message.clone(),
other => panic!("{other:?}"),
};
let mut d = Dialogs::default();
assert!(!d.open_for_command(&e, "clip.clear"));
e.execute("edit.select", &json!({"clips": [snare]})).unwrap();
assert!(d.open_for_command(&e, "clip.clear"));
assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?");
e.execute("edit.select", &json!({"sources": [kick]})).unwrap();
assert!(d.open_for_command(&e, "clip.clear"));
assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick]));
assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too.");
assert!(d.open_clear(&e, kick_clips.clone(), vec![kick]));
assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?");
assert!(d.open_clear(&e, kick_clips, vec![kick, hats]));
assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too.");
e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false);
assert!(d.open_clear(&e, vec![], vec![kick, hats]));
assert_eq!(
message(&d),
"Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk."
);
}
}
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -52,16 +52,15 @@ 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);
// 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()) {
if app.extra.applied == Some(want) {
return;
}
// Only touch the zoom once the user has changed it, so platform scaling stays untouched.
+62 -192
View File
@@ -101,11 +101,6 @@ pub struct UiState {
pub configurations: Vec<(String, Value)>,
/// Appearance (`ui.theme`): dark by default; light, a Catppuccin flavour or the system on request.
pub theme: theme::ThemeMode,
/// The main window's size (logical points) and maximized state, and the interface scale, as
/// the desktop app last saw them; it restores them at launch.
pub window_size: [f32; 2],
pub window_maximized: bool,
pub ui_scale: f32,
}
impl Default for UiState {
@@ -158,9 +153,6 @@ impl Default for UiState {
workspace_dir: String::new(),
configurations: Vec::new(),
theme: theme::ThemeMode::Dark,
window_size: [1600.0, 1000.0],
window_maximized: false,
ui_scale: 1.0,
}
}
}
@@ -170,7 +162,6 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
pub pick_folder: Option<Box<dyn Fn(&str) -> Option<String>>>,
}
/// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`).
@@ -178,15 +169,6 @@ pub fn preset_folder_name(plugin: &str) -> String {
plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect()
}
/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger
/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor.
pub fn fit_window_to_screen(ctx: &egui::Context) {
let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size));
if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
}
}
/// Drag-and-drop payload: an audio file (source id) dragged from the Clip List.
#[derive(Debug, Clone, Copy)]
pub struct DragSource(pub u64);
@@ -234,12 +216,10 @@ pub struct SoundApp {
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: Option<std::time::Instant>,
last_autosave: f64,
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,
@@ -267,7 +247,6 @@ 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,
}
@@ -297,11 +276,10 @@ impl SoundApp {
native_menu_bar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: None,
last_autosave: 0.0,
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(),
@@ -324,7 +302,6 @@ 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(),
}
}
@@ -352,9 +329,6 @@ 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) => {
@@ -393,8 +367,6 @@ 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 {
@@ -439,14 +411,17 @@ impl SoundApp {
return;
}
self.ensure_input();
if let Err(err) = self.arm_recorder() {
self.ui.status = format!("Cannot record: {err}");
if self.recorder.is_none() {
self.ui.status = "Cannot record: no audio input device".into();
return;
}
if let Some(p) = &self.player {
p.set_recording(true);
}
let already_playing = self.is_playing();
if let Some(r) = &self.recorder {
r.arm();
}
self.engine.transport.recording = true;
if already_playing {
// Punch in on the fly at the current playhead.
@@ -472,19 +447,6 @@ impl SoundApp {
}
}
/// Arm the input for the record-enabled tracks; a session never saved records into its
/// autosave folder.
fn arm_recorder(&mut self) -> Result<(), String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
r.arm(plan)
}
/// Reads every live third-party plugin's state from the audio engine and stores it on its
/// insert (`mix.insert_state`), so saves and bounces include it.
pub fn capture_plugin_states(&mut self) {
@@ -511,14 +473,15 @@ impl SoundApp {
}
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
/// Timed by the wall clock: egui's stands still while the window is hidden.
fn autosave(&mut self) {
fn autosave(&mut self, now: f64) {
let Some(dir) = self.autosave_dir.clone() else { return };
let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now);
if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording {
if self.last_autosave == 0.0 {
self.last_autosave = now;
}
if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording {
return;
}
self.last_autosave = Some(std::time::Instant::now());
self.last_autosave = now;
self.capture_plugin_states();
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let path = dir.join(&name).join(format!("{name}.scraft"));
@@ -549,12 +512,6 @@ 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() {
@@ -579,26 +536,49 @@ impl SoundApp {
if let Some(p) = &self.player {
p.set_recording(false);
}
let Some(r) = &mut self.recorder else { return };
let (takes, problem) = r.finish();
let tracks = takes.len().max(1);
let Some(r) = &self.recorder else { return };
let take = r.take();
let rate = take.sample_rate;
let sel = self.engine.session().edit.selection;
let looped = self.engine.session().edit.loop_record && !sel.is_empty();
let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) };
let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass);
if let Some(p) = self.punch.take() {
// Keep only the punch range of each new take (merged into the Record undo step).
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
let _ = self.engine.execute_merged(
"edit.trim_to_fill_selection",
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
"record",
);
if self.engine.session().edit.loop_record && !sel.is_empty() {
// Split the capture into one take per loop pass; each pass gets its own playlist.
let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64()))
.unwrap_or(0)
.max(1);
let total = take.channels.first().map_or(0, Vec::len);
let passes = total.div_ceil(pass).max(1);
let mut made = 0;
for k in 0..passes {
let chunk: Vec<Vec<f32>> =
take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect();
if chunk.first().is_none_or(|c| c.len() < pass / 8) {
continue;
}
if made > 0 {
let armed: Vec<u64> = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect();
let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed}));
}
if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() {
made += 1;
}
}
self.ui.status = format!("Loop-recorded {made} take(s)");
return;
}
self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) };
if let Some(p) = problem {
log::warn!("recording: {p}");
self.ui.status = format!("{} ({p})", self.ui.status);
match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) {
Ok(ids) => {
if let Some(p) = self.punch.take() {
// Keep only the punch range of each new take (merged into the Record undo step).
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
let _ = self.engine.execute_merged(
"edit.trim_to_fill_selection",
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
"record",
);
}
self.ui.status = format!("Recorded {} clip(s)", ids.len());
}
Err(e) => self.ui.status = e.to_string(),
}
}
@@ -745,13 +725,7 @@ impl SoundApp {
if self.engine.transport.playing {
self.engine.transport.position = p.position();
}
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;
let snap = p.meters();
for (id, m) in &snap.tracks {
let d = self.meters.entry(*id).or_default();
feed_meter(d, m.peak, m.gain_reduction_db, dt);
@@ -775,7 +749,7 @@ impl SoundApp {
self.stop_play();
}
}
if !self.meters_live() {
if !self.engine.transport.playing {
for d in self.meters.values_mut() {
feed_meter(d, [0.0, 0.0], 0.0, dt);
}
@@ -785,9 +759,6 @@ impl SoundApp {
/// Per-frame logic: control channel, transport, document sync.
pub fn logic(&mut self, ctx: &egui::Context) {
if ctx.current_pass_index() > 0 {
return;
}
// Fonts set now take effect next frame, so draw only from the frame after.
if !self.fonts_ready {
if self.fonts_installed {
@@ -804,7 +775,8 @@ impl SoundApp {
self.theme_applied = Some(mode);
}
let now = ctx.input(|i| i.time);
let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.last_frame = Some(now);
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
control::drain(self, ctx);
self.apply_editor_edits();
@@ -826,17 +798,16 @@ impl SoundApp {
}
}
self.write_automation(ctx);
self.autosave();
self.autosave(now);
if self.recorder.is_none()
&& !self.recorder_failed
&& self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)
{
self.ensure_input();
}
// Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from.
// egui takes its predicted frame time off every delay, so add it back.
let editors = self.player.as_ref().is_some_and(Player::any_editor_open);
ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt));
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
control::collect_screenshots(self, ctx);
}
@@ -902,15 +873,6 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
// Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it.
if self.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
/// UI state as JSON (control channel `ui.inspect`).
@@ -998,20 +960,6 @@ mod tests {
assert_eq!(app.engine.transport.position, 0);
}
#[test]
fn a_hidden_window_still_autosaves() {
let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id()));
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.autosave_dir = Some(dir.clone());
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() };
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() };
let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx));
assert!(app.latest_autosave().is_some());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -1031,16 +979,6 @@ 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
@@ -1059,72 +997,4 @@ mod tests {
fn automation_write_fires_after_loop_wrap() {
assert!(automation_step_due(0, 1_000_000, 960));
}
#[test]
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));
}
#[test]
fn a_window_bigger_than_its_screen_opens_maximized() {
let maximizes = |w: f32, frames: usize| {
let ctx = egui::Context::default();
let info = egui::ViewportInfo {
inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))),
monitor_size: Some(egui::vec2(1728.0, 1117.0)),
..Default::default()
};
let mut maximized = false;
for _ in 0..frames {
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx()));
out.textures_delta.clear();
maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true));
}
maximized
};
assert!(maximizes(2400.0, 1));
assert!(!maximizes(1600.0, 1));
assert!(!maximizes(2400.0, 2));
}
#[test]
fn ordinary_play_after_stop_is_full_speed() {
use serde_json::json;
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");
}
}
+6 -46
View File
@@ -145,14 +145,12 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
/// Commands that open a dialog when invoked from a menu (programmatic calls never do).
const DIALOG_COMMANDS: &[&str] = &[
"clip.clear",
"track.new",
"file.bounce_mix",
"session.new",
"file.import_audio",
"file.import_midi",
"session.open",
"session.save",
"session.save_as",
"session.save_copy",
"track.group",
@@ -163,7 +161,6 @@ const DIALOG_COMMANDS: &[&str] = &[
"event.quantize",
"event.transpose",
"edit.strip_silence",
"clip.rename",
];
/// Shared menu hierarchy for egui and native desktop hosts.
@@ -224,7 +221,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
}
ui.separator();
if ui.button("Quit").clicked() {
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
let _ = app.run("app.quit", json!({}));
}
});
let extra = ui_aliases();
@@ -264,9 +261,8 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
}
let id = command_for_path(&n.path, extra);
let (enabled, checked) = item_state(app, &n.path, id.as_deref());
let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone());
let label = if checked { format!("✔ {label}") } else { label };
let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
if ui.add_enabled(enabled, btn).clicked()
&& let Some(id) = id
@@ -397,21 +393,6 @@ const MENU_WINDOWS: &[(&str, &str)] = &[
("Event > MIDI Real-Time Properties", "window.rtp"),
];
/// The shortcut of an engine or window command.
pub fn shortcut_for(id: &str) -> Option<&'static str> {
soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut)
}
/// Undo and Redo name the step they would undo or redo; other items keep their catalog label.
pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
let e = &app.engine;
match id? {
"edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))),
"edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))),
_ => None,
}
}
/// Menu-style invocation: may open a dialog.
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
@@ -438,13 +419,12 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
return;
}
let params = params_for(path, id);
let _ = crate::dialogs::run_or_ask(app, id, params);
let _ = app.run(id, params);
}
/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its
/// dialog. Returns true when handled.
/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled.
pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool {
(wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({}))
wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)
}
/// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path.
@@ -658,24 +638,4 @@ mod tests {
invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
assert_eq!(app.dialogs.open_name(), Some("bounce"));
}
#[test]
fn save_asks_for_a_path_until_the_session_has_one() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as"));
app.dialogs.open = None;
assert!(invoke_shortcut_dialog(&mut app, "session.save"));
app.dialogs.open = None;
let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id()));
let path = dir.join("Song.scraft");
app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap();
app.run("track.new", json!({})).unwrap();
assert!(!invoke_shortcut_dialog(&mut app, "session.save"));
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(app.dialogs.open.is_none());
assert!(!app.engine.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
}
+13 -12
View File
@@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0;
pub struct MidiEditorState {
pub selected: Vec<usize>,
pub drag: Option<NoteDrag>,
pub scroll: f32,
pub top_pitch: i32,
pub clip: Option<ClipId>,
/// Rubber-band selection start (screen position).
pub band: Option<egui::Pos2>,
@@ -127,27 +127,28 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
// Vertical range: centre on the notes the first time.
let rows = (roll.height() / ROW_H).floor() as i32;
if app.midi.clip != Some(cid) {
if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) {
let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72);
let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48);
app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H;
let mid = (hi + lo) / 2;
app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127);
app.midi.clip = Some(cid);
}
if ui.rect_contains_pointer(roll) {
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
let dy = ui.input(|i| i.smooth_scroll_delta.y);
if dy.abs() > 0.5 {
app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127);
}
}
app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0));
let scroll = app.midi.scroll;
let top = app.midi.top_pitch;
let px_per_tick = roll.width() / len_ticks as f32;
let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick;
let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll;
let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32;
let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H;
let painter = ui.painter().with_clip_rect(roll.union(keys));
// Background rows and keys.
let top = pitch_at(roll.min.y);
for r in 0..=rows + 1 {
for r in 0..=rows {
let pitch = top - r;
let y = y_of(pitch);
let y = roll.min.y + r as f32 * ROW_H;
let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H));
painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) });
let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0));
@@ -287,7 +288,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Audition on the instrument would go here (needs a live MIDI input path).
}
None => {
let pitch = pitch_at(p.y).clamp(0, 127);
let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127);
let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks;
let _ = app.run(
"midi.note_add",
+42 -304
View File
@@ -22,7 +22,11 @@ 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.meters_live()).map(|p| p.meters());
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());
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
@@ -221,12 +225,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let input =
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) };
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(in_r));
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text);
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true));
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, true));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(out_r));
let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text);
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false));
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, false));
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
@@ -303,10 +307,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let _ = resp;
let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0));
ui.painter().rect_filled(pr, 1.0, t.counter_bg);
let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan);
if let Some(v) = entered {
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
}
ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text);
}
y += ks + 20.0;
}
@@ -371,17 +372,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
// Volume readout and name.
let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));
ui.painter().rect_filled(vr, 1.0, t.counter_bg);
let (_, entered) = editable_readout(
ui,
vr,
ui.id().with(("volume-value", id.0)),
&db_text(track.mixer.volume_db),
&format!("{:.1}", track.mixer.volume_db),
parse_db,
);
if let Some(db) = entered {
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
}
ui.painter().text(vr.center(), Align2::CENTER_CENTER, db_text(track.mixer.volume_db), regular(11.0), t.counter_text);
let kind = match track.kind {
TrackKind::Audio => "audio",
TrackKind::Aux => "aux",
@@ -404,7 +395,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark);
let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click());
if nresp.clicked() {
crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video);
let _ = app.run("edit.select", json!({"tracks": [id.0]}));
}
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
@@ -423,12 +414,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() { t.insert_on } else { t.slot_bg };
let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg };
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
Stroke::new(1.0, t.slot_border),
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
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);
@@ -620,11 +611,9 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
let t = Tokens::current();
let id = track.id;
let snd = track.mixer.sends.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag());
let fill = if snd.is_some() { t.send_on } else { t.slot_bg };
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside);
let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max);
let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y));
let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click());
match &snd {
Some(s) => {
let name = route_name(app, &s.target);
@@ -635,257 +624,44 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
0.0,
t.counter_text,
);
ui.painter().with_clip_rect(route_r).text(
pos2(r.min.x + 4.0, r.center().y - 1.0),
Align2::LEFT_CENTER,
ui.painter().with_clip_rect(r).text(
r.center() - vec2(0.0, 1.0),
Align2::CENTER_CENTER,
name,
regular(9.0),
regular(10.0),
if s.mute { t.mute } else { t.text },
);
let (level_resp, entered) = editable_readout(
ui,
level_r,
ui.id().with(("send-value", id.0, slot)),
&format!("{} dB", db_text(s.level_db)),
&format!("{:.1}", s.level_db),
parse_db,
);
if let Some(db) = entered {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
}
if level_resp.as_ref().is_some_and(egui::Response::dragged) {
// Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so
// applying it to the already-updated value made trackpad adjustment accelerate.
let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));
let db_per_point = if fine { 0.02 } else { 0.15 };
let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0);
if resp.dragged() {
let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0);
let _ =
app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
}
if level_resp.as_ref().is_some_and(egui::Response::double_clicked) {
if resp.double_clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
}
if let Some(resp) = level_resp {
let _ = resp.clone().on_hover_text(format!(
"Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options",
(b'A' + slot as u8) as char,
db_text(s.level_db)
));
resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));
}
let _ = resp.clone().on_hover_text(format!("Send {}: {} dB (drag to change)", (b'A' + slot as u8) as char, db_text(s.level_db)));
}
None => {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
}
}
if let Some(send) = &snd {
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(250.0);
ui.horizontal(|ui| {
ui.strong(format!("Send {}", (b'A' + slot as u8) as char));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(route_name(app, &send.target)).color(Tokens::current().counter_text));
});
});
ui.label(egui::RichText::new("DESTINATION").size(8.0).color(Tokens::current().text_dim));
send_route_menu(app, ui, id, slot, Some(&send.target));
ui.separator();
if ui.button("Remove send").clicked() {
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
ui.close();
}
});
} else {
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(250.0);
ui.strong(format!("Send {} · Choose destination", (b'A' + slot as u8) as char));
send_route_menu(app, ui, id, slot, None);
});
}
}
/// Secondary controls live behind the amount readout, separate from the bus selector like Logic.
fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) {
ui.set_min_width(230.0);
ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target)));
let mut db = send.level_db.max(-60.0);
if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() {
let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
}
send_level_scale(ui, send.level_db);
ui.horizontal(|ui| {
for (label, delta) in [("−3", -3.0), ("−1", -1.0), ("+1", 1.0), ("+3", 3.0)] {
if ui.small_button(label).clicked() {
let db = (send.level_db.max(-60.0) + delta).clamp(-60.0, 12.0);
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
let popup = if snd.is_none() { egui::Popup::menu(&resp) } else { egui::Popup::context_menu(&resp) };
popup.show(|ui| {
if snd.is_some() && ui.button("no send").clicked() {
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
}
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
for b in busses {
if ui.button(&b).clicked() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b}));
}
}
if ui.small_button("0 dB").clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
}
if ui.small_button("−∞").clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": -144.0}));
ui.separator();
if ui.button("new bus...").clicked() {
let n = app.engine.session().busses.len() + 1;
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
}
});
let mut pan = send.pan;
if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() {
let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0));
}
ui.horizontal(|ui| {
let mut mute = send.mute;
if ui.checkbox(&mut mute, "Mute").changed() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": mute}));
}
let mut pre = send.pre_fader;
if ui.checkbox(&mut pre, "Pre-fader").changed() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": pre}));
}
});
if let Route::Bus(bus) = send.target
&& let Some(aux) =
app.engine.session().tracks.iter().find(|t| t.kind == TrackKind::Aux && t.mixer.input == Route::Bus(bus)).map(|t| (t.id, t.name.clone()))
&& ui.button("Rename bus…").clicked()
{
app.dialogs.open_rename_track(aux.0, &aux.1);
ui.close();
}
}
/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away
/// commits a valid value; Escape cancels. The interaction response remains available for dragging.
fn editable_readout(
ui: &mut Ui,
r: Rect,
id: egui::Id,
display: &str,
edit_initial: &str,
parse: fn(&str) -> Option<f32>,
) -> (Option<egui::Response>, Option<f32>) {
let t = Tokens::current();
let active_id = id.with("active");
let text_id = id.with("text");
let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false);
let mut started = false;
let mut response = None;
if !active {
let resp = ui.interact(r, id, Sense::click_and_drag());
if resp.clicked() && !resp.double_clicked() {
active = true;
started = true;
ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string()));
} else {
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, display, regular(9.0), t.counter_text);
}
response = Some(resp);
}
let mut value = None;
if active {
let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string());
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r));
child.visuals_mut().override_text_color = Some(t.counter_text);
let edit = egui::TextEdit::singleline(&mut text)
.id(id.with("editor"))
.frame(egui::Frame::NONE)
.horizontal_align(egui::Align::Center)
.font(regular(9.0))
.desired_width(r.width());
let edit_resp = child.add_sized(r.size(), edit);
if started {
edit_resp.request_focus();
}
let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
if escape {
active = false;
} else if enter || edit_resp.lost_focus() {
if let Some(parsed) = parse(&text) {
value = Some(parsed);
active = false;
} else if edit_resp.lost_focus() {
active = false;
}
}
ui.data_mut(|d| d.insert_temp(text_id, text));
}
ui.data_mut(|d| d.insert_temp(active_id, active));
(response, value)
}
fn parse_db(text: &str) -> Option<f32> {
let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', ".");
if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") {
return Some(-144.0);
}
cleaned.trim().parse::<f32>().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0))
}
fn parse_pan(text: &str) -> Option<f32> {
let cleaned = text.trim().to_ascii_lowercase().replace(',', ".");
let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) {
(-1.0, v)
} else if let Some(v) = cleaned.strip_suffix('>').or_else(|| cleaned.strip_prefix('r')) {
(1.0, v)
} else {
(1.0, cleaned.as_str())
};
let raw = number.trim().parse::<f32>().ok()? * sign;
let value = if number.contains('.') && raw.abs() <= 1.0 { raw } else { raw / 100.0 };
value.is_finite().then_some(value.clamp(-1.0, 1.0))
}
/// A compact calibrated scale for a send. The marks use the same taper as the strip's level bar.
fn send_level_scale(ui: &mut Ui, db: f32) {
let t = Tokens::current();
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover());
let line_y = r.min.y + 4.0;
ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border));
for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() {
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark);
ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim));
let align = if i == 0 {
Align2::LEFT_TOP
} else if i == 5 {
Align2::RIGHT_TOP
} else {
Align2::CENTER_TOP
};
let label = if mark > 0.0 { format!("+{mark:.0}") } else { format!("{mark:.0}") };
ui.painter().text(pos2(x, line_y + 5.0), align, label, regular(8.0), t.text_dim);
}
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(db);
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text));
}
fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) {
let busses = app.engine.session().busses.clone();
let search_id = egui::Id::new(("send-bus-search", id.0, slot));
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…"));
ui.data_mut(|d| d.insert_temp(search_id, search.clone()));
let needle = search.trim().to_ascii_lowercase();
let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows(
ui,
22.0,
visible.len(),
|ui, rows| {
for bus in &visible[rows] {
let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id);
if ui.selectable_label(selected, &bus.name).clicked() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name}));
ui.close();
}
}
},
);
ui.separator();
if ui.button("new bus...").clicked() {
let mut n = busses.len() + 1;
while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
n += 1;
}
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
ui.close();
}
}
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
@@ -893,67 +669,29 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
let cmd = if input { "track.input" } else { "track.output" };
if ui.button(if input { "no input" } else { "no output" }).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
ui.close();
}
let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
if !input && ui.button(main_name).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
ui.close();
}
if input {
for i in 1..=8 {
if ui.button(format!("In {i}")).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
ui.close();
}
}
}
ui.separator();
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
ui.strong("Bus");
let search_id = egui::Id::new(("route-bus-search", id.0, input));
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…"));
ui.data_mut(|d| d.insert_temp(search_id, search.clone()));
let needle = search.trim().to_ascii_lowercase();
egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| {
for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) {
ui.menu_button("bus", |ui| {
for b in &busses {
if ui.button(b).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: b}));
ui.close();
}
}
});
if ui.button("new bus...").clicked() {
let mut n = busses.len() + 1;
while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
n += 1;
if ui.button("new bus...").clicked() {
let n = busses.len() + 1;
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
}
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
ui.close();
}
}
#[cfg(test)]
mod tests {
use super::{parse_db, parse_pan};
#[test]
fn typed_db_accepts_console_notation_and_clamps() {
assert_eq!(parse_db("-6 dB"), Some(-6.0));
assert_eq!(parse_db("+3,5"), Some(3.5));
assert_eq!(parse_db("off"), Some(-144.0));
assert_eq!(parse_db("99"), Some(12.0));
assert_eq!(parse_db("loud"), None);
}
#[test]
fn typed_pan_accepts_percent_console_and_fractional_values() {
assert_eq!(parse_pan("-50"), Some(-0.5));
assert_eq!(parse_pan("<35"), Some(-0.35));
assert_eq!(parse_pan("35>"), Some(0.35));
assert_eq!(parse_pan("R25"), Some(0.25));
assert_eq!(parse_pan("0.5"), Some(0.5));
assert_eq!(parse_pan("center"), None);
}
});
}
+56 -63
View File
@@ -4,7 +4,6 @@ use crate::SoundApp;
use crate::theme::{Tokens, bold, mono, regular, rgb};
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{ClipId, SourceId};
use soundcraft_time::format_position;
fn panel_header(ui: &mut Ui, title: &str) {
@@ -38,7 +37,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
if resp.clicked() {
crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true);
let _ = app.run("edit.select", json!({"tracks": [id]}));
}
}
});
@@ -101,58 +100,51 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
panel_header(ui, "CLIPS");
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
let s = app.engine.session();
let mut v = Vec::new();
let mut v: Vec<(Option<u64>, String, bool, [u8; 3])> = Vec::new();
// Whole files first (bold), then clips on tracks.
for src in &s.sources {
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150]));
}
for tr in &s.tracks {
for c in tr.clips() {
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color)));
}
}
v
};
let selected: Vec<u64> = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect();
let fkey = egui::Id::new("clip_filter");
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
let needle = filter.to_lowercase();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
for (id, name, whole, color, selected) in items {
for (id, name, whole, color) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
resp.context_menu(|ui| {
if whole {
if whole && let Some(src) = id {
resp.context_menu(|ui| {
if ui.button("Place on New Track").clicked() {
let _ = app.run("clip.place_source", json!({"source": id}));
let _ = app.run("clip.place_source", json!({"source": src}));
ui.close();
}
let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0);
if let Some(t) = sel_track
&& ui.button("Place on Selected Track").clicked()
{
let _ = app.run("clip.place_source", json!({"source": id, "track": t}));
let _ = app.run("clip.place_source", json!({"source": src, "track": t}));
ui.close();
}
ui.separator();
}
if ui.button("Clear…").clicked() {
let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) };
app.dialogs.open_clear(&app.engine, clips, sources);
ui.close();
}
});
if whole {
});
// Whole files drag onto tracks.
resp.dnd_set_drag_payload(crate::DragSource(id));
resp.dnd_set_drag_payload(crate::DragSource(src));
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
if selected {
if !whole && id.is_some_and(|i| selected.contains(&i)) {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color));
@@ -163,8 +155,11 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
if whole { bold(11.0) } else { regular(11.0) },
t.text,
);
if resp.clicked() {
let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) });
if resp.clicked()
&& !whole
&& let Some(i) = id
{
let _ = app.run("edit.select", json!({"clips": [i]}));
}
}
});
@@ -412,46 +407,44 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
if info.id == "eq_7band" || info.id == "eq_1band" {
eq_curve(ui, info.id, &ins);
}
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.insert_param",
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
&format!("param:{}:{slot}:{}", tid.0, p.id),
);
if let Some(pl) = &app.player {
// Keep the plugin's own editor in step.
pl.editor_set_param(tid, slot, p.id, x);
}
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.insert_param",
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
&format!("param:{}:{slot}:{}", tid.0, p.id),
);
if let Some(pl) = &app.player {
// Keep the plugin's own editor in step.
pl.editor_set_param(tid, slot, p.id, x);
}
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
});
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
});
});
if open {
+7 -24
View File
@@ -55,8 +55,6 @@ 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;
}
@@ -75,26 +73,18 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
if fixed(app, key, mods, mac) {
continue;
}
if let Some(id) = command_for(key, mods, mac) {
let hit =
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)));
if let Some(c) = hit {
let id = c.id;
if crate::menus::invoke_shortcut_dialog(app, id) {
continue;
}
let _ = app.run(id, json!({}));
} else if let Some(c) =
crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
{
let _ = app.run(c.0, json!({}));
}
}
}
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
soundcraft_engine::command_specs()
.iter()
.find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
.map(|c| c.id)
}
/// Keys with context-dependent meaning. Returns true when handled.
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
@@ -112,7 +102,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("window.search", json!({}));
true
}
Key::Equals if m.command && !(mac && m.ctrl) => {
Key::Equals if m.command => {
let _ = app.run("window.toggle_mix_edit", json!({}));
true
}
@@ -292,8 +282,8 @@ mod label_tests {
#[cfg(test)]
mod tests {
use super::{command_for, mods_match, parse, search_chord};
use egui::{Key, Modifiers};
use super::{mods_match, parse, search_chord};
use egui::Modifiers;
/// Ctrl held on Windows or Linux, as egui reports it.
const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
@@ -323,11 +313,4 @@ mod tests {
assert!(!search_chord(MAC_CMD, true));
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
}
#[test]
fn shift_cmd_k_prompts_for_a_folder_to_export_clips() {
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips"));
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
assert!(crate::dialogs::takes_path("file.export_clips"));
}
}
+1 -1
View File
@@ -409,7 +409,7 @@ fn catppuccin(f: Flavor) -> Tokens {
border_light: p.surface1,
text: p.text,
text_dim: p.subtext0,
text_dark: if dark { p.crust } else { p.base },
text_dark: if dark { p.crust } else { p.text },
header_text: p.subtext1,
counter_bg: p.crust,
counter_text: p.green,
+5 -8
View File
@@ -350,7 +350,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
if is_dir {
app.ui.workspace_dir = path;
} else if name.ends_with(".scraft") {
let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path}));
let _ = app.run("session.open", json!({"path": path}));
} else if name.ends_with(".mid") || name.ends_with(".midi") {
let _ = app.run("file.import_midi", json!({"path": path}));
} else {
@@ -493,13 +493,10 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › ")));
let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › ")));
for (shortcut, label, menu) in engine.chain(windows) {
let Some(shortcut) = shortcut else { continue };
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
ui.label(label);
ui.label(egui::RichText::new(menu).small());
for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) {
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0)));
ui.label(c.label);
ui.label(egui::RichText::new(c.menu.join(" › ")).small());
ui.end_row();
}
});
-47
View File
@@ -1,47 +0,0 @@
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);
}
-15
View File
@@ -1,15 +0,0 @@
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
#[test]
fn undo_and_redo_name_the_step_they_would_take() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let label = |app: &SoundApp, id| menus::live_label(app, Some(id));
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Can't Undo"));
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Undo Mute"));
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Can't Redo"));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Redo Mute"));
assert_eq!(label(&app, "edit.copy"), None);
}
-28
View File
@@ -1,28 +0,0 @@
use soundcraft_ui_egui::{Services, SoundApp};
use std::time::Duration;
fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
app.logic(ui.ctx());
if shown {
app.ui(ui);
}
});
out.textures_delta.clear();
out.viewport_output[&egui::ViewportId::ROOT].repaint_delay
}
#[test]
fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
for _ in 0..3 {
pass(&ctx, &mut app, true);
}
assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO);
app.run("transport.play", serde_json::json!({})).unwrap();
// Playing repaints at the meter rate (30 Hz in the Edit window, #207).
assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34));
pass(&ctx, &mut app, false);
assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3);
}
@@ -1,172 +0,0 @@
use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2};
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode};
#[derive(Debug)]
struct Slot {
rect: Rect,
fill: Color32,
text: Color32,
border: Color32,
}
// Read the rectangle and foreground emitted by the real Mix drawing path, rather than
// testing a palette token that a custom-painted control might never use.
fn slot(output: &FullOutput, label: &str) -> Slot {
let text = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.job.text == label => Some(text),
_ => None,
})
.unwrap_or_else(|| panic!("missing painted label {label}"));
let center = text.visual_bounding_rect().center();
let rect = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect),
_ => None,
})
.unwrap_or_else(|| panic!("missing painted slot for {label}"));
let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color);
assert_eq!(text.opacity_factor, 1.0);
assert_eq!(foreground.a(), 255);
assert_eq!(rect.fill.a(), 255);
Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color }
}
fn contrast(a: Color32, b: Color32) -> f64 {
let luminance = |color: Color32| {
let linear = |channel: u8| {
let s = f64::from(channel) / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
};
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
#[test]
fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
assert_eq!(app.ui.theme, ThemeMode::Dark);
// Keep demo audio loaded for the preservation check while isolating the new strips.
for track in &mut app.engine.session_mut().tracks {
track.hidden = true;
}
app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap();
let instrument = app.engine.session().edit.selected_tracks[0];
assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth");
app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap();
// An unassigned instrument is a supported model state; track.new normally supplies a synth.
let empty = app.engine.session().edit.selected_tracks[0];
app.engine.session_mut().track_mut(empty).unwrap().instrument = None;
app.run("window.mix", json!({})).unwrap();
app.run("view.mix_section", json!({"section": "minimal"})).unwrap();
for section in ["instruments", "inserts_ae"] {
app.run("view.mix_section", json!({"section": section})).unwrap();
}
let session = app.engine.session().clone();
assert!(!session.pool.is_empty(), "the fixture must include decoded audio");
let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from);
let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0);
if proof_dir.is_some() {
builder = builder.wgpu();
}
let mut harness = builder.build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
let mut failures = Vec::new();
for (name, mode, system, light) in [
("light", ThemeMode::Light, Some(egui::Theme::Dark), true),
("dark", ThemeMode::Dark, Some(egui::Theme::Light), false),
("system-light", ThemeMode::System, Some(egui::Theme::Light), true),
("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false),
] {
harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap();
harness.input_mut().system_theme = system;
harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0)));
for _ in 0..4 {
harness.step();
}
let ordinary = slot(harness.output(), "SubSynth");
let insert = slot(harness.output(), "EQ7");
let empty = slot(harness.output(), "no instrument");
let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK };
assert_eq!(insert.fill, palette.insert_on);
assert_eq!(empty.fill, palette.slot_bg);
assert_eq!(ordinary.text, palette.text);
if ordinary.fill != insert.fill || ordinary.border != insert.border {
failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}"));
}
for hovered in [false, true] {
if hovered {
harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center()));
harness.step();
harness.step();
}
let painted = slot(harness.output(), "SubSynth");
let ratio = contrast(painted.text, painted.fill);
println!(
"{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}",
painted.text.to_array(),
painted.fill.to_array(),
painted.rect
);
if ratio < 4.5 {
failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1"));
}
if !light {
let original = Color32::from_rgb(52, 62, 80);
assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original });
assert_eq!(painted.border, Color32::from_rgb(16, 16, 16));
}
if let Some(dir) = &proof_dir {
harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap();
}
}
assert_eq!(harness.state().ui.theme, mode);
assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
// Exercise the same painted control's ordinary click/picker path after palette changes.
let at = slot(harness.output(), "SubSynth").rect.center();
for pressed in [true, false] {
harness.input_mut().events.push(Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
});
harness.step();
}
harness.step(); // Let the popup finish its initial sizing pass.
harness.get_by_label_contains("Built-in").hover();
harness.step();
harness.step();
harness.get_by_label("Drum Synth").click();
harness.step();
harness.step();
let app = harness.state();
let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap();
assert_eq!(replacement.plugin, "drum_synth");
for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params {
assert_eq!(replacement.params.get(param.id), Some(&param.default));
}
assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks);
assert_eq!(app.engine.session().pool, session.pool);
assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker");
harness.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(harness.state().engine.session(), &session);
}
-19
View File
@@ -1,19 +0,0 @@
use egui_kittest::Harness;
use soundcraft_ui_egui::{Services, SoundApp};
#[test]
fn a_long_plugin_window_stays_on_a_short_screen() {
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.ui.plugin_windows = vec![(kick, 0)];
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 500.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(4);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap();
assert!(window.bottom() <= 500.0, "{window:?}");
}
-30
View File
@@ -1,30 +0,0 @@
use egui::{Event, Key, Modifiers, vec2};
use egui_kittest::Harness;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
#[test]
fn typing_in_the_rename_clip_dialog_replaces_the_old_name() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].clone();
app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap();
menus::invoke_menu(&mut app, "clip.rename", "Clip > Rename...");
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..3 {
h.step();
}
h.input_mut().events.push(Event::Text("Intro".into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
let session = h.state().engine.session();
let renamed = session.tracks.iter().flat_map(|t| t.clips()).find(|c| c.id == clip.id).map(|c| c.name.clone());
assert_eq!(renamed.as_deref(), Some("Intro"));
}
-38
View File
@@ -1,38 +0,0 @@
//! Save As over another session asks first, and Enter alone never replaces it.
use egui::vec2;
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog};
#[test]
fn saving_over_another_session_takes_the_replace_button() {
let dir = std::env::temp_dir().join(format!("soundcraft-ui-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Song.scraft");
soundcraft_engine::Engine::default().execute("session.save_as", &json!({"path": path})).unwrap();
let other = std::fs::read(&path).unwrap();
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.dialogs.open = Some(Dialog::PathPrompt {
cmd: "session.save_as".into(),
title: "Save Session As".into(),
path: path.to_string_lossy().into(),
key: "path".into(),
});
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(3);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(std::fs::read(&path).unwrap(), other);
h.get_by_label("Replace").click();
h.run_steps(3);
assert_ne!(std::fs::read(&path).unwrap(), other);
assert_eq!(h.state().engine.path, Some(path.to_string_lossy().into_owned()));
let _ = std::fs::remove_dir_all(&dir);
}
-97
View File
@@ -1,97 +0,0 @@
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp, menus};
fn harness() -> Harness<'static, SoundApp> {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
)
}
fn steps(h: &mut Harness<'_, SoundApp>) {
for _ in 0..4 {
h.step();
}
}
fn click(h: &mut Harness<'_, SoundApp>, label: &str) {
h.get_by_label(label).click();
steps(h);
}
fn close_session(h: &mut Harness<'_, SoundApp>) {
menus::invoke_menu(h.state_mut(), "session.close", "File > Close Session");
steps(h);
}
fn mute_kick(h: &mut Harness<'_, SoundApp>) {
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
}
#[test]
fn closing_a_modified_session_asks_first() {
let mut h = harness();
let tracks = h.state().engine.session().tracks.len();
h.state_mut().run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})).unwrap();
close_session(&mut h);
assert!(h.state().dialogs.open.is_none(), "a selection alone is not a change");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
mute_kick(&mut h);
close_session(&mut h);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
click(&mut h, "Cancel");
assert!(h.state().dialogs.open.is_none());
assert_eq!(h.state().engine.session().tracks.len(), tracks);
close_session(&mut h);
click(&mut h, "Don't Save");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
mute_kick(&mut h);
menus::invoke_menu(h.state_mut(), "app.quit", "");
steps(&mut h);
assert!(!h.state().quit_requested);
click(&mut h, "Don't Save");
assert!(h.state().quit_requested);
}
#[test]
fn save_in_the_question_saves_before_closing() {
let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut h = harness();
mute_kick(&mut h);
close_session(&mut h);
click(&mut h, "Save");
match &mut h.state_mut().dialogs.open {
Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path),
other => panic!("expected Save As, got {other:?}"),
}
steps(&mut h);
click(&mut h, "OK");
assert!(h.state().engine.session().tracks.is_empty());
h.state_mut().run("session.open", json!({"path": path})).unwrap();
assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
close_session(&mut h);
click(&mut h, "Save");
assert!(h.state().dialogs.open.is_none());
assert!(h.state().engine.session().tracks.is_empty());
h.state_mut().run("session.open", json!({"path": path})).unwrap();
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
let _ = std::fs::remove_dir_all(&dir);
}
+1 -21
View File
@@ -1,7 +1,7 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use serde_json::json;
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
use soundcraft_ui_egui::{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,23 +24,3 @@ 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:?}");
}
}
-95
View File
@@ -1,95 +0,0 @@
use egui::{Pos2, RawInput, Rect, pos2, vec2};
use serde_json::{Value, json};
use soundcraft_time::to_samples;
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
use std::sync::mpsc::{Sender, channel};
struct Rig {
ctx: egui::Context,
app: SoundApp,
tx: Sender<ControlRequest>,
width: f32,
}
impl Rig {
fn new(width: f32) -> Rig {
let (tx, rx) = channel();
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx);
let mut r = Rig { ctx: egui::Context::default(), app, tx, width };
r.frames(6);
r
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() };
self.app.raw_input_hook(&mut raw);
self.ctx
.run_ui(raw, |ui| {
self.app.logic(ui.ctx());
self.app.ui(ui);
})
.textures_delta
.clear();
}
}
fn send(&mut self, method: &str, params: Value) {
let (req, reply) = ControlRequest::new(method, params);
self.tx.send(req).unwrap();
self.frames(40);
assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}");
}
fn view(&self) -> (f64, i64) {
let z = self.app.engine.session().edit.zoom;
(z.samples_per_px, z.scroll)
}
fn timeline(&self) -> Rect {
let [x0, y0, x1, y1] = self.app.edit_layout.timeline;
Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))
}
}
#[test]
fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() {
let mut r = Rig::new(1400.0);
let (tl, (spp, scroll)) = (r.timeline(), r.view());
let (x, y) = (tl.center().x, tl.center().y);
assert!(r.app.edit_layout.content_h - tl.height() > 60.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0}));
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, 0.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0}));
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, 60.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1}));
assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y);
r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0}));
assert_eq!(r.view(), (spp, 0));
r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap();
let scroll_y = r.app.edit_layout.scroll_y;
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24}));
assert_eq!(r.view().1, 3);
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
}
#[test]
fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() {
let mut r = Rig::new(1400.0);
let tl = r.timeline();
let (x, y) = (tl.min.x + 300.0, tl.center().y);
let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0;
let (spp, at) = (r.view().0, under(&r));
r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0}));
assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0);
assert!((under(&r) - at).abs() < 8.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true}));
assert!(r.view().0 < spp / 4.0);
assert!((under(&r) - at).abs() < 8.0);
let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true}));
assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
}
-43
View File
@@ -1,43 +0,0 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
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();
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
}
#[test]
fn window_shortcuts_open_and_close_windows() {
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::Engine::default(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(os);
for key in [Key::Num1, Key::Num3, Key::Num4, Key::Num5, Key::Num9] {
press(&ctx, &mut app, key, cmd);
}
press(&ctx, &mut app, Key::M, Modifiers { alt: true, ..cmd });
let ui = &app.ui;
assert!(
ui.show_transport && ui.show_big_counter && ui.show_automation && ui.show_memory_locations && ui.show_video && ui.narrow_mix,
"{os:?}"
);
press(&ctx, &mut app, Key::W, cmd);
assert!(!app.ui.show_transport && !app.ui.show_big_counter && !app.ui.show_memory_locations, "{os:?}");
}
}
#[test]
fn cmd_ctrl_equals_opens_the_score_editor_on_a_mac() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(OperatingSystem::Mac);
press(&ctx, &mut app, Key::Equals, Modifiers { ctrl: true, ..MAC_CMD });
assert!(app.ui.show_score);
assert_eq!(app.ui.window, MainWindow::Edit);
press(&ctx, &mut app, Key::Equals, MAC_CMD);
assert_eq!(app.ui.window, MainWindow::Mix);
}
+2 -7
View File
@@ -1749,7 +1749,7 @@ impl EditorLink {
mod window {
use objc2::rc::Retained;
use objc2::{MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask};
use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowStyleMask};
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
use std::ffi::{c_char, c_void};
@@ -1780,10 +1780,7 @@ mod window {
let mtm = MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string())?;
let _app = NSApplication::sharedApplication(mtm);
let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h));
// Plugin editors are transient windows. In particular they must not be
// minimizable: minimized NSWindows become persistent thumbnails in the macOS Dock,
// which made every editor visited in a session look like another running app.
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable;
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Miniaturizable;
// SAFETY: a freshly allocated window on the main thread; valid rect and style.
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false)
@@ -1794,8 +1791,6 @@ mod window {
window.setTitle(&NSString::from_str(title));
window.setLevel(NSFloatingWindowLevel);
window.setHidesOnDeactivate(true);
window.setExcludedFromWindowsMenu(true);
window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);
window.center();
Ok(HostWindow { window: Retained::into_raw(window) })
}
+1 -3
View File
@@ -42,15 +42,13 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr
| `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. |
| `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). |
| `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. |
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. |
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. |
| `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). |
| `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |
| `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). |
| `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). |
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |
| `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. |
| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. |
| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). |
| `ui.move` | `{x, y}` | Move the pointer. |
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). |
| `ui.text` | `{text}` | Type text into the focused field. |
+2 -2
View File
@@ -22,9 +22,9 @@ 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). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
| `batch` | Run several commands in order (`keep_going` optional). |
| `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). `save_session` won't save over another existing file unless `replace` is true. |
| `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. |
| `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. |
| `parity` | Feature-parity report. |
+2 -2
View File
@@ -2,8 +2,8 @@
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder
scripting on CI, nothing extra installed in the signing job).
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).
| File | What |
|---|---|
+7 -48
View File
@@ -109,10 +109,6 @@ 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"
@@ -137,52 +133,15 @@ 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"
# `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"
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"
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"