Merge remote-tracking branch 'origin/main' into fix/683

# Conflicts:
#	crates/engine/src/cmd/event.rs
This commit is contained in:
Brandon Thomas
2026-10-19 02:56:08 -04:00
63 changed files with 1520 additions and 182 deletions
+30
View File
@@ -0,0 +1,30 @@
version: 2
updates:
# Maintain Rust / Cargo dependencies
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
# Consolidate updates into a single PR rather than one PR per crate
groups:
cargo-dependencies:
patterns:
- "*"
commit-message:
prefix: "chore(deps)"
# Maintain GitHub Actions workflows
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"
time: "06:00"
timezone: "America/New_York"
groups:
actions:
patterns:
- "*"
commit-message:
prefix: "ci(deps)"
+3 -13
View File
@@ -18,16 +18,6 @@ concurrency:
cancel-in-progress: true
jobs:
eval:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
- name: nix flake check --no-build
run: |
if [ -f flake.nix ]; then
nix flake check --no-build --all-systems
else
echo "No flake.nix present in this repository; skipping check."
fi
nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
Generated
+24 -18
View File
@@ -846,6 +846,11 @@ dependencies = [
"windows 0.54.0",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63"
[[package]]
name = "craft-ui"
version = "0.1.0"
@@ -3567,7 +3572,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -3585,7 +3590,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"serde",
@@ -3595,7 +3600,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"proptest",
@@ -3607,7 +3612,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"serde_json",
@@ -3617,7 +3622,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"clap-sys",
"libloading",
@@ -3630,7 +3635,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3641,7 +3646,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"proptest",
@@ -3652,8 +3657,9 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"craft-docpath",
"log",
"proptest",
"serde",
@@ -3674,7 +3680,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -3684,7 +3690,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"log",
"rayon",
@@ -3700,7 +3706,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -3713,7 +3719,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"cpal",
"log",
@@ -3728,7 +3734,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"proptest",
"serde",
@@ -3738,7 +3744,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"craft-ui",
"egui",
@@ -3764,7 +3770,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"image",
"proptest",
@@ -3775,7 +3781,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"libloading",
"log",
@@ -3791,7 +3797,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"eframe",
"egui",
@@ -5348,7 +5354,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.7.0"
version = "0.9.0"
dependencies = [
"serde_json",
]
+4 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.7.0"
version = "0.9.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -42,6 +42,9 @@ rustfft = "6"
zip = { version = "9", default-features = false }
cpal = "0.16"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
+4 -5
View File
@@ -10,7 +10,7 @@
<h1 align="center">SoundCraft</h1>
<p align="center">
<b>Recording, editing and mixing audio; an open-source, clean-room reimplementation of Avid Pro Tools, rebuilt in pure Rust.</b>
<b>Recording, editing and mixing audio; an open-source, modern digital audio workstation (DAW), built in pure Rust.</b>
</p>
<p align="center">
@@ -25,6 +25,9 @@
<img alt="Platforms" src="https://img.shields.io/badge/macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20FreeBSD%20%C2%B7%20Web-0b7385?style=flat-square">
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
<br>
<a href="https://sonarcloud.io/project/overview?id=storytold_soundcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/soundcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
</p>
<p align="center">
@@ -375,10 +378,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (readiness by audience with hours to ~95 %) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (MCP batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
@@ -155,6 +156,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
| 2026-10-13 | trivial | Progress log: mix.instrument params, unknown fields refused (#265) |
| 2026-10-13 | trivial | Progress log: bus bounce compensates aux returns (#246) |
| 2026-10-13 | trivial | Progress log: master insert latency compensated (#351) |
+6 -14
View File
@@ -1,6 +1,5 @@
//! 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_ui_egui::sampler_window::SampleRead;
@@ -166,30 +165,23 @@ impl WebShell {
}
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
if matches!(purpose, Purpose::Session) && soundcraft_ui_egui::bounce::refuses_session_switch(&mut self.app) {
return;
}
fn handle(&mut self, purpose: Purpose, name: String, bytes: Vec<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()) }
})
}
// Asks first when the open session has changes: the bytes wait for the answer.
Purpose::Session => return soundcraft_ui_egui::dialogs::open_session_bytes(&mut self.app, name, bytes),
// 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(|_| {
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(|_| {
io::import_midi_bytes(e, &bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
@@ -205,7 +197,7 @@ impl eframe::App for WebShell {
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.handle(purpose, name, bytes);
}
let samples = std::mem::take(&mut *self.samples.borrow_mut());
for (read, bytes) in samples {
+75 -13
View File
@@ -1,7 +1,12 @@
//! Per-user settings and automation profile directories.
use std::ffi::OsString;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
/// Portable mode (#268): this file beside the executable keeps settings, autosaves, presets and
/// logs in [`PORTABLE_DATA_DIR`] next to it instead of the user profile.
const PORTABLE_MARKER: &str = "portable.txt";
const PORTABLE_DATA_DIR: &str = "SoundCraftData";
#[derive(Clone, Copy)]
enum Platform {
@@ -13,7 +18,18 @@ enum Platform {
/// SoundCraft's settings folder. A nonempty `$SOUNDCRAFT_CONFIG_DIR` overrides the platform default.
/// The override is the complete directory; no application-name suffix is appended. An explicitly
/// empty value disables the settings directory instead of falling back to a personal profile.
/// Otherwise `portable.txt` beside the executable selects `<exe dir>/SoundCraftData`, when that
/// folder can be written (a read-only medium falls back to the platform default).
pub fn config_dir() -> Option<PathBuf> {
static DIR: std::sync::OnceLock<Option<PathBuf>> = std::sync::OnceLock::new();
DIR.get_or_init(|| {
let exe = std::env::current_exe().ok();
resolve(exe.as_deref().and_then(Path::parent))
})
.clone()
}
fn resolve(exe_dir: Option<&Path>) -> Option<PathBuf> {
let platform = if cfg!(target_os = "macos") {
Platform::Mac
} else if cfg!(windows) {
@@ -21,13 +37,20 @@ pub fn config_dir() -> Option<PathBuf> {
} else {
Platform::Other
};
config_dir_with(platform, |name| std::env::var_os(name))
config_dir_with(platform, |name| std::env::var_os(name), exe_dir)
}
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -> Option<PathBuf> {
fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>, exe_dir: Option<&Path>) -> Option<PathBuf> {
if let Some(dir) = env("SOUNDCRAFT_CONFIG_DIR") {
return (!dir.is_empty()).then(|| PathBuf::from(dir));
}
if let Some(exe_dir) = exe_dir.filter(|d| d.join(PORTABLE_MARKER).is_file()) {
let data = exe_dir.join(PORTABLE_DATA_DIR);
match writable(&data) {
Ok(()) => return Some(data),
Err(e) => log::warn!("portable mode: {} can't be written ({e}); using the user profile instead", data.display()),
}
}
match platform {
Platform::Mac => env("HOME").map(|h| PathBuf::from(h).join("Library/Application Support/SoundCraft")),
Platform::Windows => env("APPDATA").map(|a| PathBuf::from(a).join("SoundCraft")),
@@ -37,6 +60,14 @@ fn config_dir_with(platform: Platform, env: impl Fn(&str) -> Option<OsString>) -
}
}
/// Create `dir` if needed and prove a file can be written in it.
fn writable(dir: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dir)?;
let probe = dir.join(format!(".write-test-{}", std::process::id()));
std::fs::write(&probe, b"")?;
std::fs::remove_file(probe)
}
/// Read bounded settings, recovering unreadable docking metadata independently of other settings.
pub fn load_ui(path: &std::path::Path) -> Result<Option<soundcraft_ui_egui::UiState>, String> {
read_ui(path, true)
@@ -195,16 +226,20 @@ mod config_tests {
}
fn resolve(platform: Platform, vars: &[(&str, &str)]) -> Option<PathBuf> {
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))))
config_dir_with(platform, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), None)
}
#[test]
fn config_override_wins_without_reading_user_profile() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
let actual = config_dir_with(platform, |name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
});
let actual = config_dir_with(
platform,
|name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::from("isolated/settings"))
},
None,
);
assert_eq!(actual, Some(PathBuf::from("isolated/settings")));
}
}
@@ -213,10 +248,14 @@ mod config_tests {
fn config_empty_override_disables_profile_without_reading_defaults() {
for platform in [Platform::Mac, Platform::Windows, Platform::Other] {
assert_eq!(
config_dir_with(platform, |name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::new())
}),
config_dir_with(
platform,
|name| {
assert_eq!(name, "SOUNDCRAFT_CONFIG_DIR");
Some(OsString::new())
},
None
),
None
);
}
@@ -234,11 +273,34 @@ mod config_tests {
}
}
#[test]
fn portable_txt_beside_the_exe_keeps_settings_next_to_it() {
let exe = std::env::temp_dir().join(format!("soundcraft-portable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&exe);
std::fs::create_dir_all(&exe).unwrap();
let vars = [("APPDATA", "/roaming")];
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")), "no marker: the profile");
std::fs::write(exe.join("portable.txt"), b"").unwrap();
assert_eq!(resolve_with_exe(&vars, &exe), Some(exe.join("SoundCraftData")));
assert!(exe.join("SoundCraftData").is_dir());
// The explicit override still wins.
assert_eq!(resolve_with_exe(&[("SOUNDCRAFT_CONFIG_DIR", "/custom")], &exe), Some(PathBuf::from("/custom")));
// A data folder that can't be made (a file is in the way) falls back to the profile.
std::fs::remove_dir_all(exe.join("SoundCraftData")).unwrap();
std::fs::write(exe.join("SoundCraftData"), b"").unwrap();
assert_eq!(resolve_with_exe(&vars, &exe), Some(PathBuf::from("/roaming/SoundCraft")));
std::fs::remove_dir_all(exe).unwrap();
}
fn resolve_with_exe(vars: &[(&str, &str)], exe: &Path) -> Option<PathBuf> {
config_dir_with(Platform::Windows, |name| vars.iter().find_map(|(key, value)| (*key == name).then(|| OsString::from(value))), Some(exe))
}
#[cfg(unix)]
#[test]
fn config_override_preserves_non_unicode_paths() {
use std::os::unix::ffi::OsStringExt as _;
let value = OsString::from_vec(b"isolated/\xff".to_vec());
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone())), Some(PathBuf::from(value)));
assert_eq!(config_dir_with(Platform::Mac, |_| Some(value.clone()), None), Some(PathBuf::from(value)));
}
}
+5
View File
@@ -50,3 +50,8 @@ sync_github_name = false
[people.Momentum96]
sync_github_name = true
[people.marcoarnulfo]
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
+7 -3
View File
@@ -6,6 +6,7 @@ use std::io::{BufRead, Write};
pub const PROTOCOL_VERSION: &str = "2025-06-18";
const SUPPORTED: &[&str] = &["2025-06-18", "2025-03-26", "2024-11-05"];
const MAX_BATCH_CALLS: usize = 1000;
const INSTRUCTIONS: &str = "SoundCraft is a digital audio workstation (Pro Tools-class). Everything a user can do is a \
command: call `list_commands` to discover ids and their params, then `execute` (or `batch`). Positions are samples \
@@ -37,8 +38,8 @@ pub fn tool_definitions() -> Vec<Value> {
),
tool(
"batch",
"Run several commands in order; stops at the first error unless keep_going.",
json!({"calls": {"type": "array", "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
"Run up to 1000 commands in order; stops at the first error unless keep_going. Larger batches are rejected before any command runs.",
json!({"calls": {"type": "array", "maxItems": MAX_BATCH_CALLS, "items": {"type": "object", "properties": {"command": {"type": "string"}, "params": {"type": "object"}}}}, "keep_going": {"type": "boolean"}}),
&["calls"],
),
tool(
@@ -135,9 +136,12 @@ impl<B: Backend> Server<B> {
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
return error_result("`calls` must be an array");
};
if calls.len() > MAX_BATCH_CALLS {
return error_result(&format!("`calls` must contain at most {MAX_BATCH_CALLS} commands; no commands were executed"));
}
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
for c in calls {
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;
+34
View File
@@ -31,6 +31,8 @@ fn handshake_and_tool_list() {
assert_eq!(r["result"]["serverInfo"]["name"], "soundcraft");
let r = rpc(&mut s, 2, "tools/list", json!({}));
assert!(r["result"]["tools"].as_array().unwrap().len() >= 10);
let batch = r["result"]["tools"].as_array().unwrap().iter().find(|tool| tool["name"] == "batch").unwrap();
assert_eq!(batch["inputSchema"]["properties"]["calls"]["maxItems"], 1000);
assert!(s.handle(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).is_none());
let r = rpc(&mut s, 3, "nope", json!({}));
assert_eq!(r["error"]["code"], -32601);
@@ -171,3 +173,35 @@ fn batch_rejects_non_array_calls() {
assert!(text.contains("`calls` must be an array"), "{text}");
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
}
#[test]
fn batch_rejects_oversize_calls_before_any_mutation() {
for keep_going in [false, true] {
let mut s = Server::new(Headless::new(Engine::default()));
let before = session(&mut s);
let mut calls = vec![json!({"command": "engine.history"}); 1001];
calls[0] = json!({"command": "track.new", "params": {"name": "Must not be created"}});
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": calls, "keep_going": keep_going}}));
assert_eq!(session(&mut s), before, "oversize batch mutated the session (keep_going={keep_going})");
assert!(s.backend.engine.journal.is_empty());
assert!(!s.backend.engine.can_undo());
assert_eq!(r["result"]["isError"], true);
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("1000"), "{text}");
}
}
#[test]
fn batch_accepts_empty_and_limit_sized_calls_in_order() {
for count in [0, 1000] {
let mut s = Server::new(Headless::new(Engine::default()));
let calls: Vec<_> = (0..count).map(|at| json!({"command": "session.format_time", "params": {"at": at, "format": "samples"}})).collect();
let results = tool(&mut s, "batch", json!({"calls": calls}));
let results = results.as_array().unwrap();
assert_eq!(results.len(), count);
for (at, result) in results.iter().enumerate() {
assert_eq!(result["ok"], true);
assert_eq!(result["result"]["text"], at.to_string());
}
}
}
+19 -6
View File
@@ -1241,25 +1241,38 @@ impl Instance {
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
}
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
/// The plugin's audio ports in one direction. A layout beyond `MAX_PORTS` ports or
/// `MAX_PORT_CHANNELS` channels per port is an error: we never hand a plugin fewer ports or
/// channels than it advertised.
pub fn audio_ports(&self, is_input: bool) -> Result<Vec<PortInfo>, ClapError> {
// SAFETY: see `params`.
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Ok(Vec::new()) };
let (Some(count), Some(get)) = (p.count, p.get) else { return Ok(Vec::new()) };
let dir = if is_input { "input" } else { "output" };
// SAFETY: vtable call with its instance.
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
let n = unsafe { count(self.plugin, is_input) };
if n > MAX_PORTS {
return Err(ClapError::Process(format!("{n} audio {dir} ports (at most {MAX_PORTS} supported)")));
}
let mut out = Vec::new();
for i in 0..n {
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
// SAFETY: index below the count; `info` is writable.
if unsafe { get(self.plugin, i, is_input, &mut info) } {
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
if info.channel_count > MAX_PORT_CHANNELS {
return Err(ClapError::Process(format!(
"audio {dir} port {i} has {} channels (at most {MAX_PORT_CHANNELS} supported)",
info.channel_count
)));
}
out.push(PortInfo { channels: info.channel_count, main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
} else {
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
out.push(PortInfo { channels: 0, main: false });
}
}
out
Ok(out)
}
pub fn note_port(&self) -> Option<NotePort> {
+15 -2
View File
@@ -113,8 +113,21 @@ impl Plugin for ClapPlugin {
self.channels = channels.clamp(1, MAX_CHANNELS);
self.inst.deactivate();
self.inst.service_callback();
let ins = self.inst.audio_ports(true);
let outs = self.inst.audio_ports(false);
let ports = self.inst.audio_ports(true).and_then(|ins| Ok((ins, self.inst.audio_ports(false)?)));
let (ins, outs) = match ports {
Ok(p) => p,
Err(e) => {
// Unsupported layout: stay inactive (`process` passes audio through untouched).
log::warn!("{}: {e}", self.info.id);
self.inputs = PortBuffers::new(&[], 0);
self.outputs = PortBuffers::new(&[], 0);
self.in_main = None;
self.out_main = None;
self.max_block = 0;
self.latency = 0;
return;
}
};
self.in_main = pick_main(&ins);
self.out_main = pick_main(&outs);
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
+1 -1
View File
@@ -40,7 +40,7 @@ pub fn fixture_dir() -> &'static Path {
std::fs::create_dir_all(dir.join("vendor")).unwrap();
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
}
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
dir
})
}
+74 -4
View File
@@ -6,6 +6,11 @@
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
//! MIDI CC 7 (volume) sets the constant to its value / 127.
//!
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
//! advertised layout, -1.0 when they do not.
//!
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
//! reported as an output parameter event by the next `process`.
@@ -36,6 +41,27 @@ unsafe impl<const N: usize> Sync for Features<N> {}
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
static WIDE_FEATURES: Features<2> = Features([c"audio-effect".as_ptr(), std::ptr::null()]);
const fn wide_desc(id: &'static CStr, name: &'static CStr) -> clap_plugin_descriptor {
clap_plugin_descriptor {
clap_version: CLAP_VERSION,
id: id.as_ptr(),
name: name.as_ptr(),
vendor: c"SoundCraft tests".as_ptr(),
url: std::ptr::null(),
manual_url: std::ptr::null(),
support_url: std::ptr::null(),
version: c"1.0.0".as_ptr(),
description: std::ptr::null(),
features: WIDE_FEATURES.0.as_ptr(),
}
}
static WIDE16_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide16", c"Fixture Wide 16x64");
static WIDE17_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide17ports", c"Fixture Wide 17 ports");
static WIDE65_DESC: clap_plugin_descriptor = wide_desc(c"org.soundcraft.test.wide65ch", c"Fixture Wide 65 channels");
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
clap_version: CLAP_VERSION,
id: c"org.soundcraft.test.gain".as_ptr(),
@@ -66,6 +92,8 @@ struct Fx {
plugin: clap_plugin,
host: *const clap_host,
synth: bool,
/// `(ports per direction, channels per port)` for the wide fixtures.
wide: Option<(u32, u32)>,
gain: f64,
mode: f64,
level: f64,
@@ -168,6 +196,22 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let n = pr.frames_count as usize;
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
return CLAP_PROCESS_ERROR;
}
@@ -324,10 +368,26 @@ static PARAMS: clap_plugin_params = clap_plugin_params {
};
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
if let Some((ports, _)) = unsafe { fx(p) }.wide {
return ports;
}
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
}
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
if let Some((ports, channels)) = unsafe { fx(p) }.wide {
if index >= ports {
return false;
}
let info = unsafe { &mut *info };
info.id = index * 2 + u32::from(is_input);
write_name(&mut info.name, c"wide");
info.flags = if index == 0 { CLAP_AUDIO_PORT_IS_MAIN } else { 0 };
info.channel_count = channels;
info.port_type = std::ptr::null();
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
return true;
}
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
return false;
}
@@ -498,23 +558,32 @@ unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
// ---- factory + entry ----
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
2
5
}
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
match i {
0 => &GAIN_DESC,
1 => &SYNTH_DESC,
2 => &WIDE16_DESC,
3 => &WIDE17_DESC,
4 => &WIDE65_DESC,
_ => std::ptr::null(),
}
}
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
let id = unsafe { CStr::from_ptr(id) };
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
(&GAIN_DESC as *const _, false)
let (desc, synth, wide) = if id == c"org.soundcraft.test.gain" {
(&GAIN_DESC as *const _, false, None)
} else if id == c"org.soundcraft.test.synth" {
(&SYNTH_DESC as *const _, true)
(&SYNTH_DESC as *const _, true, None)
} else if id == c"org.soundcraft.test.wide16" {
(&WIDE16_DESC as *const _, false, Some((16, 64)))
} else if id == c"org.soundcraft.test.wide17ports" {
(&WIDE17_DESC as *const _, false, Some((17, 2)))
} else if id == c"org.soundcraft.test.wide65ch" {
(&WIDE65_DESC as *const _, false, Some((1, 65)))
} else {
return std::ptr::null();
};
@@ -535,6 +604,7 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
},
host,
synth,
wide,
gain: 1.0,
mode: 0.0,
level: 0.0,
+22 -1
View File
@@ -13,7 +13,7 @@ const SYNTH: &str = "clap:org.soundcraft.test.synth";
fn scan_lists_both_fixture_plugins() {
let dir = fixture_dir();
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
assert_eq!(found.len(), 2);
assert_eq!(found.len(), 5);
let g = found.iter().find(|d| d.id == GAIN).unwrap();
assert_eq!(g.name, "Fixture Gain");
assert_eq!(g.vendor, "SoundCraft tests");
@@ -110,6 +110,27 @@ fn synth_plays_notes_at_their_offsets() {
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
}
#[test]
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
fixture_dir();
// Control: exactly at the limits (16 ports of 64 channels) activates and gets its full layout.
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 3);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
// 17 ports, or a 65-channel port: never activated, never processed with a cut-down layout.
for id in ["clap:org.soundcraft.test.wide17ports", "clap:org.soundcraft.test.wide65ch"] {
let mut p = soundcraft_clap_host::create(id).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 0, "{id}: not activated");
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 0.5), "{id}: the plugin never ran: {:?}", &io[0][..4]);
}
}
#[test]
fn many_instances_create_and_drop_cleanly() {
fixture_dir();
+6
View File
@@ -275,6 +275,12 @@ pub trait PluginEditor: Send {
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
/// GUI shows it. Values are in SoundCraft units.
fn set_param(&mut self, _id: &str, _value: f32) {}
/// The plugin's own text for a parameter value (SoundCraft units) when SoundCraft's
/// [`ParamInfo::format`] would be wrong for it. Main thread only; `None` means "format it
/// yourself".
fn value_text(&mut self, _id: &str, _value: f32) -> Option<String> {
None
}
}
/// Reads a plugin's state on the main (UI) thread while the instance processes on the audio
+1
View File
@@ -23,6 +23,7 @@ serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
zip.workspace = true
craft-docpath.workspace = true
[dev-dependencies]
proptest.workspace = true
+8 -4
View File
@@ -1,7 +1,7 @@
//! Crash-recovery copies of open sessions, one folder each, written in the background, and the
//! recovery of the ones left behind with unsaved work.
use crate::io::{Recording, open_session, sanitize_name};
use crate::io::{Recording, open_session_linked, sanitize_name};
use crate::{Engine, EngineError, Result};
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
use soundcraft_model::{Session, SourceAudio, SourceId};
@@ -197,7 +197,7 @@ impl Autosave {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
if let Some(f) = &file
&& (!src.unsaved && f.is_file() || audio.is_none())
&& (audio.is_none() || !src.unsaved && f.is_file())
{
src.path = f.to_string_lossy().into_owned();
continue;
@@ -291,7 +291,9 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
/// media that couldn't be loaded.
pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
let mut r = Engine::default();
let mut missing = open_session(&mut r, &left.dir.join(SESSION).to_string_lossy())?;
let session = left.dir.join(SESSION);
let links = left.origin.as_deref().map_or(session.as_path(), Path::new);
let mut missing = open_session_linked(&mut r, &session.to_string_lossy(), links)?;
let mut s = r.session().clone();
let unfinished = unfinished_takes(left, &s, &mut missing);
if let Some(home) = left.origin.as_deref().and_then(|o| Path::new(o).parent()) {
@@ -315,7 +317,8 @@ pub fn recover(e: &mut Engine, left: &Leftover) -> Result<Vec<String>> {
/// is left untouched and added to `skipped`.
fn unfinished_takes(left: &Leftover, s: &Session, skipped: &mut Vec<String>) -> Option<(Recording, Vec<(soundcraft_model::TrackId, PathBuf)>)> {
let plan: Recording = serde_json::from_str(&std::fs::read_to_string(left.dir.join(RECORDING)).ok()?).ok()?;
let known: Vec<PathBuf> = s.sources.iter().filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
let known: Vec<PathBuf> =
s.sources.iter().filter(|src| s.pool.get(src.id).is_some()).filter_map(|src| std::fs::canonicalize(&src.path).ok()).collect();
let folders = take_folders(left);
let mut takes = Vec::new();
for t in &plan.takes {
@@ -413,6 +416,7 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
#[cfg(test)]
mod tests {
use super::*;
use crate::io::open_session;
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
+40 -1
View File
@@ -94,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
add_marker
),
cmd!("markers.delete", "Delete Memory Location", [], None, "{number | id}", always, delete_marker),
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?}", always, edit_marker),
cmd!("markers.edit", "Edit Memory Location", [], None, "{number | id, name?, comments?, at?, color?: [r,g,b] | null}", always, edit_marker),
cmd!(noundo "markers.recall", "Recall Memory Location", [], Some("Period + n + Period"), "{number}", always, recall_marker),
cmd!(noundo "markers.next", "Go to Next Marker", [], None, "{}", always, |e, _| step_marker(e, 1)),
cmd!(noundo "markers.previous", "Go to Previous Marker", [], None, "{}", always, |e, _| step_marker(e, -1)),
@@ -338,8 +338,23 @@ fn delete_marker(e: &mut Engine, p: &Value) -> Result<Value> {
fn edit_marker(e: &mut Engine, p: &Value) -> Result<Value> {
let i = marker_index(e, p, "markers.edit")?;
let at = position_param(e, "markers.edit", p, "at")?;
let color = match p.get("color") {
None => None,
Some(Value::Null) => Some(None),
Some(Value::Array(a)) if a.len() == 3 => {
let ch = |i: usize| a.get(i).and_then(Value::as_u64).and_then(|v| u8::try_from(v).ok());
match (ch(0), ch(1), ch(2)) {
(Some(r), Some(g), Some(b)) => Some(Some([r, g, b])),
_ => return Err(bad("markers.edit", "`color` components must be integers 0..=255")),
}
}
Some(_) => return Err(bad("markers.edit", "`color` must be [r,g,b] or null")),
};
let s = e.session_mut();
let Some(m) = s.markers.get_mut(i) else { return Err(bad("markers.edit", "gone")) };
if let Some(c) = color {
m.color = c;
}
// `name` is the documented new name; when it was the selector it already matches, so this is a no-op.
if let Some(n) = str_param(p, "rename").or_else(|| str_param(p, "name")) {
m.name = n.to_string();
@@ -458,6 +473,30 @@ mod tests {
assert_eq!(find(&c), Some((1000, 2000)));
}
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
#[test]
fn edit_marker_sets_documented_color() {
let mut e = demo_engine();
let n = e.session().markers.first().unwrap().number;
let color = |e: &crate::Engine| e.session().markers.iter().find(|m| m.number == n).unwrap().color;
assert_eq!(color(&e), None);
e.execute("markers.edit", &json!({"number": n, "color": [255, 0, 0]})).unwrap();
assert_eq!(color(&e), Some([255, 0, 0]));
assert!(e.undo());
assert_eq!(color(&e), None);
assert!(e.redo());
assert_eq!(color(&e), Some([255, 0, 0]));
e.execute("markers.edit", &json!({"number": n, "color": null})).unwrap();
assert_eq!(color(&e), None);
for bad in [json!([1, 2]), json!([1, 2, 300]), json!("red"), json!([1, 2, -3])] {
assert!(e.execute("markers.edit", &json!({"number": n, "color": bad})).is_err());
assert_eq!(color(&e), None);
}
}
/// Constant replaces the tempo over [start, end) only; the tempo at the old end tick carries on after it.
#[test]
fn tempo_constant_is_bounded_by_its_range() {
+40 -4
View File
@@ -414,7 +414,9 @@ fn step_input(e: &mut Engine, p: &Value) -> Result<Value> {
let end = s.tempo.tick_to_samples(end_tick, sr);
// Continue the clip under (or ending at) the insertion point, else start a new one.
let host = s.track(t).and_then(|tr| {
tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. })).find(|c| c.range().contains(at) || c.end() == at).map(|c| c.id)
let mut midi = tr.clips().iter().filter(|c| matches!(c.content, ClipContent::Midi { .. }));
// A clip starting at `at` wins over one that merely ends there.
midi.clone().find(|c| c.range().contains(at)).or_else(|| midi.find(|c| c.end() == at)).map(|c| c.id)
});
let host = match host {
Some(h) => h,
@@ -730,13 +732,16 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
let t = midi_target(e, p, id)?;
let s = e.session();
let sr = s.sample_rate;
// Latest start tick whose longest note still ends within MAX_POSITION.
let max_tick = s.tempo.samples_to_ticks(MAX_POSITION, sr).saturating_sub(TICKS_PER_QUARTER * 1024).max(0);
let mut raw: Vec<(i64, Note)> = Vec::new();
for v in list.iter().take(100_000) {
let tick = match (v.get("start_ticks").and_then(Value::as_i64), v.get("start")) {
(Some(tk), _) => tk.max(0),
(None, Some(_)) => s.tempo.samples_to_ticks(position_param(e, id, v, "start")?.unwrap_or(0).max(0), sr),
_ => return Err(bad(id, "each note needs `start` or `start_ticks`")),
};
}
.min(max_tick);
let note = Note {
pitch: i64_or(v, "pitch", 60).clamp(0, 127) as u8,
velocity: i64_or(v, "velocity", 100).clamp(1, 127) as u8,
@@ -750,7 +755,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
let first = raw.iter().map(|x| x.0).min().unwrap_or(0);
// Start the clip on the bar line so the quantize grid lines up with the music.
let clip_tick = s.tempo.bar_start_tick(s.tempo.bar_beat_at_tick(first).bar).min(first).max(0);
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or(first + 1);
let end_tick = raw.iter().map(|(tk, n)| tk.saturating_add(n.length)).max().unwrap_or_else(|| first.saturating_add(1));
let mut seq = soundcraft_midi::Sequence::default();
for (tk, mut n) in raw {
n.start = tk - clip_tick;
@@ -764,7 +769,7 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
seq.sort();
}
let start = s.tempo.tick_to_samples(clip_tick, sr);
let end = s.tempo.tick_to_samples(end_tick, sr);
let end = s.tempo.tick_to_samples(end_tick, sr).min(MAX_POSITION);
let n = seq.notes.len();
let s = e.session_mut();
let cid = s.new_clip_id();
@@ -862,6 +867,25 @@ mod tests {
assert_eq!(e.session().edit.selection.start, 3 * 24_000);
}
#[test]
fn step_input_at_clip_boundary_uses_next_clip() {
let (mut e, t) = midi_engine();
let sr = 48_000;
e.execute("midi.new_clip", &json!({"track": t.0, "start": 0, "end": sr})).unwrap();
let second = e.execute("midi.new_clip", &json!({"track": t.0, "start": sr, "end": 2 * sr})).unwrap()["clip"].as_u64().unwrap();
e.execute("edit.select", &json!({"tracks": [t.0], "start": sr})).unwrap();
let r = e.execute("event.step_input", &json!({"track": t.0, "pitch": 60, "duration": "1/4"})).unwrap();
assert_eq!(r["clip"].as_u64(), Some(second));
let mut ranges: Vec<(i64, i64)> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
ranges.sort();
assert_eq!(ranges, vec![(0, sr), (sr, 2 * sr)]);
let c = e.session().find_clip(soundcraft_model::ClipId(second)).map(|(_, c)| c.clone()).unwrap();
match &c.content {
ClipContent::Midi { sequence } => assert_eq!(sequence.notes.iter().map(|n| n.start).collect::<Vec<_>>(), vec![0]),
_ => panic!(),
}
}
#[test]
fn tempo_curves() {
let mut e = Engine::default();
@@ -943,6 +967,18 @@ mod tests {
assert!(r["bpm"].as_f64().is_some());
}
#[test]
fn retro_record_huge_start_ticks_stay_in_range() {
for tk in [i64::MAX, 1_000_000_000_000_000] {
let (mut e, t) = midi_engine();
let notes = json!([{"pitch": 60, "start_ticks": tk, "length_ticks": TICKS_PER_QUARTER * 1024}]);
e.execute("event.retrospective_record", &json!({"track": t.0, "notes": notes})).unwrap();
let c = e.session().track(t).unwrap().clips()[0].clone();
assert!(c.start >= 0 && c.length >= 1, "{tk}: {} {}", c.start, c.length);
assert!(c.start.saturating_add(c.length) <= MAX_POSITION, "{tk}: start {} len {}", c.start, c.length);
}
}
#[test]
fn move_song_start_moves_tick_material() {
let (mut e, t) = midi_engine();
+17 -2
View File
@@ -126,8 +126,8 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
let seq = seq_mut(e, id, "midi.notes_move")?;
for (i, n) in seq.notes.iter_mut().enumerate() {
if idx.contains(&i) {
n.start = (n.start + dt).max(0);
n.pitch = (i64::from(n.pitch) + ds).clamp(0, 127) as u8;
n.start = n.start.saturating_add(dt).max(0);
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
}
}
seq.sort();
@@ -157,6 +157,21 @@ mod tests {
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
}
#[test]
fn notes_move_huge_params_do_not_overflow() {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
let clip = r["clip"].as_u64().unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 480})).unwrap();
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": i64::MAX})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
assert_eq!(n["notes"][0]["start"], i64::MAX);
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "semitones": i64::MAX})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
assert_eq!(n["notes"][0]["pitch"], 127);
}
#[test]
fn rejects_audio_tracks() {
let mut e = Engine::default();
+18 -12
View File
@@ -7,7 +7,7 @@ use crate::cmd;
use serde_json::json;
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode;
use std::path::{Path, PathBuf};
use std::path::Path;
macro_rules! option {
($id:literal, $label:literal, $flag:literal) => {
@@ -407,7 +407,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
let text_v = crate::io::read_text(&path)?;
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let dir = Path::new(&path).parent().map(Path::to_path_buf).unwrap_or_default();
let names: Option<Vec<String>> =
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
let picked: Vec<soundcraft_model::Track> = match &names {
@@ -435,20 +434,27 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let mut map: std::collections::BTreeMap<u64, SourceId> = Default::default();
let mut missing = Vec::new();
for src in needed.iter().filter_map(|sid| other.source(*sid)) {
let abs = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
let mut ns = src.clone();
ns.id = SourceId(s.alloc());
ns.path = abs.to_string_lossy().into_owned();
match crate::io::decode_file(&mut files, &abs, sr) {
Ok(audio) => {
ns.frames = audio.buffer.frames() as u64;
ns.sample_rate = sr;
ns.unsaved = true;
s.pool.insert(ns.id, audio);
match crate::io::stored_path(&src.path, Path::new(&path)) {
Ok(abs) => {
ns.path = abs.to_string_lossy().into_owned();
match crate::io::decode_file(&mut files, &abs, sr) {
Ok(audio) => {
ns.frames = audio.buffer.frames() as u64;
ns.sample_rate = sr;
ns.unsaved = true;
s.pool.insert(ns.id, audio);
}
Err(_) => {
ns.unsaved = false;
missing.push(ns.path.clone());
}
}
}
Err(_) => {
Err(why) => {
ns.unsaved = false;
missing.push(ns.path.clone());
missing.push(format!("{}: {why}", ns.path));
}
}
map.insert(src.id.0, ns.id);
+79
View File
@@ -19,6 +19,7 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.group", "Group...", ["Track"], Some("Cmd+G"), "{name?, tracks?, edit?: true, mix?: true}", has_selection, group),
cmd!("track.ungroup", "Delete Group", [], None, "{group: id|name}", always, delete_group),
cmd!("track.group_toggle", "Toggle Group Active", [], None, "{group: id|name}", always, toggle_group),
cmd!("track.group_modify", "Modify Group", [], None, "{group: id|name, name?, tracks?, edit?, mix?}", always, modify_group),
cmd!(
"track.duplicate",
"Duplicate...",
@@ -320,6 +321,55 @@ fn toggle_group(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"active": active}))
}
/// Change an existing group's name, membership and Edit/Mix linking. Omitted fields keep their value.
fn modify_group(e: &mut Engine, p: &Value) -> Result<Value> {
const CMD: &str = "track.group_modify";
let g = find_group(e, p, CMD)?;
// Absent `tracks` leaves membership alone; present, it replaces it. Only an array counts, so a
// mistyped param can't silently swap the members for whatever happens to be selected.
let members = match p.get("tracks") {
None => None,
Some(Value::Array(_)) => {
let mut t = tracks_param(e, CMD, p)?;
// A track listed twice would take every linked move twice over.
let mut seen = std::collections::HashSet::new();
t.retain(|id| seen.insert(*id));
if t.is_empty() {
return Err(bad(CMD, "a group needs at least one track"));
}
Some(t)
}
Some(_) => return Err(bad(CMD, "`tracks` must be an array of track ids or names")),
};
let name = match str_param(p, "name") {
None => None,
Some(n) => {
let n = n.trim();
if n.is_empty() {
return Err(bad(CMD, "`name` must not be empty"));
}
// `find_group` resolves by name, so duplicates would make a group unaddressable.
if e.session().groups.iter().any(|x| x.name == n && x.id != g) {
return Err(bad(CMD, format!("a group named `{n}` already exists")));
}
let mut n = n.to_string();
soundcraft_model::truncate_chars(&mut n, soundcraft_model::MAX_NAME_CHARS);
Some(n)
}
};
let s = e.session_mut();
let Some(gr) = s.groups.iter_mut().find(|x| x.id == g) else { return Err(bad(CMD, "unknown `group`")) };
if let Some(n) = name {
gr.name = n;
}
if let Some(m) = members {
gr.members = m;
}
gr.edit = bool_or(p, "edit", gr.edit);
gr.mix = bool_or(p, "mix", gr.mix);
Ok(json!({"group": g, "name": gr.name, "tracks": gr.members, "edit": gr.edit, "mix": gr.mix}))
}
fn duplicate(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.duplicate", p)?;
let (pl, auto, ins, snd) =
@@ -872,6 +922,35 @@ mod tests {
assert!(e.execute("track.playlist_promote", &json!({"track": kick, "playlist": 1, "start": 0, "end": 10})).is_err());
}
#[test]
fn group_modify_changes_name_membership_and_linking() {
let mut e = crate::demo::demo_engine();
let g = e.execute("track.group", &json!({"tracks": ["Kick", "Snare"], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
// Every field at once, the way the dialog submits it.
let r = e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm", "tracks": ["Kick", "Bass"], "edit": false})).unwrap();
assert_eq!(r["name"], "Rhythm");
let gr = e.session().groups.iter().find(|x| x.id.0 == g).unwrap().clone();
assert_eq!(gr.name, "Rhythm");
assert_eq!(gr.members, vec![e.session().track_by_name("Kick").unwrap().id, e.session().track_by_name("Bass").unwrap().id]);
assert!(!gr.edit);
assert!(gr.mix, "an omitted field keeps its value");
assert_eq!(gr.letter, 'a', "the letter is the group's stable id and never changes");
// Omitting `tracks` leaves membership alone rather than falling back to the selection.
e.execute("edit.select", &json!({"tracks": ["Pad"], "exact": true})).unwrap();
e.execute("track.group_modify", &json!({"group": g, "name": "Rhythm Section"})).unwrap();
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members, gr.members);
// Resolving the group by its new name keeps working, so names stay unique and non-empty.
e.execute("track.group", &json!({"tracks": ["Pad"], "name": "Pads"})).unwrap();
assert!(e.execute("track.group_modify", &json!({"group": "Pads", "name": "Rhythm Section"})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "name": " "})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": []})).is_err());
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": "Kick"})).is_err(), "`tracks` must be an array");
assert!(e.execute("track.group_modify", &json!({"group": g, "tracks": ["Kick", "Kick"]})).is_ok());
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().members.len(), 1, "a repeated track is listed once");
assert!(e.execute("track.group_modify", &json!({"group": 9999, "name": "x"})).is_err());
assert_eq!(e.session().groups.iter().find(|x| x.id.0 == g).unwrap().name, "Rhythm Section", "a rejected call changes nothing");
}
#[test]
fn folder_routes_members_through_its_bus() {
let mut e = crate::demo::demo_engine();
+29
View File
@@ -383,6 +383,10 @@ fn write_rtp(e: &mut Engine, p: &Value) -> Result<Value> {
n.start = n.start.saturating_add(r.delay_ticks).max(0);
notes += 1;
}
// Playback delays controller events too, but ignores those before tick 0 (hidden by a start trim).
for c in sequence.ctrls.iter_mut().filter(|c| c.tick >= 0) {
c.tick = c.tick.saturating_add(r.delay_ticks).max(0);
}
sequence.sort();
}
let pre = format!("rtp.{}.", t.0);
@@ -635,6 +639,31 @@ mod tests {
assert!(!e.session().edit.values.keys().any(|k| k.starts_with(&format!("rtp.{}.", keys.0))));
}
#[test]
fn rtp_delay_shifts_controller_events() {
use soundcraft_midi::{CtrlEvent, CtrlKind};
let mut e = crate::demo::demo_engine();
let keys = e.session().track_by_name("Keys").unwrap().id;
let s = e.session_mut();
if let Some(pl) = s.track_mut(keys).and_then(Track::playlist_mut)
&& let ClipContent::Midi { sequence } = &mut pl.clips[0].content
{
sequence.ctrls.clear();
sequence.ctrls.push(CtrlEvent { tick: 100, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
sequence.ctrls.push(CtrlEvent { tick: 200, channel: 0, kind: CtrlKind::PitchBend, value: 10 });
sequence.ctrls.push(CtrlEvent { tick: -30, channel: 0, kind: CtrlKind::Cc(1), value: 5 });
}
s.edit.values.insert(track_key("rtp", keys, "delay"), 50.0);
e.execute("edit.select", &json!({"tracks": [keys.0]})).unwrap();
e.execute("track.write_midi_rtp", &json!({})).unwrap();
let ClipContent::Midi { sequence } = &e.session().track(keys).unwrap().clips()[0].content else { panic!() };
let tick_of = |k: CtrlKind| sequence.ctrls.iter().find(|c| c.kind == k).map(|c| c.tick);
assert_eq!(tick_of(CtrlKind::Cc(7)), Some(150), "playback delayed the CC by 50 ticks; baked result must match");
assert_eq!(tick_of(CtrlKind::PitchBend), Some(250));
// Hidden by a start trim: playback ignores it, so baking leaves it alone.
assert_eq!(tick_of(CtrlKind::Cc(1)), Some(-30));
}
#[test]
fn trim_and_vca_coalesce() {
let mut e = Engine::default();
+4 -2
View File
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
offset
),
cmd!(query "video.list", "Movies", [], None, "{} — movies used by Video tracks (path, size, rate, codec, online file)", always, |e, _| {
cmd!(query "video.list", "Movies", [], None, "{} — movies used by Video tracks (path, size, rate, codec, online file, why it is offline)", always, |e, _| {
let s = e.session();
let rows: Vec<Value> = s
.videos
@@ -36,7 +36,8 @@ pub fn specs() -> Vec<CommandSpec> {
.map(|v| {
let clips = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| c.video_source() == Some(v.id)).count();
json!({"id": v.id, "name": v.name, "path": v.path, "width": v.width, "height": v.height, "frame_rate": v.frame_rate,
"duration": v.duration, "codec": v.codec, "clips": clips, "file_found": crate::io::file_found(&v.path)})
"duration": v.duration, "codec": v.codec, "clips": clips,
"file_found": v.offline.is_none() && crate::io::file_found(&v.path), "offline": v.offline})
})
.collect();
Ok(json!({"videos": rows, "online": !s.edit.flag(OFFLINE_FLAG)}))
@@ -92,6 +93,7 @@ mod tests {
frame_rate: 24.0,
duration: 2.0,
codec: "h264".into(),
offline: None,
});
let c = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::video(c, "m", vid, 0, 1000, 96_000));
+148 -18
View File
@@ -60,6 +60,23 @@ pub fn file_found(path: impl AsRef<Path>) -> bool {
crate::file_access::check_read(path).is_ok() && path.is_file()
}
/// The file a path stored in the session file `session` refers to, or why it is not opened.
///
/// Every path a session names (audio sources, movies) goes through here before it reaches the
/// file system. A relative path is taken from the session's folder, and only files on this
/// computer or on the network share the session itself is on are opened (`craft_docpath`).
/// Paths the person picks in this session (imports) are not stored paths and skip this.
pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> {
let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf());
let path = craft_docpath::resolve(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))?;
// A path that is still relative would be taken from the working directory, which the session
// does not choose.
if path.is_relative() {
return Err("not opened: the path is not absolute".into());
}
Ok(path)
}
/// Create `dir` and its parents, inside the automation write root when there is one.
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
let dir = dir.as_ref();
@@ -206,6 +223,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
frame_rate: info.frame_rate,
duration: info.duration,
codec: info.codec.clone(),
offline: None,
});
// Reuse the first Video track when the new clip does not overlap its clips; else add one at the top.
let range = Range::new(at, at.saturating_add(len));
@@ -235,22 +253,35 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
}))
}
/// Resolve each referenced movie: keep it when its path exists, else look for a file of the same
/// name next to the session (moved projects) and relink. Returns the movies still missing.
pub fn relink_videos(s: &mut Session, session_dir: &Path) -> Vec<String> {
/// Resolve each movie referenced by the session file `session` ([`stored_path`]): keep the
/// resolved path when its file exists, else look for a file of the same name next to the session
/// (moved projects) and relink. A movie whose stored path is refused and has no such copy is
/// marked offline. Returns the movies still missing.
pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
let session_dir = session.parent().unwrap_or(Path::new(""));
let mut missing = Vec::new();
for v in &mut s.videos {
let p = if Path::new(&v.path).is_absolute() { PathBuf::from(&v.path) } else { session_dir.join(&v.path) };
if file_found(&p) {
v.offline = None;
let p = stored_path(&v.path, session);
// The UI opens `path` as it is: keep the checked path, not one relative to the working
// directory.
if let Ok(found) = &p
&& file_found(found)
{
v.path = found.to_string_lossy().into_owned();
continue;
}
let name = Path::new(&v.path).file_name().map(PathBuf::from).unwrap_or_default();
let near = [session_dir.join(&name), session_dir.join("Video Files").join(&name)]
.into_iter()
.find(|c| !name.as_os_str().is_empty() && file_found(c));
match near {
Some(c) => v.path = c.to_string_lossy().into_owned(),
None => missing.push(format!("{} (video)", p.display())),
match (near, p) {
(Some(c), _) => v.path = c.to_string_lossy().into_owned(),
(None, Ok(p)) => missing.push(format!("{} (video)", p.display())),
(None, Err(why)) => {
missing.push(format!("{} (video): {why}", v.path));
v.offline = Some(why);
}
}
}
missing
@@ -1096,19 +1127,26 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
open_session_linked(e, path, Path::new(path))
}
/// [`open_session`], following the media paths in it as if they were stored in the session file
/// `links` (crash recovery: the session written beside the autosave, its paths from the original).
pub(crate) fn open_session_linked(e: &mut Engine, path: &str, links: &Path) -> Result<Vec<String>> {
let text = read_text(path)?;
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(err.to_string()))?;
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
let mut missing = Vec::new();
let mut files = DecodedFiles::new();
for src in &s.sources {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
match decode_file(&mut files, &p, s.sample_rate.hz()) {
let decoded =
stored_path(&src.path, links).map_err(|why| format!("{}: {why}", src.path)).and_then(|p| decode_file(&mut files, &p, s.sample_rate.hz()));
match decoded {
Ok(audio) => s.pool.insert(src.id, audio),
Err(err) => missing.push(err),
}
}
missing.extend(relink_videos(&mut s, &dir));
missing.extend(relink_videos(&mut s, links));
s.folder = Some(dir);
e.replace_session(s);
e.path = Some(path.to_string());
@@ -1350,18 +1388,25 @@ pub struct Take {
/// Place recorded take files as `plan` says, as one undo step ("Record").
pub fn place_recording(e: &mut Engine, plan: &Recording, takes: Vec<(TrackId, PathBuf)>) -> Vec<soundcraft_model::ClipId> {
let ids = add_recording(e, plan.start, takes, plan.pass);
if let Some(p) = plan.punch {
let clips: Vec<u64> = ids.iter().map(|c| c.0).collect();
let _ = e.execute_merged("edit.trim_to_fill_selection", &json!({"clips": clips, "start": p.start, "end": p.end}), "record");
}
ids
add_recording_in(e, plan.start, takes, plan.pass, plan.punch)
}
/// 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> {
add_recording_in(e, start, takes, pass, None)
}
/// [`add_recording`] limited to the `punch` range: each clip is cut to it before it's placed, so
/// the material around the punch isn't overwritten.
fn add_recording_in(
e: &mut Engine,
start: Samples,
takes: Vec<(TrackId, PathBuf)>,
pass: Option<Samples>,
punch: Option<Range>,
) -> Vec<soundcraft_model::ClipId> {
let before = e.session_arc();
let mut out = Vec::new();
for (tid, path) in takes {
@@ -1384,6 +1429,13 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu
if offset > 0 && n < pass / 8 {
break;
}
let (clip_start, cut, clip_len) = match punch {
Some(p) => {
let Some(kept) = Range::new(start, start.saturating_add(n)).intersect(&p) else { continue };
(kept.start, kept.start.saturating_sub(start), kept.len())
}
None => (start, 0, n),
};
if offset > 0
&& let Some(t) = s.track_mut(tid)
{
@@ -1391,7 +1443,7 @@ pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBu
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));
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset.saturating_add(cut), clip_start, clip_len));
out.push(cid);
}
}
@@ -1473,6 +1525,30 @@ mod record_tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn punching_in_keeps_the_material_around_the_punch_range() {
let dir = std::env::temp_dir().join(format!("soundcraft-punch-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 1");
let base = dir.join("Audio Files/Base.wav");
let take = dir.join("Audio Files/Take.wav");
write_take(&base, 3_000);
write_take(&take, 1_000);
add_recording(&mut e, 0, vec![(vox, base)], None);
let plan = Recording {
start: 1_000,
pass: None,
punch: Some(Range::new(1_400, 1_600)),
rate: 48_000,
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
};
place_recording(&mut e, &plan, vec![(vox, take)]);
let clips: Vec<_> = e.session().track(vox).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
assert_eq!(clips, vec![(0, 1_400, 0), (1_400, 200, 400), (1_600, 1_400, 1_600)]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
assert_eq!(live_span(1_000, None, None, 0), None);
@@ -1731,6 +1807,60 @@ mod open_tests {
assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn stored_media_paths_are_followed_only_on_this_computer() {
let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &EncodeOptions::default()).unwrap()).unwrap();
let mut saved = Session::default();
let t = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let src = add_source(&mut saved, "k", audio, Some("../k.wav"), FileFormat::Wav);
let id = saved.new_clip_id();
crate::edit::place_clip(&mut saved, t, Clip::audio(id, "k", src, 0, 0, 480));
// In a folder of its own, so a movie named `k.wav` is not relinked to a file beside it.
std::fs::create_dir_all(dir.join("s")).unwrap();
let path = dir.join("s").join("Song.scraft").to_string_lossy().into_owned();
let mut stored = vec![r"\\192.0.2.1\share\x.wav".to_string(), "file://192.0.2.1/s/x.wav".to_string()];
// Linux reads `//tmp/…` as the local `/tmp/…`, so only the path check keeps it from being read.
if cfg!(unix) {
stored.push(format!("/{}", dir.join("k.wav").display()));
}
let refused = |m: &str| m.contains("not opened: the path is on another computer");
for p in stored {
let mut s = saved.clone();
s.sources[0].path.clone_from(&p);
let id = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id,
name: "x".into(),
path: p.clone(),
width: 0,
height: 0,
frame_rate: 0.0,
duration: 0.0,
codec: String::new(),
offline: None,
});
std::fs::write(&path, s.to_json().unwrap()).unwrap();
let mut r = Engine::default();
let missing = open_session(&mut r, &path).unwrap();
assert_eq!(missing.iter().filter(|m| refused(m)).count(), 2, "{p}: {missing:?}");
assert!(r.session().pool.get(r.session().sources[0].id).is_none(), "{p}");
assert!(r.session().videos[0].offline.is_some(), "{p}");
let mut into = Engine::default();
let imported = into.execute("file.import_session_data", &json!({"path": path})).unwrap();
assert!(imported["missing"].as_array().unwrap().iter().any(|m| m.as_str().is_some_and(refused)), "{p}: {imported}");
assert!(into.session().pool.get(into.session().sources[0].id).is_none(), "{p}");
}
// A path relative to the session opens.
std::fs::write(&path, saved.to_json().unwrap()).unwrap();
let mut r = Engine::default();
assert!(open_session(&mut r, &path).unwrap().is_empty());
assert!(r.session().pool.get(r.session().sources[0].id).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
+1 -1
View File
@@ -40,7 +40,7 @@ fn install_fixture_now() {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::copy(target.join("debug").join(lib), dir.join("Fixture.clap")).unwrap();
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 5);
}
#[test]
+12
View File
@@ -695,6 +695,7 @@ mod tests {
frame_rate: 23.976,
duration: 12.5,
codec: "prores".into(),
offline: None,
});
let cid = s.new_clip_id();
if let Some(p) = s.track_mut(t).and_then(Track::playlist_mut) {
@@ -749,6 +750,17 @@ mod tests {
assert_eq!((s.sample_rate, s.tempo), (SampleRate::HZ_48000, TempoMap::default()));
}
#[test]
fn a_meter_change_far_beyond_the_position_range_is_dropped_on_load() {
let mut v = serde_json::to_value(Session::default()).unwrap();
v["tempo"]["meters"] = serde_json::json!([
{"tick": 0, "numerator": 4, "denominator": 4},
{"tick": i64::MAX, "numerator": 3, "denominator": 4}
]);
let s = Session::from_json(&v.to_string()).unwrap();
assert_eq!(s.parse_position("2|1|000", TimeFormat::BarsBeats), Ok(96_000));
}
#[test]
fn rejects_garbage_and_future_versions() {
assert!(Session::from_json("not json").is_err());
+3
View File
@@ -46,6 +46,9 @@ pub struct VideoSource {
/// Codec id (`h264`, `prores`, `mjpeg`, `hevc`, …).
#[serde(default)]
pub codec: String,
/// Why the movie is not opened, when its stored path was refused on opening the session.
#[serde(skip)]
pub offline: Option<String>,
}
impl VideoSource {
+6
View File
@@ -726,6 +726,12 @@ impl Player {
}
}
/// The plugin's own text for a parameter value when SoundCraft's format would be wrong for it
/// (see `PluginEditor::value_text`). Main thread only.
pub fn editor_value_text(&self, track: TrackId, slot: usize, id: &str, value: f32) -> Option<String> {
self.side().editors.get_mut(&(track, slot))?.value_text(id, value)
}
/// Start playback at `from`, stopping at `end`, or looping `looped`.
pub fn play(&self, from: Samples, end: Option<Samples>, looped: Option<Range>) {
self.shared.position.store(from, Ordering::Relaxed);
+2 -1
View File
@@ -43,7 +43,8 @@ impl TimeFormat {
fn min_secs(s: Samples, sr: SampleRate) -> String {
let neg = s < 0;
let ms_total = (sr.seconds(s.saturating_abs()) * 1000.0).floor() as i64;
// Integer maths: a float `seconds * 1000` lands just below exact millisecond positions.
let ms_total = i64::try_from(i128::from(s).abs() * 1000 / i128::from(sr.hz().max(1))).unwrap_or(i64::MAX);
let mins = ms_total / 60_000;
let secs = (ms_total / 1000) % 60;
let ms = ms_total % 1000;
+16 -5
View File
@@ -1,6 +1,6 @@
//! Tempo and meter maps: the conversion between musical time (ticks) and samples.
use crate::{SampleRate, Samples, TimeError, to_samples};
use crate::{MAX_POSITION, SampleRate, Samples, TimeError, to_samples};
/// Ticks per quarter note (Pro Tools' resolution).
pub const TICKS_PER_QUARTER: i64 = 960;
@@ -74,17 +74,17 @@ impl TempoMap {
&self.meters
}
/// Repair a map read from a file: only valid tempos and meters, in tick order, with one of each
/// at tick 0.
/// Repair a map read from a file: only valid tempos and meters within `0..=MAX_POSITION` ticks, in
/// tick order, with one of each at tick 0.
pub fn sanitize(&mut self) {
self.tempos.retain(|e| e.tick >= 0 && valid_bpm(e.bpm).is_ok());
self.tempos.retain(|e| (0..=MAX_POSITION).contains(&e.tick) && valid_bpm(e.bpm).is_ok());
self.tempos.sort_by_key(|e| e.tick);
self.tempos.dedup_by_key(|e| e.tick);
if self.tempos.first().is_none_or(|e| e.tick > 0) {
let bpm = self.tempos.first().map_or(120.0, |e| e.bpm);
self.tempos.insert(0, TempoEvent { tick: 0, bpm });
}
self.meters.retain(|m| m.tick >= 0 && valid_meter(m.numerator, m.denominator));
self.meters.retain(|m| (0..=MAX_POSITION).contains(&m.tick) && valid_meter(m.numerator, m.denominator));
self.meters.sort_by_key(|m| m.tick);
self.meters.dedup_by_key(|m| m.tick);
if self.meters.first().is_none_or(|m| m.tick > 0) {
@@ -386,6 +386,17 @@ mod tests {
assert_eq!(empty, TempoMap::default());
}
#[test]
fn sanitize_drops_events_beyond_the_position_range() {
let mut m = TempoMap {
tempos: vec![TempoEvent { tick: 0, bpm: 120.0 }, TempoEvent { tick: i64::MAX, bpm: 90.0 }],
meters: vec![MeterEvent { tick: 0, numerator: 4, denominator: 4 }, MeterEvent { tick: i64::MAX, numerator: 3, denominator: 4 }],
};
m.sanitize();
assert_eq!(m, TempoMap::default());
assert_eq!(m.bar_start_tick(2), TICKS_PER_QUARTER * 4);
}
#[test]
fn rejects_bad_values() {
let mut m = TempoMap::default();
+36
View File
@@ -0,0 +1,36 @@
//! Min:Secs shows exact millisecond positions exactly (no float-floor off-by-one).
use soundcraft_time::{FrameRate, SampleRate, TempoMap, TimeFormat, format_position};
fn min_secs(s: i64, sr: SampleRate) -> String {
format_position(s, TimeFormat::MinSecs, sr, &TempoMap::default(), FrameRate::default(), 0, 0)
}
fn expected(ms_total: i64) -> String {
format!("{}:{:02}.{:03}", ms_total / 60_000, (ms_total / 1000) % 60, ms_total % 1000)
}
#[test]
fn known_positions_at_44100() {
assert_eq!(min_secs(88_641, SampleRate::HZ_44100), "0:02.010");
assert_eq!(min_secs(89_523, SampleRate::HZ_44100), "0:02.030");
}
#[test]
fn negative_position_at_44100() {
assert_eq!(min_secs(-88_641, SampleRate::HZ_44100), "-0:02.010");
}
#[test]
fn every_exact_millisecond_displays_exactly() {
for sr in [SampleRate::HZ_44100, SampleRate::HZ_48000] {
let hz = i64::from(sr.hz());
for ms in 0..=600_000_i64 {
if (ms * hz) % 1000 != 0 {
continue;
}
let s = ms * hz / 1000;
assert_eq!(min_secs(s, sr), expected(ms), "{hz} Hz, sample {s}");
}
}
}
+82 -14
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, GroupId, Session, SourceId, TrackId};
use soundcraft_time::Samples;
#[derive(Debug, Clone)]
@@ -23,7 +23,8 @@ pub enum Dialog {
Spot(crate::spot::Spot),
Fades { shape: String },
StripSilence { threshold: f64, min_ms: f64, pre_ms: f64, post_ms: f64 },
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
// `id: None` creates (Track › Group…); `id: Some` modifies that group (Groups list › Modify Group…).
Group { id: Option<GroupId>, 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) },
@@ -34,6 +35,8 @@ pub struct Dialogs {
pub open: Option<Dialog>,
/// Runs once the session has been saved from `SaveChanges`.
after_save: Option<(String, Value)>,
/// A session the browser read while the open one had changes, waiting for `SaveChanges`.
pending_session: Option<(String, Vec<u8>)>,
}
impl Dialogs {
@@ -52,7 +55,8 @@ impl Dialogs {
Dialog::Spot(_) => "spot",
Dialog::Fades { .. } => "fades",
Dialog::StripSilence { .. } => "strip_silence",
Dialog::Group { .. } => "group",
Dialog::Group { id: None, .. } => "group",
Dialog::Group { id: Some(_), .. } => "modify_group",
Dialog::Session { .. } => "session",
Dialog::ScoreSetup { .. } => "score_setup",
Dialog::SaveChanges { .. } => "save_changes",
@@ -71,6 +75,16 @@ impl Dialogs {
self.open = Some(Dialog::RenameMarker { id, name: name.to_string() });
}
/// Groups list › Modify Group…: the creation dialog, pre-filled from an existing group. False
/// when the group is gone (deleted between the click and the menu).
pub fn open_modify_group(&mut self, e: &Engine, id: GroupId) -> bool {
let s = e.session();
let Some(g) = s.group(id) else { return false };
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), g.members.contains(&t.id))).collect();
self.open = Some(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members });
true
}
/// 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 };
@@ -131,7 +145,7 @@ impl Dialogs {
"track.group" => {
let s = e.session();
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), s.edit.selected_tracks.contains(&t.id))).collect();
Dialog::Group { name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members }
Dialog::Group { id: None, name: format!("Group {}", s.groups.len() + 1), edit: true, mix: true, members }
}
"setup.session" => Dialog::Session { frame_rate: e.session().frame_rate.label().into(), bit_depth: "24".into() },
"file.score_setup" => {
@@ -155,7 +169,7 @@ 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")
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit" | OPEN_PENDING)
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
{
return false;
@@ -178,6 +192,34 @@ pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value,
app.run(id, params)
}
/// What `SaveChanges` carries on with to open the session the browser read.
const OPEN_PENDING: &str = "ui.open_pending_session";
/// Opens a session the browser read as bytes (picked or dropped), asking first when the open one
/// has changes. The read finishes later than the pick, so the question is asked only now.
pub fn open_session_bytes(app: &mut SoundApp, name: String, bytes: Vec<u8>) {
if crate::bounce::refuses_session_switch(app) {
return;
}
app.dialogs.pending_session = Some((name, bytes));
if !ask_to_save(app, OPEN_PENDING, &json!({})) {
open_pending_session(app);
}
}
fn open_pending_session(app: &mut SoundApp) {
let Some((name, bytes)) = app.dialogs.pending_session.take() else { return };
if crate::bounce::refuses_session_switch(app) {
return;
}
let _ = app.run("transport.stop", json!({}));
app.ui.status = match soundcraft_engine::io::open_session_bytes(&mut app.engine, &name, &bytes) {
Ok(missing) if missing.is_empty() => format!("Opened {name}"),
Ok(missing) => format!("Opened {name} ({} missing media)", missing.len()),
Err(e) => e.to_string(),
};
}
/// 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 {
@@ -283,7 +325,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Spot(_) => "Spot",
Dialog::Fades { .. } => "Fades",
Dialog::StripSilence { .. } => "Strip Silence",
Dialog::Group { .. } => "Create Group",
Dialog::Group { id: None, .. } => "Create Group",
Dialog::Group { id: Some(_), .. } => "Modify Group",
Dialog::Session { .. } => "Session Setup",
Dialog::ScoreSetup { .. } => "Score Setup",
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
@@ -508,7 +551,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
));
}
}
Dialog::Group { name, edit, mix, members } => {
Dialog::Group { id, name, edit, mix, members } => {
ui.horizontal(|ui| {
ui.label("Name");
ui.text_edit_singleline(name);
@@ -523,9 +566,18 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
crate::widgets::checkbox(ui, on, n.as_str());
}
});
if buttons(ui, "OK", enter) {
let ids: Vec<u64> = members.iter().filter(|m| m.2).map(|m| m.0).collect();
action = Some(("track.group".into(), json!({"name": name, "edit": edit, "mix": mix, "tracks": ids})));
let ids: Vec<u64> = members.iter().filter(|m| m.2).map(|m| m.0).collect();
// Both commands reject an empty group, so don't offer an OK that can only fail.
if buttons_enabled(ui, "OK", enter, !ids.is_empty()) {
let mut params = json!({"name": name, "edit": edit, "mix": mix, "tracks": ids});
let cmd = match id {
Some(g) => {
params["group"] = json!(g.0);
"track.group_modify"
}
None => "track.group",
};
action = Some((cmd.into(), params));
}
}
Dialog::Session { frame_rate, bit_depth } => {
@@ -594,6 +646,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
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;
app.dialogs.pending_session = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
}
if let Some(then) = save_first {
@@ -613,7 +666,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
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) {
match run_then(app, &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());
}
@@ -624,7 +677,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
if saving
&& let Some((then, params)) = app.dialogs.after_save.take()
&& !app.engine.is_dirty()
&& let Err(e) = app.run(&then, params)
&& let Err(e) = run_then(app, &then, params)
{
app.ui.status = e;
}
@@ -633,15 +686,30 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
/// Runs a dialog's command, or what `SaveChanges` carries on with.
fn run_then(app: &mut SoundApp, cmd: &str, params: Value) -> Result<Value, String> {
if cmd == OPEN_PENDING {
open_pending_session(app);
return Ok(Value::Null);
}
app.run(cmd, params)
}
/// Cancel/OK row; returns true when OK was pressed (or Enter).
fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
let mut pressed = enter;
buttons_enabled(ui, ok, enter, true)
}
/// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works.
fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
let mut pressed = enter && ready;
ui.separator();
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::new("dlg_cancel"), true));
}
if ui.add(egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent)).clicked() {
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
if ui.add_enabled(ready, b).clicked() {
pressed = true;
}
});
+154 -16
View File
@@ -1798,23 +1798,13 @@ fn crossfade_partner<'a>(track: &'a Track, clip: &Clip) -> Option<&'a Clip> {
fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &AutoParam, tl: Rect, lane: Rect) {
let t = Tokens::current();
let (lo, hi, _) = p.range();
let (lo, hi) = if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
(-60.0f32, 12.0f32)
} else if matches!(p, AutoParam::Plugin { .. }) {
plugin_range(track, p)
} else {
(lo, hi)
};
let range = lane_range(track, p);
let def = match p {
AutoParam::Volume => track.mixer.volume_db,
AutoParam::Pan(i) => track.mixer.pan.get(usize::from(*i)).copied().unwrap_or(0.0),
_ => 0.0,
};
let yv = |v: f32| -> f32 {
let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0);
lane.max.y - 4.0 - k * (lane.height() - 8.0)
};
let yv = |v: f32| auto_y(lane, range, v);
let lanes = track.lane(p);
let pts: Vec<(f32, f32)> = match lanes {
Some(l) if !l.points.is_empty() => {
@@ -1844,6 +1834,47 @@ fn draw_automation(painter: &egui::Painter, s: &Session, track: &Track, p: &Auto
painter.text(pos2(lane.min.x + 6.0, lane.min.y + 9.0), Align2::LEFT_CENTER, p.label(), regular(10.0), t.text_dim);
}
/// The values an automation lane spans, bottom to top.
fn lane_range(track: &Track, p: &AutoParam) -> (f32, f32) {
if matches!(p, AutoParam::Volume | AutoParam::SendLevel(_)) {
(-60.0, 12.0)
} else if matches!(p, AutoParam::Plugin { .. }) {
plugin_range(track, p)
} else {
let (lo, hi, _) = p.range();
(lo, hi)
}
}
fn auto_y(lane: Rect, (lo, hi): (f32, f32), v: f32) -> f32 {
let k = ((v.clamp(lo, hi) - lo) / (hi - lo).max(1e-6)).clamp(0.0, 1.0);
lane.max.y - 4.0 - k * (lane.height() - 8.0)
}
fn auto_value(lane: Rect, (lo, hi): (f32, f32), y: f32) -> f32 {
let k = ((lane.max.y - 4.0 - y) / (lane.height() - 8.0)).clamp(0.0, 1.0);
lo + (hi - lo) * k
}
/// The automation a track's lane draws: its view's, or volume on the tracks that always show it.
fn shown_automation(track: &Track) -> Option<AutoParam> {
AutoParam::parse(&track.view).or_else(|| matches!(track.kind, TrackKind::Master | TrackKind::Aux | TrackKind::Vca).then_some(AutoParam::Volume))
}
/// The drawn breakpoint nearest `pos`, within grabbing distance.
fn breakpoint_at(s: &Session, tl: Rect, lane: Rect, track: &Track, p: &AutoParam, pos: Pos2) -> Option<Samples> {
let range = lane_range(track, p);
let dist = |at: Samples, v: f32| pos2(x_of(s, tl, at), auto_y(lane, range, v)).distance(pos);
track
.lane(p)?
.points
.iter()
.map(|pt| (pt.at, dist(pt.at, pt.value)))
.filter(|(_, d)| *d <= 6.0)
.min_by(|a, b| a.1.total_cmp(&b.1))
.map(|(at, _)| at)
}
fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) {
if let AutoParam::Plugin { slot, param } = p
&& let Some(Some(ins)) = track.mixer.inserts.get(usize::from(*slot))
@@ -1977,6 +2008,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
let origin = if resp.drag_started() { ui.input(|i| i.pointer.press_origin()).unwrap_or(p) } else { p };
let at_origin = sample_at(&s, tl, origin.x).max(0);
let auto_view = AutoParam::parse(&track.view);
let point_hit = if matches!(tool, Tool::Smart | Tool::Grabber | Tool::Pencil) {
shown_automation(track).and_then(|param| breakpoint_at(&s, tl, lane, track, &param, origin).map(|at| (param, at)))
} else {
None
};
let body_hit = clip_at(track, at_origin);
// Check fade endpoints before clip bodies: a full-length fade can end exactly
// at the next clip's start, where `clip_at` would otherwise choose that neighbour.
@@ -2008,8 +2044,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
None
}
};
// Effective tool under the Smart tool.
// Effective tool under the Smart tool; on a breakpoint every tool that edits one grabs it.
let eff = match tool {
_ if point_hit.is_some() => Tool::Grabber,
Tool::Smart => {
if let Some(c) = &hit {
if near_edge(c).is_some() {
@@ -2036,7 +2073,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
};
ui.ctx().set_cursor_icon(icon);
}
let fade_corner = if tool == Tool::Smart {
let fade_corner = if tool == Tool::Smart && point_hit.is_none() {
fade_handle.map(|(_, side)| side).or_else(|| {
hit.as_ref().and_then(|c| {
let xs = x_of(&s, tl, c.start);
@@ -2074,7 +2111,11 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
place_insertion(app, ui, &s, track.id, at, mods.shift);
}
let gain_drag = if resp.drag_started() { gain_icon_at(origin) } else { None };
if let Some(c) = gain_drag {
if resp.drag_started()
&& let Some((param, at)) = &point_hit
{
app.gesture = Some(Gesture::Breakpoint { track: track.id, param: param.clone(), at: *at });
} else if let Some(c) = gain_drag {
// `clip.gain` refuses to run with nothing selected.
if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() {
let _ = app.engine.execute("edit.select", &json!({"clips": [c.id.0]}));
@@ -2141,7 +2182,15 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
clip_gain_popup(ui.ctx(), *fader_bottom, *pos);
}
if resp.clicked() && curve_handle.is_none() {
if resp.clicked()
&& let Some((param, at)) = &point_hit
{
// Alt-click deletes a breakpoint, as in Pro Tools.
if mods.alt {
let _ =
app.run("automation.clear", json!({"track": track.id.0, "param": automation_id(param), "start": at, "end": at.saturating_add(1)}));
}
} else if resp.clicked() && curve_handle.is_none() {
match eff {
Tool::Grabber => {
if let Some(c) = &hit {
@@ -2187,6 +2236,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
}
if resp.double_clicked()
&& curve_handle.is_none()
&& point_hit.is_none()
&& eff != Tool::Pencil
&& let Some(c) = &hit
{
@@ -2300,6 +2350,29 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) {
*last = at;
let _ = app.engine.execute("transport.locate", &json!({"at": at}));
}
Some(Gesture::Breakpoint { track, param, at: from }) => {
if let Some(t) = s.track(*track)
&& let Some((_, row)) = rows.iter().find(|(i, _)| *i == t.id.0)
&& let Some(points) = t.lane(param).map(|l| l.points.as_slice())
&& let Some(i) = points.iter().position(|pt| pt.at == *from)
{
let lane = lane_rect(t, rect(*row), tl);
let after = i.checked_sub(1).and_then(|j| points.get(j)).map_or(0, |pt| pt.at.saturating_add(1));
let before = points.get(i + 1).map_or(Samples::MAX, |pt| pt.at.saturating_sub(1));
let to = snap(&s, at).clamp(after, before.max(after));
let value = auto_value(lane, lane_range(t, param), p.y);
if to != *from || points.get(i).is_some_and(|pt| (pt.value - value).abs() > 1e-6) {
let (key, pid) = (format!("breakpoint:{}", track.0), automation_id(param));
if to != *from {
let clear = json!({"track": track.0, "param": pid, "start": *from, "end": from.saturating_add(1)});
let _ = app.engine.execute_merged("automation.clear", &clear, &key);
}
let _ =
app.engine.execute_merged("automation.set_point", &json!({"track": track.0, "param": pid, "at": to, "value": value}), &key);
*from = to;
}
}
}
Some(Gesture::Pencil { track, points }) => {
if let Some(t) = s.track(*track)
&& let Some(param) = AutoParam::parse(&t.view)
@@ -3893,4 +3966,69 @@ mod tests {
assert_eq!(points.len(), 1, "status: {}", h.state().ui.status);
assert!((points[0].value + 12.0).abs() < 0.01, "{}", points[0].value);
}
/// A volume lane over a clip, with two breakpoints low enough that the Smart tool would grab
/// the clip there: the screen position of each, and the clips as they were.
fn breakpoints_over_a_clip(tool: &str) -> (Harness<'static, SoundApp>, TrackId, [Pos2; 2], Vec<Clip>) {
let mut engine = soundcraft_engine::demo::demo_engine();
engine.execute("edit.tool", &json!({"tool": tool})).unwrap();
engine.execute("edit.mode", &json!({"mode": "shuffle"})).unwrap();
let mut h = harness_with(engine);
let tl = timeline(&h);
let (clip, _) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 200.0);
let track = h.state().engine.session().find_clip(clip.id).unwrap().0;
let (xs, xe) = (x_of(h.state().engine.session(), tl, clip.start), x_of(h.state().engine.session(), tl, clip.end()));
let xs_at = [xs + (xe - xs) * 0.3, xs + (xe - xs) * 0.6];
for x in xs_at {
let at = sample_at(h.state().engine.session(), tl, x);
h.state_mut().engine.execute("automation.set_point", &json!({"track": track.0, "param": "volume", "at": at, "value": -40.0})).unwrap();
}
h.state_mut().engine.execute("track.view", &json!({"track": track.0, "view": "volume"})).unwrap();
h.run_steps(2);
let s = h.state().engine.session_arc();
let t = s.track(track).unwrap();
let i = h.state().edit_layout.rows.iter().position(|(id, _)| *id == track.0).unwrap();
let lane = lane_rect(t, row(&h, i), tl);
let y = lane.max.y - 4.0 - (lane.height() - 8.0) * (20.0 / 72.0);
assert!(y > lane.center().y, "the breakpoints sit where the Smart tool grabs");
let at = t.lane(&AutoParam::Volume).unwrap().points.iter().map(|p| pos2(x_of(&s, tl, p.at), y)).collect::<Vec<_>>();
(h, track, [at[0], at[1]], t.clips().to_vec())
}
fn volume_points(h: &Harness<SoundApp>, track: TrackId) -> Vec<(Samples, f32)> {
let s = h.state().engine.session();
s.track(track).and_then(|t| t.lane(&AutoParam::Volume)).map(|l| l.points.iter().map(|p| (p.at, p.value)).collect()).unwrap_or_default()
}
#[test]
fn dragging_a_breakpoint_moves_it_not_the_clip_under_it() {
for tool in ["smart", "grabber"] {
let (mut h, track, [a, b], clips) = breakpoints_over_a_clip(tool);
let before = volume_points(&h, track);
let to = b + vec2(-40.0, -15.0);
drag(&mut h, b, to);
let s = h.state().engine.session_arc();
assert_eq!(s.track(track).unwrap().clips(), clips.as_slice(), "{tool}: the clip stays");
let points = volume_points(&h, track);
assert_eq!(points.len(), 2, "{tool}: {points:?}");
assert_eq!(points[0], before[0], "{tool}: the other breakpoint stays");
let want = sample_at(&s, timeline(&h), to.x);
assert!((points[1].0 - want).abs() as f64 <= s.edit.zoom.samples_per_px, "{tool}: {} vs {want}", points[1].0);
assert!(points[1].1 > -40.0 + 1.0, "{tool}: dragged up, {}", points[1].1);
assert!(x_of(&s, timeline(&h), points[1].0) > a.x, "{tool}");
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(volume_points(&h, track), before, "{tool}: one undo step");
}
}
#[test]
fn alt_click_on_a_breakpoint_deletes_it() {
for tool in ["smart", "grabber", "pencil"] {
let (mut h, track, [_, b], clips) = breakpoints_over_a_clip(tool);
let before = volume_points(&h, track);
click_with(&mut h, b, egui::Modifiers::ALT);
assert_eq!(volume_points(&h, track), before[..1], "{tool}");
assert_eq!(h.state().engine.session().track(track).unwrap().clips(), clips.as_slice(), "{tool}");
}
}
}
+4
View File
@@ -12,8 +12,10 @@ const REGULAR: &[(&str, u32)] = &[
("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 0),
("/usr/share/fonts/TTF/DejaVuSans.ttf", 0),
("/usr/share/fonts/dejavu/DejaVuSans.ttf", 0),
("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf", 0),
("/usr/local/share/fonts/dejavu/DejaVuSans.ttf", 0),
("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", 0),
("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf", 0),
];
const BOLD: &[(&str, u32)] = &[
("/System/Library/Fonts/HelveticaNeue.ttc", 1),
@@ -22,8 +24,10 @@ const BOLD: &[(&str, u32)] = &[
("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/dejavu-sans-fonts/DejaVuSans-Bold.ttf", 0),
("/usr/local/share/fonts/dejavu/DejaVuSans-Bold.ttf", 0),
("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", 0),
("/usr/share/fonts/liberation-sans-fonts/LiberationSans-Bold.ttf", 0),
];
/// Hangul fallback, appended after every family so Korean session, track and clip names draw
/// whatever the interface language. The Noto Sans CJK collection's face 1 is its Korean face.
+26
View File
@@ -240,6 +240,7 @@ pub enum Gesture {
Fader { track: TrackId, start_db: f32 },
ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 },
Pencil { track: TrackId, points: Vec<(Samples, f32)> },
Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples },
MidiClip { track: TrackId, from: Samples, to: Samples },
ZoomBox { start: f32 },
}
@@ -301,6 +302,8 @@ pub struct SoundApp {
synthetic_grace: u32,
control_rx: Option<Receiver<ControlRequest>>,
pending_shots: Vec<control::PendingShot>,
/// An insert just picked from its menu, opened once the audio engine has its instance.
pub(crate) pending_open: Option<(TrackId, usize)>,
last_rev: u64,
fonts_ready: bool,
fonts_installed: bool,
@@ -384,6 +387,7 @@ impl SoundApp {
synthetic_grace: 0,
control_rx: None,
pending_shots: Vec::new(),
pending_open: None,
last_rev: 0,
fonts_ready: false,
fonts_installed: false,
@@ -617,6 +621,27 @@ impl SoundApp {
}
}
/// Opens the insert just picked from its menu: a hosted plugin's own editor, else (a built-in
/// plugin, no audio engine, or an editor that can't open here) its plugin window.
fn open_inserted(&mut self) {
let Some((track, slot)) = self.pending_open.take() else { return };
let Some(plugin) = self.engine.session().track(track).and_then(|t| t.mixer.inserts.get(slot).cloned().flatten()).map(|i| i.plugin) else {
return;
};
if soundcraft_mix::is_third_party(&plugin)
&& let Some(p) = &self.player
&& p.has_editor(track, slot)
{
match p.open_editor(track, slot) {
Ok(()) => return,
Err(e) => self.ui.status = format!("{}: {e}", mix_window::plugin_info(&plugin).map_or(plugin.as_str(), |i| i.name)),
}
}
if !self.ui.plugin_windows.contains(&(track, slot)) {
self.ui.plugin_windows.push((track, slot));
}
}
/// Parameter edits made in plugin editors go into the session (one undo step per gesture).
fn apply_editor_edits(&mut self) {
let Some(p) = &self.player else { return };
@@ -1153,6 +1178,7 @@ impl SoundApp {
p.update_session(self.engine.session_arc());
}
}
self.open_inserted();
self.autosave_changes();
self.write_automation(ctx);
self.autosave();
+32
View File
@@ -243,6 +243,9 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
if c.path == "File > Open" {
crate::recent::menu(app, ui);
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
}
}
if root.label == "AudioSuite" {
ui.separator();
@@ -276,6 +279,23 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
);
}
/// Track › Instrument: the instrument picker for the selected instrument track (#374).
fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) {
match instrument_track(app.engine.session()) {
Some(id) => {
ui.menu_button("Instrument", |ui| crate::mix_window::instrument_menu(app, ui, id));
}
None => {
ui.add_enabled(false, egui::Button::new("Instrument")).on_disabled_hover_text("Select an instrument track");
}
}
}
/// The first selected instrument track, in track order.
fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::TrackId> {
s.tracks.iter().find(|t| t.kind == soundcraft_model::TrackKind::Instrument && s.edit.selected_tracks.contains(&t.id)).map(|t| t.id)
}
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
if !n.children.is_empty() {
ui.menu_button(&n.label, |ui| {
@@ -720,4 +740,16 @@ mod tests {
assert!(!app.engine.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn track_instrument_menu_targets_the_selected_instrument_track() {
let mut e = soundcraft_engine::Engine::default();
let audio = e.execute("track.new", &json!({"kind": "audio"})).unwrap()["tracks"][0].as_u64().unwrap();
assert_eq!(instrument_track(e.session()), None);
let inst = e.execute("track.new", &json!({"kind": "instrument"})).unwrap()["tracks"][0].as_u64().unwrap();
e.execute("edit.select", &json!({"tracks": [audio]})).unwrap();
assert_eq!(instrument_track(e.session()), None, "an audio track has no instrument");
e.execute("edit.select", &json!({"tracks": [audio, inst]})).unwrap();
assert_eq!(instrument_track(e.session()).map(|t| t.0), Some(inst));
}
}
+13 -3
View File
@@ -620,7 +620,7 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
let search = MenuSearch::show(ui, egui::Id::new(("plugin-search", id.0, slot)), "Search plugins…");
if search.typing() {
if let Some(plugin) = plugin_search(ui, &search, false) {
run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin}));
pick_insert(app, ui, id, slot, &plugin);
}
return;
}
@@ -643,13 +643,13 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
ui.menu_button(format!("{c:?}"), |ui| {
for p in soundcraft_dsp::plugins().iter().filter(|p| p.category == c && !p.is_instrument) {
if ui.button(p.name).clicked() {
run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": p.id}));
pick_insert(app, ui, id, slot, p.id);
}
}
});
}
if let Some(plugin) = hosted_menus(ui, false) {
run_pick(app, ui, "mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin}));
pick_insert(app, ui, id, slot, &plugin);
}
}
@@ -707,6 +707,16 @@ pub fn instrument_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
}
}
/// Inserts the plugin picked in an insert's menu and, as DAWs do, opens it once the audio engine has
/// it (`SoundApp::open_inserted`).
fn pick_insert(app: &mut SoundApp, ui: &Ui, id: TrackId, slot: usize, plugin: &str) {
match app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": plugin})) {
Ok(_) => app.pending_open = Some((id, slot)),
Err(e) => app.ui.status = e,
}
ui.close();
}
/// Runs a menu's pick, shows its error in the status line and closes the menu.
fn run_pick(app: &mut SoundApp, ui: &Ui, cmd: &str, params: serde_json::Value) {
if let Err(e) = app.run(cmd, params) {
+12 -1
View File
@@ -66,6 +66,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let out = lists.show(ui, |ui| {
for (id, name, shown, color, sel) in tracks {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click_and_drag());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, sel, &name));
if sel {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
@@ -94,6 +95,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::hover());
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "<ALL>", regular(11.5).clone(), t.text);
let all = ui.interact(r, ui.id().with("group_all"), Sense::click());
all.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "<ALL>"));
if all.double_clicked() {
let _ = app.run("edit.select_all", json!({}));
}
@@ -105,6 +107,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
});
for (id, letter, name, active, color) in groups {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), Sense::click());
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, true, active, &name));
if active {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 4.0, r.min.y + 2.0), vec2(14.0, 13.0)), 1.0, rgb(color));
}
@@ -133,6 +136,10 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.run("track.group_toggle", json!({"group": id}));
ui.close();
}
if ui.button("Modify Group…").clicked() {
let _ = app.dialogs.open_modify_group(&app.engine, soundcraft_model::GroupId(id));
ui.close();
}
if ui.button("Delete Group").clicked() {
let _ = app.run("track.ungroup", json!({"group": id}));
ui.close();
@@ -544,7 +551,11 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
}
let _ = resp;
let shown = if instrument { crate::sampler_window::format_param(&ins.plugin, p, x) } else { p.format(x) };
let shown = if instrument {
crate::sampler_window::format_param(&ins.plugin, p, x)
} else {
app.player.as_ref().and_then(|pl| pl.editor_value_text(tid, slot, p.id, x)).unwrap_or_else(|| p.format(x))
};
ui.label(egui::RichText::new(shown).font(mono(11.0)));
ui.end_row();
}
+9 -3
View File
@@ -404,8 +404,9 @@ pub fn movie_secs(s: &Session, clip_start: Samples, offset: Samples, at: Samples
(at.saturating_sub(clip_start).saturating_add(offset) as f64 - sync) / s.sample_rate.as_f64().max(1.0)
}
/// The picture at `at`: the topmost active Video track with an unmuted clip there.
pub fn picture_at(s: &Session, at: Samples) -> Option<(MovieKey, String, f64, f64)> {
/// The picture at `at`: the topmost active Video track with an unmuted clip there. The path is
/// the movie's, or why it is offline.
pub fn picture_at(s: &Session, at: Samples) -> Option<(MovieKey, Result<String, String>, f64, f64)> {
for t in s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Video && !t.inactive) {
for c in t.clips() {
if c.muted || !c.range().contains(at) {
@@ -414,7 +415,8 @@ pub fn picture_at(s: &Session, at: Samples) -> Option<(MovieKey, String, f64, f6
if let ClipContent::Video { source, offset } = c.content
&& let Some(v) = s.video(source)
{
return Some((MovieKey::new(source, &v.path), v.path.clone(), movie_secs(s, c.start, offset, at), v.frame_rate));
let path = v.offline.clone().map_or_else(|| Ok(v.path.clone()), Err);
return Some((MovieKey::new(source, &v.path), path, movie_secs(s, c.start, offset, at), v.frame_rate));
}
}
}
@@ -450,6 +452,10 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra
p.text(vis.center(), egui::Align2::CENTER_CENTER, "Video Track Offline", regular(10.0), Color32::from_gray(170));
continue;
}
if let Some(why) = &v.offline {
p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {why}"), regular(10.0), Color32::from_rgb(220, 120, 120));
continue;
}
let key = MovieKey::new(source, &v.path);
if let Some(Err(e)) = app.video.info(key) {
p.text(vis.center(), egui::Align2::CENTER_CENTER, format!("media offline: {e}"), regular(10.0), Color32::from_rgb(220, 120, 120));
+4 -2
View File
@@ -24,7 +24,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
let s = app.engine.session_arc();
let pic = picture_at(&s, pos);
let on = online(&s);
if on && let Some((key, path, secs, fps)) = &pic {
if on && let Some((key, Ok(path), secs, fps)) = &pic {
app.video.request_frame(ctx, *key, path, *secs, *fps);
}
let aspect = pic
@@ -39,6 +39,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
ui.horizontal(|ui| {
crate::widgets::checkbox(ui, &mut burn, "Timecode burn-in");
let text = match &pic {
Some((_, Err(why), ..)) => why.clone(),
Some((k, ..)) => match app.video.info(*k) {
Some(Ok(i)) => format!("{}x{} {:.3} fps {}", i.width, i.height, i.frame_rate, i.codec_detail),
Some(Err(e)) => e.clone(),
@@ -64,6 +65,7 @@ fn video(app: &mut SoundApp, ctx: &egui::Context) {
} else {
match &pic {
None => None,
Some((_, Err(why), ..)) => Some(why.clone()),
Some((k, ..)) => match (app.video.current(*k), app.video.current_error(*k)) {
(Some(tex), _) => {
painter.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -130,7 +132,7 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
let key = MovieKey::new(source, &v.path);
let cr = Rect::from_min_max(pos2(x_at(c.start), r.min.y + 4.0), pos2(x_at(c.end()).max(x_at(c.start) + 2.0), r.max.y - 4.0));
painter.rect_filled(cr, 0.0, Color32::from_rgb(30, 30, 34));
if !on {
if !on || v.offline.is_some() {
continue;
}
let tw = (cr.height() * v.aspect() as f32).max(8.0);
@@ -11,7 +11,6 @@ use std::path::Path;
/// Known gaps: commands with no menu, shortcut or literal reference in the UI source today.
/// Give each one a UI path and remove it from this list; don't add to it.
const KNOWN_UNREACHED: &[&str] = &[
"automation.clear",
"edit.paste_clip_effects",
"edit.paste_clip_gain",
"event.midi_note",
+41
View File
@@ -42,3 +42,44 @@ fn dragging_down_the_group_dialogs_track_list_adds_each_track() {
let members: Vec<u64> = session.groups.last().map(|g| g.members.iter().map(|m| m.0).collect()).unwrap_or_default();
assert_eq!(members, tracks[..4].iter().map(|t| t.0).collect::<Vec<_>>());
}
/// The Groups list's Modify Group… reopens the creation dialog on an existing group: it comes up
/// pre-filled, and OK edits that group in place instead of adding a second one.
#[test]
fn modify_group_dialog_edits_the_existing_group() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let tracks: Vec<(u64, String)> = app.engine.session().tracks.iter().map(|t| (t.id.0, t.name.clone())).collect();
assert!(tracks.len() >= 2);
let g = app.run("track.group", json!({"tracks": [tracks[0].0], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
assert!(app.dialogs.open_modify_group(&app.engine, soundcraft_model::GroupId(g)));
assert_eq!(app.dialogs.open_name(), Some("modify_group"));
// Pre-filled from the group, not from the track selection.
let Some(soundcraft_ui_egui::dialogs::Dialog::Group { id, name, edit, mix, members }) = &app.dialogs.open else {
panic!("Modify Group… did not open the group dialog");
};
assert_eq!((id.map(|i| i.0), name.as_str(), *edit, *mix), (Some(g), "Drums", true, true));
assert_eq!(members.iter().filter(|m| m.2).map(|m| m.0).collect::<Vec<_>>(), vec![tracks[0].0]);
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,
);
h.run_steps(3);
// Add a second track and submit.
h.get_by_role_and_label(Role::CheckBox, &tracks[1].1).click();
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "OK").click();
h.run_steps(2);
let session = h.state().engine.session();
assert_eq!(session.groups.len(), 1, "Modify edits the group in place rather than creating another");
let gr = &session.groups[0];
assert_eq!(gr.id.0, g);
assert_eq!(gr.name, "Drums");
assert_eq!(gr.members.iter().map(|m| m.0).collect::<Vec<_>>(), vec![tracks[0].0, tracks[1].0]);
}
+3
View File
@@ -107,6 +107,9 @@ fn picks_without_the_search_still_close_the_menu() {
assert_eq!(h.state().engine.session().tracks[0].mixer.inserts[2].as_ref().unwrap().plugin, plugin.id);
assert!(h.query_by_label(&category).is_none(), "a plugin from a category closes the menu");
}
// The picks opened the plugin's window (#486), whose Bypass button would match the menu's.
h.state_mut().ui.plugin_windows.clear();
h.run_steps(2);
for (item, bypassed) in [("Bypass", Some(true)), ("no insert", None)] {
h.get_by_label("Insert C").click_secondary();
@@ -177,7 +177,7 @@ fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
harness.step();
}
harness.step(); // Let the popup finish its initial sizing pass.
harness.get_by_label_contains("Built-in").hover();
harness.get_by_label("Built-in ⏵").hover();
harness.step();
harness.step();
harness.get_by_label("Drum Synth").click();
+109
View File
@@ -0,0 +1,109 @@
//! #486: picking a plugin for an insert slot opens it straight away, as DAWs do, from the Mix
//! window's slots and the Edit window's Inserts column alike.
use egui::{Key, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_model::TrackId;
use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
fn harness(engine: soundcraft_engine::Engine, window: MainWindow) -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(engine, None, Services::default());
app.ui.window = window;
app.run("view.edit_inserts_ae", json!({})).unwrap();
app.run("track.height", json!({"tracks": ["Vox"], "height": "medium"})).unwrap();
let mut h = Harness::builder().with_size(vec2(1400.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn vox() -> soundcraft_engine::Engine {
let mut engine = soundcraft_engine::Engine::default();
engine.execute("track.new", &json!({"name": "Vox"})).unwrap();
engine
}
fn vox_id(h: &Harness<SoundApp>) -> TrackId {
h.state().engine.session().tracks[0].id
}
/// Two built-in effects from one category, and the category's menu label.
fn two_effects() -> (String, &'static soundcraft_dsp::PluginInfo, &'static soundcraft_dsp::PluginInfo) {
let first = soundcraft_dsp::plugins().iter().find(|p| !p.is_instrument).unwrap();
let other = soundcraft_dsp::plugins().iter().find(|p| p.category == first.category && p.id != first.id).unwrap();
(format!("{:?} ⏵", first.category), first, other)
}
/// Opens `slot`'s menu (a click on an empty slot, a right-click on a filled one) and picks
/// `plugin` from its category.
fn pick(h: &mut Harness<SoundApp>, slot: &str, filled: bool, category: &str, plugin: &str) {
let node = h.get_by_label(slot);
if filled {
node.click_secondary();
} else {
node.click();
}
h.run_steps(3);
h.get_by_label(category).hover();
h.run_steps(3);
h.get_by_label(plugin).click();
h.run_steps(3);
}
fn inserted(h: &Harness<SoundApp>) -> Option<String> {
h.state().engine.session().tracks[0].mixer.inserts[0].as_ref().map(|i| i.plugin.clone())
}
#[test]
fn picking_a_plugin_for_an_empty_insert_opens_it() {
let (category, plugin, _) = two_effects();
let mut h = harness(vox(), MainWindow::Mix);
pick(&mut h, "Insert A", false, &category, plugin.name);
assert_eq!(inserted(&h).as_deref(), Some(plugin.id));
let id = vox_id(&h);
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)], "the new insert's window opened");
}
#[test]
fn picking_from_the_search_opens_it_too() {
let mut h = harness(vox(), MainWindow::Mix);
h.get_by_label("Insert A").click();
h.run_steps(3);
h.event(egui::Event::Text("reverb".into()));
h.run_steps(2);
h.key_press(Key::Enter);
h.run_steps(3);
assert!(inserted(&h).is_some(), "Enter inserted a reverb");
let id = vox_id(&h);
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);
}
#[test]
fn replacing_a_filled_insert_opens_the_new_plugin() {
let (category, first, second) = two_effects();
let mut engine = vox();
let track = engine.session().tracks[0].id;
engine.execute("mix.insert", &json!({"track": track.0, "slot": 0, "plugin": first.id})).unwrap();
let mut h = harness(engine, MainWindow::Mix);
assert!(h.state().ui.plugin_windows.is_empty(), "nothing opens before a pick");
pick(&mut h, "Insert A", true, &category, second.name);
assert_eq!(inserted(&h).as_deref(), Some(second.id));
assert_eq!(h.state().ui.plugin_windows, vec![(track, 0)]);
}
#[test]
fn the_edit_windows_inserts_column_opens_it_too() {
let (category, plugin, _) = two_effects();
let mut h = harness(vox(), MainWindow::Edit);
pick(&mut h, "Insert A", false, &category, plugin.name);
assert_eq!(inserted(&h).as_deref(), Some(plugin.id));
let id = vox_id(&h);
assert_eq!(h.state().ui.plugin_windows, vec![(id, 0)]);
}
+57 -1
View File
@@ -2,7 +2,7 @@ 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};
use soundcraft_ui_egui::{Services, SoundApp, dialogs, menus};
fn harness() -> Harness<'static, SoundApp> {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -95,3 +95,59 @@ fn save_in_the_question_saves_before_closing() {
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
let _ = std::fs::remove_dir_all(&dir);
}
/// A session the browser read: an empty one, so opening it is easy to see.
fn session_bytes() -> Vec<u8> {
soundcraft_engine::Engine::default().session().to_json().unwrap().into_bytes()
}
fn open_bytes(h: &mut Harness<'_, SoundApp>) {
dialogs::open_session_bytes(h.state_mut(), "Other.scraft".into(), session_bytes());
steps(h);
}
#[test]
fn a_session_read_in_the_browser_asks_before_replacing_a_modified_one() {
let mut h = harness();
open_bytes(&mut h);
assert!(h.state().dialogs.open.is_none(), "an unmodified session is replaced without asking");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
let tracks = h.state().engine.session().tracks.len();
mute_kick(&mut h);
open_bytes(&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);
h.state_mut().run("edit.undo", json!({})).unwrap();
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute, "Cancel keeps the undo history");
mute_kick(&mut h);
open_bytes(&mut h);
click(&mut h, "Don't Save");
assert!(h.state().engine.session().tracks.is_empty());
}
#[test]
fn save_in_the_question_saves_before_opening_a_session_read_in_the_browser() {
let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-bytes-{}", 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);
open_bytes(&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);
let _ = std::fs::remove_dir_all(&dir);
}
+54
View File
@@ -0,0 +1,54 @@
//! #651: the Tracks and Groups lists publish their rows to AccessKit, by name, so screen readers
//! can read them and tests can reach them by label instead of by geometry.
use egui::accesskit::{Role, Toggled};
use egui::vec2;
use egui_kittest::{
Harness,
kittest::{NodeT, Queryable},
};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp};
fn harness() -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let first = app.engine.session().tracks.first().map(|t| t.id.0).unwrap_or_default();
app.run("track.group", json!({"name": "Rhythm", "tracks": [first]})).unwrap();
app.engine.session_mut().edit.selected_tracks.clear();
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,
);
h.run_steps(3);
h
}
#[test]
fn tracks_list_rows_are_named_and_report_their_selection() {
let mut h = harness();
let tracks: Vec<(u64, String)> = h.state().engine.session().tracks.iter().map(|t| (t.id.0, t.name.clone())).collect();
assert!(tracks.len() >= 2);
for (_, name) in &tracks {
assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(), Some(Toggled::False), "{name}");
}
let (id, name) = &tracks[1];
h.get_by_role_and_label(Role::Button, name).click();
h.run_steps(2);
assert_eq!(h.state().engine.session().edit.selected_tracks.iter().map(|t| t.0).collect::<Vec<_>>(), vec![*id]);
assert_eq!(h.get_by_role_and_label(Role::Button, name).accesskit_node().toggled(), Some(Toggled::True));
}
#[test]
fn groups_list_rows_are_named_and_report_whether_the_group_is_active() {
let mut h = harness();
h.get_by_label("<ALL>");
assert_eq!(h.get_by_role_and_label(Role::CheckBox, "Rhythm").accesskit_node().toggled(), Some(Toggled::True));
h.get_by_role_and_label(Role::CheckBox, "Rhythm").click();
h.run_steps(2);
assert!(h.state().engine.session().groups.iter().all(|g| !g.active));
assert_eq!(h.get_by_role_and_label(Role::CheckBox, "Rhythm").accesskit_node().toggled(), Some(Toggled::False));
}
+6 -1
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
pub type SharedSource = Arc<dyn ByteSource + Send + Sync>;
/// Largest picture we decode (bounds every allocation derived from the file).
const MAX_PIXELS: u64 = 8192 * 8192;
pub(crate) const MAX_PIXELS: u64 = 8192 * 8192;
/// A decoded picture, packed RGBA (8 bits per channel, `width * height * 4` bytes).
#[derive(Debug, Clone, PartialEq)]
@@ -395,6 +395,11 @@ impl Movie {
self.decoded += 1;
match self.codec {
Codec::ProRes => {
let (hdr, ..) = crate::prores::FrameHeader::parse(&data).map_err(|e| VideoError::Decode(e.to_string()))?;
let (w, h) = (u32::from(hdr.width), u32::from(hdr.height));
if (w, h) != (self.info.width, self.info.height) {
return Err(VideoError::Decode(format!("{w}x{h} frame in a {}x{} track", self.info.width, self.info.height)));
}
let opts = crate::prores::DecodeOptions { bit_depth: Some(8), threads: false };
let f = crate::prores::decode_frame_with(&data, &opts).map_err(|e| VideoError::Decode(e.to_string()))?;
if u64::from(f.width) * u64::from(f.height) > MAX_PIXELS {
+4
View File
@@ -109,6 +109,10 @@ pub fn decode_frame_with(data: &[u8], opts: &DecodeOptions) -> Result<Frame> {
/// a sequence). On error the contents of `out` are unspecified.
pub fn decode_frame_into(data: &[u8], opts: &DecodeOptions, out: &mut Frame) -> Result<()> {
let (hdr, mut off, frame_size) = FrameHeader::parse(data)?;
// The planes are sized from the header: refuse a size no movie may have before allocating.
if u64::from(hdr.width) * u64::from(hdr.height) > crate::movie::MAX_PIXELS {
return Err(Error::Unsupported("frame size"));
}
let data = &data[..frame_size];
let depth = opts.bit_depth.unwrap_or(match hdr.chroma {
ChromaFormat::Yuv422 => 10,
+22
View File
@@ -96,6 +96,28 @@ fn prores_and_mjpeg_decode() {
}
}
/// A 64x48 ProRes frame whose header claims `w`x`h`.
fn prores_frame_claiming(w: u16, h: u16) -> Vec<u8> {
use crate::prores::{ChromaFormat, Encoder, Frame, Profile};
let mut data = Encoder::new(Profile::Hq, 64, 48).encode(&Frame::new(64, 48, ChromaFormat::Yuv422, 10, false)).unwrap();
data[16..18].copy_from_slice(&w.to_be_bytes());
data[18..20].copy_from_slice(&h.to_be_bytes());
data
}
#[test]
fn prores_frame_size_is_checked_before_decoding() {
use crate::prores::{DecodeOptions, Error, decode_frame_with};
let huge = decode_frame_with(&prores_frame_claiming(65535, 65535), &DecodeOptions::default());
assert!(matches!(huge, Err(Error::Unsupported("frame size"))), "{huge:?}");
// A frame that differs from the track's declared size is refused, not decoded at its own size.
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mov)).unwrap();
let t = mw.add_track(TrackConfig::new(SampleEntry::prores(crate::isobmff::FourCc(*b"apch"), 64, 48), 25)).unwrap();
mw.write_sample(t, WriteSample { data: &prores_frame_claiming(56, 48), duration: 1, composition_offset: 0, is_sync: true }).unwrap();
let mut m = Movie::open(mw.finish().unwrap().into_inner()).unwrap();
assert!(matches!(m.frame(0), Err(VideoError::Decode(e)) if e.contains("56x48 frame in a 64x48 track")));
}
#[test]
fn unsupported_codec_opens_but_does_not_decode() {
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
+14
View File
@@ -1788,6 +1788,20 @@ impl EditorLink {
v.is_finite().then(|| v.clamp(0.0, 1.0))
}
/// The plugin's own text for a normalized value (`getParamStringByValue`). Main thread only
/// (`None` elsewhere, and once the instance is gone).
pub fn value_text(&self, id: u32, n: f64) -> Option<String> {
if !window::on_main_thread() || !n.is_finite() {
return None;
}
let g = self.lock();
let c = g.controller.as_ref()?;
let mut s: sv::String128 = [0; 128];
// SAFETY: valid controller (the instance clears it under this lock before it terminates),
// main thread; `s` is a writable String128.
ok(unsafe { c.getParamStringByValue(id, n.clamp(0.0, 1.0), &mut s) }).then(|| read_c16(&s)).filter(|t| !t.is_empty())
}
/// Shows a value change in the editor (`setParamNormalized`, main thread).
pub fn set_normalized(&self, id: u32, n: f64) {
if !window::on_main_thread() || !n.is_finite() {
+30 -1
View File
@@ -2,6 +2,7 @@
use crate::ffi::{AFTERTOUCH, BusBuffers, EVENT_CAP, EditorLink, HostContext, Instance, NoteEvent, PARAM_QUEUE_CAP, PITCH_BEND};
use soundcraft_dsp::{Plugin, PluginEditor, PluginInfo, Transport, Unit};
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use vst3::ComWrapper;
@@ -399,7 +400,16 @@ impl Plugin for Vst3Plugin {
fn editor(&mut self) -> Option<Box<dyn PluginEditor>> {
let link = self.inst.editor()?;
Some(Box::new(Vst3Editor { link, host: self.inst.host_ref(), info: self.info, ids: self.ids.clone(), maps: self.maps.clone() }))
let mut units: HashMap<u32, String> = self.inst.params().into_iter().map(|p| (p.id, p.units)).collect();
Some(Box::new(Vst3Editor {
link,
host: self.inst.host_ref(),
info: self.info,
index: self.info.params.iter().enumerate().map(|(i, p)| (p.id, i)).collect(),
units: self.ids.iter().map(|id| units.remove(id).unwrap_or_default()).collect(),
ids: self.ids.clone(),
maps: self.maps.clone(),
}))
}
fn state_reader(&mut self) -> Option<Box<dyn soundcraft_dsp::PluginStateReader>> {
@@ -423,6 +433,10 @@ struct Vst3Editor {
link: Arc<EditorLink>,
host: ComWrapper<HostContext>,
info: &'static PluginInfo,
/// Position of each SoundCraft param id in `info.params` (looked up once per row and frame).
index: HashMap<&'static str, usize>,
/// The plugin's own unit label of each param (same order as `ids`).
units: Vec<String>,
ids: Vec<u32>,
maps: Vec<Mapping>,
}
@@ -487,4 +501,19 @@ impl PluginEditor for Vst3Editor {
self.link.set_normalized(vid, n);
}
}
fn value_text(&mut self, id: &str, value: f32) -> Option<String> {
let &i = self.index.get(id)?;
let pi = self.info.params.get(i)?;
// A plugin whose plain mapping is the identity (JUCE, the SDK's default `Parameter`) leaves
// a 0..1 range with the units of its real one: only its own text says what the value is.
if !matches!(self.maps.get(i)?, Mapping::Plain) || pi.min != 0.0 || pi.max != 1.0 {
return None;
}
let text = self.link.value_text(*self.ids.get(i)?, self.normalized(i, value)?)?;
// VST3 keeps the units apart from the text (JUCE's label, the SDK's `RangeParameter`),
// but some plugins put them in both.
let units = self.units.get(i).map_or("", |u| u.trim());
if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) }
}
}
@@ -23,7 +23,16 @@ fn main() {
ed.set_param("7", 1.5);
ed.set_param("nope", 1.0);
assert!(ed.idle().is_empty(), "set_param is not reported back as an edit");
// #599: an identity-mapped (JUCE-style) parameter shows the plugin's own text, verbatim, with
// its units appended once; correctly mapped parameters and choices keep SoundCraft's format.
assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB"));
assert_eq!(ed.value_text("13", 1.0).as_deref(), Some("24.0 dB"));
assert_eq!(ed.value_text("14", 0.25).as_deref(), Some("3.0 dB"), "units already in the text");
assert_eq!(ed.value_text("7", 1.5), None);
assert_eq!(ed.value_text("3", 1.0), None);
assert_eq!(ed.value_text("nope", 0.5), None);
drop(p);
assert_eq!(ed.value_text("13", 0.375), None, "the plugin is gone");
assert!(!ed.is_open());
assert!(ed.open().is_err(), "the plugin is gone");
println!("editor_main_thread: ok");
+8 -1
View File
@@ -298,12 +298,17 @@ impl IConnectionPointTrait for GainController {
}
}
const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 5] = [
const GAIN_PARAMS: [(u32, &str, &str, i32, f64, i32); 7] = [
(7, "Gain", "dB", 0, 0.5, ParameterInfo_::ParameterFlags_::kCanAutomate),
(9, "Secret", "", 0, 0.0, ParameterInfo_::ParameterFlags_::kIsHidden),
(3, "Mode", "", 2, 0.0, ParameterInfo_::ParameterFlags_::kIsList),
(11, "Bypass", "", 1, 0.0, ParameterInfo_::ParameterFlags_::kIsBypass),
(12, "Freq", "Hz", 0, 0.5, 0),
// Like JUCE and the SDK's default `Parameter`: no plain mapping (identity), so the real range
// is only in the plugin's own text (-24..+24, the default 0.375 is -6), without the units.
(13, "Level", "dB", 0, 0.375, ParameterInfo_::ParameterFlags_::kCanAutomate),
// Identity too, but its text already carries the units (0..12 dB).
(14, "Drive", "dB", 0, 0.0, ParameterInfo_::ParameterFlags_::kCanAutomate),
];
impl IEditControllerTrait for GainController {
@@ -351,6 +356,8 @@ impl IEditControllerTrait for GainController {
let s = unsafe { &mut *string };
let text = match id {
3 => ["A", "B", "C"][((n * 2.0).round() as usize).min(2)].to_string(),
13 => format!("{:.1}", 48.0 * n - 24.0).replace('-', "\u{2212}"),
14 => format!("{:.1} dB", 12.0 * n),
_ => format!("{:.2}", unsafe { self.normalizedParamToPlain(id, n) }),
};
copy_c16(&text, s);
+18 -1
View File
@@ -42,7 +42,7 @@ fn info_exposes_visible_params_with_plain_ranges() {
assert_eq!(info.id, gain);
assert_eq!(info.short_name, "Fixture ");
let ids: Vec<&str> = info.params.iter().map(|p| p.id).collect();
assert_eq!(ids, vec!["7", "3", "12"], "hidden 9 and bypass 11 are skipped");
assert_eq!(ids, vec!["7", "3", "12", "13", "14"], "hidden 9 and bypass 11 are skipped");
let g = info.param("7").unwrap();
assert_eq!((g.name, g.min, g.max, g.default, g.unit, g.taper), ("Gain", 0.0, 2.0, 1.0, Unit::Db, Taper::Linear));
let mode = info.param("3").unwrap();
@@ -55,6 +55,23 @@ fn info_exposes_visible_params_with_plain_ranges() {
assert!(std::ptr::eq(info, soundcraft_vst3_host::plugin_info(&gain).unwrap()));
}
/// #599: a plugin whose `normalizedParamToPlain` is the identity (JUCE, the SDK's default
/// `Parameter`) gives SoundCraft a 0..1 range; its real values are only in its own text, which the
/// editor handle reads from the controller on the main thread only (see `editor_main_thread`).
#[test]
fn identity_mapped_param_text_is_read_on_the_main_thread_only() {
let id = id_of("Fixture Gain");
let level = soundcraft_vst3_host::plugin_info(&id).unwrap().param("13").unwrap();
assert_eq!((level.min, level.max, level.unit), (0.0, 1.0, Unit::Db));
let mut p = soundcraft_vst3_host::instantiate_plugin(&id).unwrap();
let mut ed = soundcraft_dsp::Plugin::editor(&mut p).expect("editor handle");
if cfg!(any(target_os = "macos", windows)) {
assert_eq!(ed.value_text("13", 0.375), None, "test threads are not the main thread");
} else {
assert_eq!(ed.value_text("13", 0.375).as_deref(), Some("\u{2212}6.0 dB"));
}
}
#[test]
fn gain_processes_audio_and_follows_params() {
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Gain")).unwrap();
+1 -1
View File
@@ -15,7 +15,7 @@ input), and every command is reachable from:
## Isolated automation profiles
Set `SOUNDCRAFT_CONFIG_DIR` to a fresh directory to isolate UI preferences, `Autosave`, `Presets`, and `logs`.
The nonempty value is used as the complete settings directory. Unset retains the normal platform paths;
The nonempty value is used as the complete settings directory, even in portable mode (`portable.txt` beside the executable, which otherwise uses `SoundCraftData` next to it). Unset retains the normal platform paths;
an explicit empty value disables the settings directory instead of falling back to a personal profile. Save synthetic sessions to explicit scratch paths. Do not use `SOUNDCRAFT_NO_PREFS`
when testing preference persistence across restarts.
+1 -1
View File
@@ -90,7 +90,7 @@ files it was given rather than guarding against other software on the computer.
|---|---|
| `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 up to 1000 commands in order (`keep_going` optional). Larger batches return `isError: true` before executing any command. Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). |
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). `save_session` won't save over another existing file unless `replace` is true. |
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
+5 -1
View File
@@ -1,6 +1,6 @@
# SoundCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (alpha gate added; stage re-normalized to pre-alpha) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (M8 batch limit validation) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).
@@ -54,6 +54,9 @@ Status: ✅ done · 🟡 in progress · ⬜ not started.
| M16 | Built-in plugin depth, sidechain, ARA | ⬜ | 110–170 |
| M17 | Immersive delivery (ADM BWF, object renderer, binaural) and video depth | ⬜ | 80–130 |
M8 follow-up (2026-10-14): MCP batches above 1000 calls are rejected before execution (#460).
The limit is advertised in the tool schema and description; stage and readiness estimates are unchanged.
## To beta (~350–570 h, about 60–70 % parallelizable)
Beta needs ~75 % ready for real work and no blocking gap in session interchange.
@@ -86,5 +89,6 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo
| Date | Change | Summary |
|---|---|---|
| 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) |
| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
| 2026-10-10 | major | Created from ROADMAP.md's milestones and alpha list; added M12–M17 and the beta plan with estimates |
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag; a plugin opens when inserted, #486) · **Target:** Avid Pro Tools Ultimate 2026.4.1
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -51,6 +51,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Preferences | 🟡 | Many settings stored, fewer honoured |
| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |
| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |
| A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window |
| Command palette (Search) | ✅ | Over every command |
## Look and meters
@@ -68,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
| 2026-10-13 | minor | Drag across checkboxes (#399) |
| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged |
| 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged |
@@ -41,6 +41,12 @@
<keyword>recording</keyword>
<keyword>midi</keyword>
</keywords>
<screenshots>
<screenshot type="default">
<caption>Mixer with channel strips, sends, faders and an equalizer</caption>
<image type="source" width="2400" height="1500">https://raw.githubusercontent.com/storytold/soundcraft/main/docs/images/mix.png</image>
</screenshot>
</screenshots>
<branding>
<color type="primary" scheme_preference="light">#8fd9e6</color>
<color type="primary" scheme_preference="dark">#0b7385</color>