mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-06 10:20:26 -06:00
Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94544a9a5f | ||
|
|
26bdcdf6fb | ||
|
|
17d3f00700 | ||
|
|
1c350101bc | ||
|
|
b35dd0f9eb | ||
|
|
e5d3174929 | ||
|
|
27329587c6 | ||
|
|
1dbc185e01 | ||
|
|
8444edf075 | ||
|
|
6c3a0dfc50 | ||
|
|
d557d18118 | ||
|
|
98cda6ecaf | ||
|
|
920ec2c3f3 | ||
|
|
5e8898976e | ||
|
|
c426b889bb | ||
|
|
bdb73895a5 | ||
|
|
1a4a88fb0d | ||
|
|
9e902b48fe | ||
|
|
265e526f6a | ||
|
|
d2298ff974 | ||
|
|
cf40296888 | ||
|
|
b32065a109 | ||
|
|
97e2603683 | ||
|
|
73e3478ae3 | ||
|
|
4740e8d4c4 | ||
|
|
fd9c673694 | ||
|
|
1f8b267722 | ||
|
|
95f25979eb | ||
|
|
929a7c5307 | ||
|
|
ee63b09cbe | ||
|
|
4071975aa4 | ||
|
|
9e9177f1af | ||
|
|
3ea1f34a1d | ||
|
|
6e8c039097 | ||
|
|
5d7197a2d0 |
@@ -53,8 +53,8 @@ User-visible behaviour = a command in `crates/engine/src/cmd/*` (id, label, menu
|
||||
Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the incumbent's menu leaves (names only). `cargo xtask parity` writes `docs/parity-checklist.md` (generated; the hand-written assessment is `docs/target-app-parity.md`); a test enforces a floor that only rises.
|
||||
|
||||
## Running and looking at the app
|
||||
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port is printed).
|
||||
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}}}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- `cargo run --release -p soundcraft -- --demo --control 0` (demo session + control channel on a free port; the port and the launch's `SOUNDCRAFT_CONTROL_TOKEN` are printed, or export the token first so both sides share it).
|
||||
- Drive it: JSON lines over TCP, e.g. `{"id":1,"method":"engine.execute","params":{"command":"track.new","params":{"count":2,"format":"stereo"}},"token":"…"}`, then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"},"token":"…"}`. Methods: `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
|
||||
- Offscreen UI render (no window, no focus stealing): `cargo run -p soundcraft-ui-egui --example ui_shot -- out.png [script.jsonl]`.
|
||||
- Headless: `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav`; MCP: `soundcraft-cli mcp [--connect PORT]`.
|
||||
- **For UI work, look at the result** (render, read the PNG) and compare with `plan/protools/screenshots/`.
|
||||
|
||||
Generated
+68
-18
@@ -277,6 +277,12 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayref"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -4006,6 +4012,17 @@ version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "sctk-adwaita"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
|
||||
dependencies = [
|
||||
"log",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"tiny-skia",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -4280,13 +4297,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
"craft-update",
|
||||
"eframe",
|
||||
"egui",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"muda",
|
||||
"objc2 0.6.5",
|
||||
@@ -4304,7 +4322,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4314,7 +4332,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4326,7 +4344,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4339,7 +4357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4352,7 +4370,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4369,7 +4387,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4380,7 +4398,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4403,7 +4421,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4413,7 +4431,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4429,7 +4447,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4442,7 +4460,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4457,7 +4475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4467,7 +4485,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4496,7 +4514,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4508,7 +4526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4524,7 +4542,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4569,6 +4587,12 @@ version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "strict-num"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -4939,6 +4963,31 @@ dependencies = [
|
||||
"time-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"arrayvec",
|
||||
"bytemuck",
|
||||
"cfg-if",
|
||||
"log",
|
||||
"tiny-skia-path",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiny-skia-path"
|
||||
version = "0.11.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"bytemuck",
|
||||
"strict-num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinystr"
|
||||
version = "0.8.5"
|
||||
@@ -5983,6 +6032,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"sctk-adwaita",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
@@ -6140,7 +6190,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/*", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
|
||||
@@ -286,3 +286,34 @@ The Font Software may be sold as part of a larger software package but no copy o
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
|
||||
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
|
||||
|
||||
Linux Wayland window decorations
|
||||
-------------------------------
|
||||
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
|
||||
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
|
||||
for vector window controls. Its title-font features are disabled: no Cantarell
|
||||
font is embedded by this dependency and no font file is added to this repo.
|
||||
|
||||
sctk-adwaita's MIT licence:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Bartłomiej Maryńczak
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -168,6 +168,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
|
||||
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
|
||||
new tracks. **Setup › Keyboard Shortcuts** lists them all.
|
||||
|
||||
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
|
||||
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
|
||||
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
|
||||
fit their longest option and remain fixed when the selection changes.
|
||||
|
||||
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
|
||||
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
|
||||
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
|
||||
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
|
||||
|
||||
### Logs
|
||||
|
||||
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
|
||||
@@ -204,7 +214,8 @@ soundcraft-cli run --demo \
|
||||
--cmd 'mix.insert={"track":"Bass","plugin":"eq_7band"}' \
|
||||
--bounce mix.wav
|
||||
|
||||
# Drive the running app.
|
||||
# Drive the running app (the token keeps other local accounts off the loopback port).
|
||||
export SOUNDCRAFT_CONTROL_TOKEN=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
soundcraft --demo --control 7801 &
|
||||
soundcraft-cli app --port 7801 transport.play
|
||||
soundcraft-cli app --port 7801 ui.screenshot '{"path":"shot.png"}'
|
||||
|
||||
@@ -26,10 +26,16 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
|
||||
serde_json.workspace = true
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
# The per-launch control token (already in the graph through egui's ahash).
|
||||
getrandom = "0.3"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
|
||||
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd.workspace = true
|
||||
|
||||
|
||||
@@ -3,8 +3,13 @@
|
||||
//!
|
||||
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
|
||||
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
|
||||
//!
|
||||
//! Every request carries the launch's secret as `"token"` (`SOUNDCRAFT_CONTROL_TOKEN`, or one made
|
||||
//! here and printed after the port): the port is loopback, which any local account can reach, and
|
||||
//! a request acts with this process's authority.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_automation::CONTROL_TOKEN_VAR;
|
||||
use soundcraft_ui_egui::ControlRequest;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
@@ -28,9 +33,20 @@ const MAX_CONNECTIONS: usize = 16;
|
||||
const MAX_QUEUED_REQUESTS: usize = MAX_CONNECTIONS;
|
||||
|
||||
/// Bind the loopback control listener and return the request receiver. Port 0 picks a free one and the actual port is
|
||||
/// printed; bind or spawn failures are logged instead of panicking.
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
/// printed; bind or spawn failures are logged instead of panicking. Requests must carry `token`; with `None` one is
|
||||
/// made for this launch and printed after the port, so only the launching process learns it.
|
||||
pub fn start(port: u16, token: Option<String>, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
|
||||
let (token, made) = match token {
|
||||
Some(t) => (t, false),
|
||||
None => match new_token() {
|
||||
Ok(t) => (t, true),
|
||||
Err(e) => {
|
||||
log::error!("control server not started: no {CONTROL_TOKEN_VAR} and no random token: {e}");
|
||||
return rx;
|
||||
}
|
||||
},
|
||||
};
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
@@ -41,13 +57,29 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let actual = listener.local_addr().map(|a| a.port()).unwrap_or(port);
|
||||
log::info!("control server listening on 127.0.0.1:{actual}");
|
||||
println!("SOUNDCRAFT_CONTROL_PORT={actual}");
|
||||
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx));
|
||||
if made {
|
||||
println!("{CONTROL_TOKEN_VAR}={token}");
|
||||
}
|
||||
let spawned = std::thread::Builder::new().name("soundcraft-control".into()).spawn(move || accept_loop(listener, tx, ctx, Arc::from(token)));
|
||||
if let Err(e) = spawned {
|
||||
log::error!("control thread failed: {e}");
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
/// 32 random bytes as hex, from the OS.
|
||||
fn new_token() -> Result<String, getrandom::Error> {
|
||||
let mut bytes = [0u8; 32];
|
||||
getrandom::fill(&mut bytes)?;
|
||||
Ok(bytes.iter().map(|b| format!("{b:02x}")).collect())
|
||||
}
|
||||
|
||||
/// `token` equals `expected`, compared in time that doesn't depend on where they differ.
|
||||
fn token_matches(token: Option<&str>, expected: &str) -> bool {
|
||||
let Some(token) = token else { return false };
|
||||
token.len() == expected.len() && token.bytes().zip(expected.bytes()).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0
|
||||
}
|
||||
|
||||
/// A connection's place among [`MAX_CONNECTIONS`], given back when the connection ends (also when
|
||||
/// its thread can't be started, or panics).
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
@@ -60,7 +92,7 @@ impl Drop for Slot {
|
||||
|
||||
/// Accept until the listener closes, admitting at most [`MAX_CONNECTIONS`] peers; excess peers get one error line.
|
||||
/// Each admitted connection is served on its own thread, releasing its slot on exit.
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, token: Arc<str>) {
|
||||
let open = Arc::new(AtomicUsize::new(0));
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= MAX_CONNECTIONS {
|
||||
@@ -71,9 +103,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().name("soundcraft-control-conn".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, tx, ctx);
|
||||
serve(stream, tx, ctx, &token);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -103,8 +136,9 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
|
||||
}
|
||||
|
||||
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
|
||||
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
|
||||
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
|
||||
/// s reply deadline, and stop at EOF, invalid UTF-8, a closed request queue or a wrong `token`. Bad JSON still gets
|
||||
/// an error reply.
|
||||
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context, token: &str) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
let mut reader = BufReader::new(read);
|
||||
@@ -126,6 +160,11 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
|
||||
let reply = match serde_json::from_str::<Value>(line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
if !token_matches(msg.get("token").and_then(Value::as_str), token) {
|
||||
let error = format!("unauthorized: the request's `token` is not this app's {CONTROL_TOKEN_VAR}");
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "id": id, "error": error}));
|
||||
break;
|
||||
}
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
@@ -185,12 +224,14 @@ mod tests {
|
||||
assert!(state + request.to_string().len() <= MAX_LINE);
|
||||
}
|
||||
|
||||
const TOKEN: &str = "s3cret";
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
fn serve_echo() -> std::net::SocketAddr {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(MAX_QUEUED_REQUESTS);
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default()));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, egui::Context::default(), Arc::from(TOKEN)));
|
||||
std::thread::spawn(move || {
|
||||
for req in rx {
|
||||
let _ = req.reply.send(json!({"ok": true, "method": req.method}));
|
||||
@@ -204,7 +245,7 @@ mod tests {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\",\"token\":\"s3cret\"}\r\n\n{\"id\":4,\"method\":\"pong\",\"token\":\"s3cret\"}\n").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
@@ -214,6 +255,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_without_the_token_is_refused_and_the_connection_closed() {
|
||||
let addr = serve_echo();
|
||||
for request in [r#"{"id":1,"method":"ping"}"#, r#"{"id":1,"method":"ping","token":"s3cre"}"#, r#"{"id":1,"method":"ping","token":"S3CRET"}"#]
|
||||
{
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
writeln!(c, "{request}").unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
let mut line = String::new();
|
||||
r.read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["id"].as_i64(), v["ok"].as_bool()), (Some(1), Some(false)), "{line}");
|
||||
assert!(v["error"].as_str().unwrap_or("").starts_with("unauthorized"), "{line}");
|
||||
assert!(v.get("method").is_none(), "the request was not forwarded: {line}");
|
||||
line.clear();
|
||||
assert_eq!(r.read_line(&mut line).unwrap(), 0, "the connection is closed after a refusal");
|
||||
}
|
||||
assert!(token_matches(Some(TOKEN), TOKEN));
|
||||
assert!(!token_matches(None, TOKEN));
|
||||
assert_eq!(new_token().unwrap().len(), 64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Usage: `soundcraft [--demo] [--control <port>] [--no-audio] [session.scraft | audio files…]`; `soundcraft --help` lists the flags and `SOUNDCRAFT_CONTROL_PORT`.
|
||||
//!
|
||||
//! `--control <port>` (or `SOUNDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server
|
||||
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{}}` →
|
||||
//! `{"id":1,"ok":true,"result":…}`. See `soundcraft_ui_egui::control` for the methods.
|
||||
//! (`0` picks a free port and prints it): `{"id":1,"method":"ui.inspect","params":{},"token":"…"}` →
|
||||
//! `{"id":1,"ok":true,"result":…}`, where `token` is `SOUNDCRAFT_CONTROL_TOKEN` (or the one made for
|
||||
//! this launch, printed after the port). See `soundcraft_ui_egui::control` for the methods.
|
||||
//!
|
||||
//! `log` records go to standard error and `<settings dir>/logs/soundcraft.log` (`RUST_LOG` sets the
|
||||
//! levels); see [`logging`].
|
||||
@@ -260,7 +261,7 @@ fn main() -> eframe::Result {
|
||||
app.extra.ui_scale = prefs.ui_scale;
|
||||
app.ui = prefs;
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
let rx = control_server::start(port, soundcraft_automation::control_token(), cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in &files {
|
||||
|
||||
@@ -110,6 +110,15 @@ fn zero_channels_is_malformed() {
|
||||
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aiff_negative_channel_count_is_malformed() {
|
||||
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
|
||||
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
|
||||
aiff[comm + 8] = 0x80;
|
||||
aiff[comm + 9] = 0;
|
||||
assert!(probe(&aiff, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sample_rate_is_malformed() {
|
||||
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
|
||||
|
||||
@@ -151,18 +151,31 @@ impl RemoteError {
|
||||
|
||||
type Connection = (TcpStream, BufReader<TcpStream>);
|
||||
|
||||
/// The variable carrying the control channel's secret: the app refuses every request whose
|
||||
/// `token` differs from it, so another local account can't drive the app through the loopback
|
||||
/// port. The app makes one per launch when the variable is unset (printed after the port line).
|
||||
pub const CONTROL_TOKEN_VAR: &str = "SOUNDCRAFT_CONTROL_TOKEN";
|
||||
|
||||
/// [`CONTROL_TOKEN_VAR`] from the environment; a blank value counts as unset.
|
||||
pub fn control_token() -> Option<String> {
|
||||
std::env::var(CONTROL_TOKEN_VAR).ok().map(|t| t.trim().to_owned()).filter(|t| !t.is_empty())
|
||||
}
|
||||
|
||||
/// A TCP client for a running app's control channel.
|
||||
pub struct Remote {
|
||||
/// The target as `host:port`, for connecting and for messages.
|
||||
addr: String,
|
||||
/// Sent with every request; without one the app refuses, naming the variable.
|
||||
token: Option<String>,
|
||||
conn: Option<Connection>,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Remote {
|
||||
/// A client for the app at `target`; it connects on the first request.
|
||||
/// A client for the app at `target`, authenticating with [`control_token`]; it connects on the
|
||||
/// first request.
|
||||
pub fn new(target: &ControlTarget) -> Self {
|
||||
Remote { addr: target.to_string(), conn: None, next_id: 1 }
|
||||
Remote { addr: target.to_string(), token: control_token(), conn: None, next_id: 1 }
|
||||
}
|
||||
|
||||
/// The open connection, connecting first when there is none.
|
||||
@@ -178,7 +191,7 @@ impl Remote {
|
||||
fn roundtrip(&mut self, method: &str, params: &Value) -> Result<Value, (RemoteError, bool)> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let line = json!({"id": id, "method": method, "params": params, "token": self.token}).to_string();
|
||||
let (w, r) = self.connection().map_err(|e| (e, false))?;
|
||||
writeln!(w, "{line}").map_err(|e| (e.into(), false))?;
|
||||
let mut buf = String::new();
|
||||
|
||||
@@ -222,10 +222,33 @@ mod tests {
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
/// A new directory that this test alone created, readable only by its user on Unix. Creation
|
||||
/// fails if the path already exists: the test then loads native libraries from it, so it must
|
||||
/// never reuse a directory someone else could have put a library in. It lives next to the test
|
||||
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
|
||||
/// directory without a sticky bit or with inheritable write access, where another user could
|
||||
/// rename or populate it. Anyone who can write to the build directory can already replace the
|
||||
/// test binary itself, so this parent adds no new trust.
|
||||
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
|
||||
let exe = std::env::current_exe().expect("test binary path");
|
||||
let parent = exe.parent().expect("test binary has a parent directory");
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
|
||||
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let builder = {
|
||||
let mut b = std::fs::DirBuilder::new();
|
||||
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
|
||||
b
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
let d = fresh_private_dir("soundcraft-clap-garbage");
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
|
||||
@@ -7,6 +7,7 @@ use crate::{Engine, Result, cmd};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
|
||||
|
||||
/// Returns the track command specifications exposed to UI and automation clients.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -14,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"New...",
|
||||
["Track"],
|
||||
Some("Cmd+Shift+N"),
|
||||
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}",
|
||||
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id, rows?: [objects with the same fields] (1–128 tracks total)}",
|
||||
always,
|
||||
new_track
|
||||
),
|
||||
@@ -231,7 +232,37 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Creates either one legacy row or several validated rows as one command.
|
||||
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let ids = if let Some(rows) = p.get("rows") {
|
||||
let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?;
|
||||
if rows.is_empty() || rows.len() > 128 {
|
||||
return Err(bad("track.new", "`rows` must contain 1–128 rows"));
|
||||
}
|
||||
let mut total = 0_i64;
|
||||
for row in rows {
|
||||
if !row.is_object() || row.get("rows").is_some() {
|
||||
return Err(bad("track.new", "each row must be an object without nested rows"));
|
||||
}
|
||||
total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128));
|
||||
}
|
||||
if total > 128 {
|
||||
return Err(bad("track.new", "a single creation is limited to 128 tracks"));
|
||||
}
|
||||
let mut ids = Vec::new();
|
||||
for row in rows {
|
||||
ids.extend(new_track_row(e, row)?);
|
||||
}
|
||||
ids
|
||||
} else {
|
||||
new_track_row(e, p)?
|
||||
};
|
||||
e.session_mut().edit.selected_tracks = ids.clone();
|
||||
Ok(json!({"tracks": ids}))
|
||||
}
|
||||
|
||||
/// All rows run inside one command, so failures roll back and Undo covers the whole creation.
|
||||
fn new_track_row(e: &mut Engine, p: &Value) -> Result<Vec<TrackId>> {
|
||||
let count = i64_or(p, "count", 1).clamp(1, 128);
|
||||
let kind = match str_param(p, "kind") {
|
||||
Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?,
|
||||
@@ -271,8 +302,7 @@ fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
}
|
||||
ids.push(id);
|
||||
}
|
||||
s.edit.selected_tracks = ids.clone();
|
||||
Ok(json!({"tracks": ids}))
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
fn group(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
|
||||
@@ -82,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
|
||||
for c in t.clips() {
|
||||
if let ClipContent::Midi { sequence } = &c.content {
|
||||
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
|
||||
for n in &sequence.notes {
|
||||
// Only what plays: notes starting inside the clip, cut at its end.
|
||||
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
|
||||
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
|
||||
let mut n = *n;
|
||||
n.start = n.start.saturating_add(base);
|
||||
n.length = n.length.min(clip_end - n.start).max(1);
|
||||
seq.notes.push(n);
|
||||
}
|
||||
for ev in &sequence.ctrls {
|
||||
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
|
||||
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
|
||||
let mut ev = *ev;
|
||||
ev.tick = ev.tick.saturating_add(base);
|
||||
seq.ctrls.push(ev);
|
||||
kept.push(ev);
|
||||
}
|
||||
// A sustain still held at the clip end is released there.
|
||||
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
|
||||
for ev in &kept {
|
||||
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
|
||||
continue;
|
||||
}
|
||||
match release.iter_mut().find(|r| r.channel == ev.channel) {
|
||||
Some(r) if ev.tick >= r.tick => *r = *ev,
|
||||
Some(_) => {}
|
||||
None => release.push(*ev),
|
||||
}
|
||||
}
|
||||
seq.ctrls.append(&mut kept);
|
||||
for mut r in release.into_iter().filter(|r| r.value >= 64) {
|
||||
r.tick = clip_end;
|
||||
r.value = 0;
|
||||
seq.ctrls.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> {
|
||||
let a = s.tempo.bar_start_tick(bar);
|
||||
let b = s.tempo.bar_start_tick(bar + 1);
|
||||
let m = s.tempo.meter_at_tick(a);
|
||||
let (ua, ub) = (a / UNIT, b / UNIT);
|
||||
if ub <= ua {
|
||||
if b <= a {
|
||||
break;
|
||||
}
|
||||
v.push((ua, ub - ua, m.numerator, m.denominator));
|
||||
let (ua, ub) = (a / UNIT, b / UNIT);
|
||||
bar += 1;
|
||||
if ub <= ua {
|
||||
// A bar shorter than a sixteenth that does not reach the next unit: the following
|
||||
// bar carries this span, so the grid stays contiguous.
|
||||
continue;
|
||||
}
|
||||
// Bars shorter than a sixteenth are written as one measure of the units they cover, so
|
||||
// the time signature matches the measure's content.
|
||||
let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) };
|
||||
v.push((ua, ub - ua, num, den));
|
||||
if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES {
|
||||
break;
|
||||
}
|
||||
@@ -644,6 +652,58 @@ mod tests {
|
||||
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
|
||||
}
|
||||
|
||||
/// Note onsets (pitch count of every chord that is not a tie continuation).
|
||||
fn note_starts(parts: &[Part]) -> usize {
|
||||
parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_shorter_than_a_sixteenth_keep_every_note() {
|
||||
let base = crate::demo::demo_engine();
|
||||
let baseline = note_starts(&layout(base.session(), &[]));
|
||||
assert!(baseline > 0, "demo has notes");
|
||||
for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] {
|
||||
for mid in [false, true] {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 };
|
||||
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
|
||||
let parts = layout(e.session(), &[]);
|
||||
assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part");
|
||||
assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost");
|
||||
for p in &parts {
|
||||
for m in &p.measures {
|
||||
let units: u32 = m.items.iter().map(|i| i.units).sum();
|
||||
// The written meter matches the measure's content (divisions are sixteenths).
|
||||
assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch");
|
||||
}
|
||||
}
|
||||
let setup = ScoreSetup::from_session(e.session());
|
||||
let x = musicxml(&parts, &setup).expect("musicxml fits");
|
||||
assert!(x.contains("<pitch>"), "{n}/{d} mid={mid}: musicxml has no notes");
|
||||
let s = svg(&parts, &setup).expect("svg fits");
|
||||
assert!(s.contains("<ellipse"), "{n}/{d} mid={mid}: svg has no notes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bars_of_a_sixteenth_a_32nd_and_a_64th_in_a_row_keep_a_contiguous_grid() {
|
||||
let base = crate::demo::demo_engine();
|
||||
let baseline = note_starts(&layout(base.session(), &[]));
|
||||
let mut e = crate::demo::demo_engine();
|
||||
// Bars 5 to 8 are 1/16, 1/32, 1/64 and 3/64 long, then 4/4 resumes.
|
||||
for (bar, (n, d)) in (5..).zip([(1, 16), (1, 32), (1, 64), (3, 64), (4, 4)]) {
|
||||
let tick = e.session().tempo.bar_start_tick(bar);
|
||||
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
|
||||
}
|
||||
let grid = measure_grid(e.session(), i64::MAX);
|
||||
assert_eq!(grid.first().map(|m| m.0), Some(0));
|
||||
for w in grid.windows(2) {
|
||||
assert_eq!(w[0].0 + w[0].1, w[1].0, "measures leave no gap or overlap: {w:?}");
|
||||
}
|
||||
assert_eq!(note_starts(&layout(e.session(), &[])), baseline, "notes lost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_is_bounded_by_byte_budget() {
|
||||
// A part with many notes must not produce an unbounded document.
|
||||
|
||||
@@ -13,6 +13,7 @@ mod long_renders;
|
||||
mod midi_offset;
|
||||
mod min_secs_with_timecode_counter;
|
||||
mod mix_required_params;
|
||||
mod new_track_rows;
|
||||
mod random_edits;
|
||||
mod recording;
|
||||
mod session_transport;
|
||||
|
||||
@@ -28,3 +28,71 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
|
||||
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
|
||||
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
// 24000 samples is 960 ticks at 120 bpm.
|
||||
let id = e.session().tracks[0].clips()[0].id;
|
||||
e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000);
|
||||
|
||||
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
|
||||
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
|
||||
let notes = &smf.tracks[0].sequence.notes;
|
||||
assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported");
|
||||
let late = notes.iter().find(|n| n.pitch == 62).unwrap();
|
||||
assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_midi_releases_a_sustain_held_past_the_trim() {
|
||||
use soundcraft_midi::{CtrlEvent, CtrlKind};
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
|
||||
let id = e.session().tracks[0].clips()[0].id;
|
||||
{
|
||||
let clip = e.session_mut().find_clip_mut(id).unwrap();
|
||||
if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content {
|
||||
let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value };
|
||||
sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)];
|
||||
}
|
||||
clip.trim_end_to(24_000);
|
||||
}
|
||||
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
|
||||
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
|
||||
let ctrls = &smf.tracks[0].sequence.ctrls;
|
||||
let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect();
|
||||
assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}");
|
||||
assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() {
|
||||
let mut e = Engine::default();
|
||||
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap();
|
||||
let track = r["tracks"][0].as_u64().unwrap();
|
||||
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
|
||||
let clip = r["clip"].as_u64().unwrap();
|
||||
for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] {
|
||||
e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap();
|
||||
}
|
||||
// 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm.
|
||||
e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap();
|
||||
e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap();
|
||||
|
||||
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
|
||||
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
|
||||
let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect();
|
||||
notes.sort_unstable();
|
||||
// The note cut by the start trim is gone; the others keep their place and the last stops at the end.
|
||||
assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
use serde_json::json;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
||||
/// Mixed rows preserve their order and share one selection and history step.
|
||||
#[test]
|
||||
fn mixed_rows_create_in_order_select_every_track_and_undo_in_one_step() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"name": "Existing"})).unwrap();
|
||||
let before = e.session().to_json().unwrap();
|
||||
let result = e
|
||||
.execute(
|
||||
"track.new",
|
||||
&json!({"rows": [
|
||||
{"count": 2, "kind": "audio", "format": "mono", "name": "Dialogue", "timebase": "samples"},
|
||||
{"kind": "aux", "format": "stereo", "name": "Reverb", "timebase": "samples"},
|
||||
{"kind": "instrument", "format": "stereo", "name": "Keys", "timebase": "ticks", "instrument": "subtractive_synth"},
|
||||
{"kind": "folder", "format": "stereo", "name": "Music"}
|
||||
]}),
|
||||
)
|
||||
.unwrap();
|
||||
let s = e.session();
|
||||
let created = &s.tracks[1..];
|
||||
assert_eq!(created.len(), 5);
|
||||
assert_eq!(
|
||||
created.iter().map(|t| t.kind).collect::<Vec<_>>(),
|
||||
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux, TrackKind::Instrument, TrackKind::Folder]
|
||||
);
|
||||
assert_eq!(
|
||||
created.iter().map(|t| t.format).collect::<Vec<_>>(),
|
||||
[ChannelFormat::Mono, ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Stereo, ChannelFormat::Stereo]
|
||||
);
|
||||
assert!(!created[0].ticks_timebase);
|
||||
assert!(created[3].ticks_timebase && created[3].instrument.is_some());
|
||||
assert_eq!(s.busses.len(), 1);
|
||||
let ids = created.iter().map(|t| t.id).collect::<Vec<_>>();
|
||||
assert_eq!(s.edit.selected_tracks, ids);
|
||||
assert_eq!(result["tracks"], json!(ids));
|
||||
let after = s.to_json().unwrap();
|
||||
assert!(e.undo());
|
||||
assert_eq!(e.session().to_json().unwrap(), before);
|
||||
assert!(e.redo());
|
||||
assert_eq!(e.session().to_json().unwrap(), after);
|
||||
}
|
||||
|
||||
/// A failure in a later row rolls the complete command back atomically.
|
||||
#[test]
|
||||
fn invalid_later_row_leaves_no_partial_creation_or_undo_step() {
|
||||
for field in [json!({"kind": "unknown"}), json!({"format": "unknown"})] {
|
||||
let mut e = Engine::default();
|
||||
let before = e.session().to_json().unwrap();
|
||||
assert!(e.execute("track.new", &json!({"rows": [{"kind": "folder"}, field]})).is_err());
|
||||
assert_eq!(e.session().to_json().unwrap(), before);
|
||||
assert!(!e.can_undo());
|
||||
assert!(e.journal.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Row structure and aggregate track count reject malformed or oversized requests.
|
||||
#[test]
|
||||
fn row_shape_and_total_size_are_bounded() {
|
||||
for rows in [json!(null), json!({}), json!([]), json!([true]), json!([{"rows": []}]), json!([{"count": 128}, {}]), json!(vec![json!({}); 129])] {
|
||||
let mut e = Engine::default();
|
||||
assert!(e.execute("track.new", &json!({"rows": rows})).is_err(), "{rows}");
|
||||
assert!(e.session().tracks.is_empty());
|
||||
assert!(!e.can_undo());
|
||||
}
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"rows": [{"count": 64}, {"count": 64}]})).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 128);
|
||||
}
|
||||
|
||||
/// Calls without `rows` retain the original defaults and per-row count clamp.
|
||||
#[test]
|
||||
fn legacy_single_row_parameters_keep_their_defaults_and_count_clamp() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({"kind": "aux", "count": -1})).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 1);
|
||||
assert_eq!(e.session().tracks[0].format, ChannelFormat::Stereo);
|
||||
e.execute("track.new", &json!({"count": 200})).unwrap();
|
||||
assert_eq!(e.session().tracks.len(), 129);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::Entry;
|
||||
|
||||
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
@@ -108,14 +110,28 @@ impl Sequence {
|
||||
|
||||
self.ctrls.sort_by_key(|c| c.tick);
|
||||
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
|
||||
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
|
||||
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
|
||||
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
|
||||
// map per tick keeps the work per event constant after a dense tick.
|
||||
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
|
||||
for c in self.ctrls.drain(..) {
|
||||
// Within a tick, replace an earlier event of the same channel+kind.
|
||||
// Only events at the same tick (the tail of `kept`) can match.
|
||||
let slot =
|
||||
kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && kind_key(k.kind) == kind_key(c.kind));
|
||||
match slot {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
match last_at.entry((c.channel, kind_key(c.kind))) {
|
||||
Entry::Occupied(mut e) => {
|
||||
let (tick, idx) = *e.get();
|
||||
match kept.get_mut(idx) {
|
||||
Some(slot) if tick == c.tick => *slot = c,
|
||||
_ => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
Entry::Vacant(e) => {
|
||||
e.insert((c.tick, kept.len()));
|
||||
kept.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.ctrls = kept;
|
||||
|
||||
@@ -222,3 +222,72 @@ fn sequence_serde_round_trip() {
|
||||
let json = serde_json::to_string(&seq).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
|
||||
// Every channel x controller number at one tick, twice: the second copy of each wins. A
|
||||
// rescan of the tick's kept events per incoming event would make this quadratic.
|
||||
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
|
||||
let ctrls = [0, 1]
|
||||
.iter()
|
||||
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
|
||||
.collect();
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls };
|
||||
let start = std::time::Instant::now();
|
||||
assert_eq!(seq.remove_duplicates(), keys.len());
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
|
||||
assert_eq!(seq.ctrls.len(), keys.len());
|
||||
assert!(seq.ctrls.iter().all(|c| c.value == 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
|
||||
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
|
||||
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
|
||||
ctrls.sort_by_key(|c| c.tick);
|
||||
let key = |k: CtrlKind| match k {
|
||||
CtrlKind::Cc(n) => (0, n),
|
||||
CtrlKind::PitchBend => (1, 0),
|
||||
CtrlKind::ChannelPressure => (2, 0),
|
||||
CtrlKind::PolyPressure(p) => (3, p),
|
||||
CtrlKind::Program => (4, 0),
|
||||
};
|
||||
let mut kept: Vec<CtrlEvent> = Vec::new();
|
||||
for c in ctrls {
|
||||
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
|
||||
Some(slot) => *slot = c,
|
||||
None => kept.push(c),
|
||||
}
|
||||
}
|
||||
kept
|
||||
}
|
||||
|
||||
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
|
||||
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
|
||||
let mut state = 0x9E37_79B9_7F4A_7C15u64;
|
||||
let mut next = move || {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state
|
||||
};
|
||||
let kinds = |r: u64| match r % 5 {
|
||||
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
|
||||
1 => CtrlKind::PitchBend,
|
||||
2 => CtrlKind::ChannelPressure,
|
||||
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
|
||||
_ => CtrlKind::Program,
|
||||
};
|
||||
for round in 0..200 {
|
||||
let mut ctrls = Vec::new();
|
||||
for i in 0..(next() % 64) {
|
||||
let r = next();
|
||||
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
|
||||
}
|
||||
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
|
||||
let removed = seq.remove_duplicates();
|
||||
let expected = reference(ctrls.clone());
|
||||
assert_eq!(seq.ctrls, expected, "round {round}");
|
||||
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
|
||||
}
|
||||
}
|
||||
|
||||
+259
-67
@@ -23,6 +23,7 @@ use files::{OPEN_PENDING, default_dir, open_pending_session, path_dialog};
|
||||
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, auto_rename_action, strip_name_fields, strip_silence_action};
|
||||
pub use strip_silence::{StripRects, strip_silence_rectangles};
|
||||
|
||||
pub mod new_tracks;
|
||||
pub mod ops_windows;
|
||||
pub(super) mod recent;
|
||||
pub mod recovery;
|
||||
@@ -30,7 +31,7 @@ pub mod spot;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Dialog {
|
||||
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
|
||||
NewTracks(new_tracks::NewTracks),
|
||||
NewSession { name: String, sample_rate: u32, demo: bool },
|
||||
Bounce { path: String, format: FileFormat, bit_depth: String, normalize: bool, dither: bool },
|
||||
RenameTrack { id: TrackId, name: String },
|
||||
@@ -82,9 +83,10 @@ pub struct Dialogs {
|
||||
}
|
||||
|
||||
impl Dialogs {
|
||||
/// Returns the stable UI identifier of the currently open dialog.
|
||||
pub fn open_name(&self) -> Option<&'static str> {
|
||||
self.open.as_ref().map(|d| match d {
|
||||
Dialog::NewTracks { .. } => "new_tracks",
|
||||
Dialog::NewTracks(_) => "new_tracks",
|
||||
Dialog::NewSession { .. } => "new_session",
|
||||
Dialog::Bounce { .. } => "bounce",
|
||||
Dialog::RenameTrack { .. } => "rename_track",
|
||||
@@ -166,9 +168,7 @@ impl Dialogs {
|
||||
let ed = &e.session().edit;
|
||||
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
|
||||
}
|
||||
"track.new" => {
|
||||
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
|
||||
}
|
||||
"track.new" => Dialog::NewTracks(new_tracks::NewTracks::default()),
|
||||
"session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false },
|
||||
"file.bounce_mix" => {
|
||||
let dir = e
|
||||
@@ -281,54 +281,6 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<
|
||||
Some(text)
|
||||
}
|
||||
|
||||
/// The New Tracks form; the command to run once Create is pressed.
|
||||
fn new_tracks_form(
|
||||
ui: &mut egui::Ui,
|
||||
count: &mut u32,
|
||||
format: &mut String,
|
||||
kind: &mut String,
|
||||
timebase: &mut String,
|
||||
name: &mut String,
|
||||
enter: bool,
|
||||
) -> Option<(String, Value)> {
|
||||
ui.horizontal(|ui| {
|
||||
let create = ui.label("Create");
|
||||
ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id);
|
||||
ui.label("new");
|
||||
let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
|
||||
for f in soundcraft_model::ChannelFormat::ALL {
|
||||
ui.selectable_value(format, f.label().to_string(), f.label());
|
||||
}
|
||||
});
|
||||
fmt.response.accessible_name("Channel format");
|
||||
let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind")
|
||||
.selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
for k in soundcraft_model::TrackKind::ALL {
|
||||
if ui.selectable_label(kind == k.id(), k.label()).clicked() {
|
||||
*kind = k.id().to_string();
|
||||
*name = k.default_name().to_string();
|
||||
}
|
||||
}
|
||||
});
|
||||
kind_combo.response.accessible_name("Track kind");
|
||||
ui.label("in");
|
||||
let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(ui, |ui| {
|
||||
ui.selectable_value(timebase, "samples".to_string(), "Samples");
|
||||
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
|
||||
});
|
||||
tb.response.accessible_name("Timebase");
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Name:");
|
||||
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
|
||||
});
|
||||
if buttons(ui, "Create", enter) {
|
||||
return Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name})));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The Bounce form's file row: a path field and a Browse button that also picks the format.
|
||||
fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut FileFormat) {
|
||||
ui.horizontal(|ui| {
|
||||
@@ -462,6 +414,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialogs.open.take() else { return };
|
||||
let mut keep = true;
|
||||
let mut action: Option<(String, Value)> = None;
|
||||
let mut bounce_params: Option<Value> = None;
|
||||
let mut save_first: Option<(String, Value)> = None;
|
||||
let confirmed = matches!(d, Dialog::SaveChanges { .. });
|
||||
// Strip Silence's Rename button, and the naming scheme its dialog's OK keeps.
|
||||
@@ -469,7 +422,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
let mut renamed: Option<StripNames> = None;
|
||||
let strip_names = app.dialogs.strip_names.clone();
|
||||
let title = match &d {
|
||||
Dialog::NewTracks { .. } => "New Tracks",
|
||||
Dialog::NewTracks(_) => "New Tracks",
|
||||
Dialog::NewSession { .. } => "New Session",
|
||||
Dialog::Bounce { .. } => "Bounce Mix",
|
||||
Dialog::RenameTrack { .. } => "Rename Track",
|
||||
@@ -498,11 +451,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
.resizable(false)
|
||||
.anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
|
||||
.show(ctx, |ui| {
|
||||
// Escape owns the whole frame: no focused button or other control may act first.
|
||||
if esc {
|
||||
return;
|
||||
}
|
||||
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
|
||||
match &mut d {
|
||||
Dialog::NewTracks { count, format, kind, timebase, name } => {
|
||||
if let Some(cmd) = new_tracks_form(ui, count, format, kind, timebase, name, enter) {
|
||||
action = Some(cmd);
|
||||
Dialog::NewTracks(tracks) => {
|
||||
if let Some(params) = tracks.show(ui, enter) {
|
||||
action = Some(("track.new".into(), params));
|
||||
}
|
||||
}
|
||||
Dialog::NewSession { name, sample_rate, demo } => {
|
||||
@@ -550,11 +507,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
);
|
||||
if buttons(ui, "Bounce", enter) {
|
||||
let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension());
|
||||
crate::bounce::start(
|
||||
app,
|
||||
&json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
|
||||
bounce_params = Some(
|
||||
json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
|
||||
);
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
Dialog::RenameTrack { id, name } => {
|
||||
@@ -759,14 +714,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
});
|
||||
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) {
|
||||
keep = false;
|
||||
action = None;
|
||||
bounce_params = None;
|
||||
save_first = None;
|
||||
app.dialogs.after_save = None;
|
||||
app.dialogs.pending_session = None;
|
||||
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::unique("dlg_cancel")));
|
||||
}
|
||||
// Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.
|
||||
if !keep && matches!(d, Dialog::AutoRename { .. }) {
|
||||
action = None;
|
||||
}
|
||||
if let Some(strip) = rename_from
|
||||
&& keep
|
||||
{
|
||||
@@ -794,6 +748,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
|
||||
}
|
||||
app.dialogs.after_save = Some(then);
|
||||
}
|
||||
if let Some(params) = bounce_params {
|
||||
keep = false;
|
||||
crate::bounce::start(app, ¶ms);
|
||||
}
|
||||
if let Some((cmd, params)) = &action
|
||||
&& !confirmed
|
||||
&& ask_to_save(app, cmd, params)
|
||||
@@ -844,11 +802,13 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
|
||||
}
|
||||
|
||||
/// `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 {
|
||||
pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
|
||||
let mut pressed = enter && ready;
|
||||
let mut canceled = false;
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Cancel").clicked() {
|
||||
canceled = true;
|
||||
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true));
|
||||
}
|
||||
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
|
||||
@@ -856,7 +816,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo
|
||||
pressed = true;
|
||||
}
|
||||
});
|
||||
pressed
|
||||
pressed && !canceled
|
||||
}
|
||||
|
||||
/// A name field that takes the focus with its text selected, so typing replaces the old name.
|
||||
@@ -915,6 +875,238 @@ fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, sta
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dialogs::new_tracks::set_new_track_kind;
|
||||
|
||||
/// Opens and settles the dialog for a chosen desktop operating system.
|
||||
fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) {
|
||||
let ctx = egui::Context::default();
|
||||
ctx.set_os(os);
|
||||
crate::fonts::install(&ctx, true);
|
||||
let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
(ctx, app)
|
||||
}
|
||||
|
||||
/// Runs one dialog frame with synthetic input events.
|
||||
fn dialog_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
|
||||
let raw =
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
|
||||
let mut output = ctx.run_ui(raw, |ui| {
|
||||
crate::shortcuts::handle(app, ui.ctx());
|
||||
show(app, ui.ctx());
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
|
||||
/// Constructs one non-repeating key-press event for dialog tests.
|
||||
fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
|
||||
}
|
||||
|
||||
/// Returns every editable value from the active New Tracks row.
|
||||
fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) {
|
||||
match app.dialogs.open.as_ref().unwrap() {
|
||||
Dialog::NewTracks(tracks) => {
|
||||
let row = tracks.active_row().unwrap();
|
||||
(row.count, &row.format, &row.kind, &row.timebase, &row.name)
|
||||
}
|
||||
other => panic!("unexpected dialog: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opening selects the count, and bare arrows adjust it within its bounds.
|
||||
#[test]
|
||||
fn new_tracks_focus_selects_count_and_arrows_clamp_it() {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("7".into())]);
|
||||
assert_eq!(track_fields(&app).0, 7, "typing must replace the selected initial value");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 8);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 7);
|
||||
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
|
||||
tracks.rows[0].count = 128;
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 128);
|
||||
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
|
||||
tracks.rows[0].count = 1;
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
|
||||
assert_eq!(track_fields(&app).0, 1);
|
||||
}
|
||||
|
||||
/// macOS command-arrow families edit format, type and timebase without changing count.
|
||||
#[test]
|
||||
fn new_tracks_mac_chords_change_settings_without_changing_count() {
|
||||
for ctrl in [false, true] {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
let cmd = egui::Modifiers { command: true, mac_cmd: true, ctrl, ..Default::default() };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
|
||||
assert_eq!(track_fields(&app), (1, "Stereo", "audio", "samples", "Audio"));
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
|
||||
assert_eq!(track_fields(&app).1, "Mono");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
|
||||
assert_eq!(track_fields(&app).1, soundcraft_model::ChannelFormat::ALL.last().unwrap().label());
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
|
||||
assert_eq!(track_fields(&app), (1, "Mono", "aux", "samples", "Aux"));
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
|
||||
assert_eq!(track_fields(&app).2, "audio");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
|
||||
assert_eq!(track_fields(&app).2, "video");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
|
||||
assert_eq!(track_fields(&app).4, "Audio");
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
}
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
let cmd_alt = egui::Modifiers { command: true, mac_cmd: true, alt: true, ..Default::default() };
|
||||
for (arrow, expected) in [(egui::Key::ArrowDown, "ticks"), (egui::Key::ArrowUp, "samples")] {
|
||||
dialog_frame(&ctx, &mut app, vec![key(arrow, cmd_alt)]);
|
||||
assert_eq!(track_fields(&app).3, expected);
|
||||
assert_eq!(track_fields(&app).0, 1);
|
||||
assert_eq!(track_fields(&app).2, "audio");
|
||||
}
|
||||
}
|
||||
|
||||
/// Type changes update default names while preserving names entered by the user.
|
||||
#[test]
|
||||
fn new_tracks_type_changes_preserve_custom_names() {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
|
||||
tracks.rows[0].name = "Dialogue cinema".into();
|
||||
}
|
||||
let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
|
||||
assert_eq!(track_fields(&app).4, "Dialogue cinema");
|
||||
let mut kind = "audio".to_string();
|
||||
let mut name = "Audio".to_string();
|
||||
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Instrument);
|
||||
assert_eq!(name, "Inst");
|
||||
name = "Foley".to_string();
|
||||
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Aux);
|
||||
assert_eq!(name, "Foley");
|
||||
}
|
||||
|
||||
/// Enter creates, while Escape cancels even when both keys arrive in one frame.
|
||||
#[test]
|
||||
fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() {
|
||||
for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
|
||||
let (ctx, mut app) = tracks_dialog(os);
|
||||
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("3".into())]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), 3);
|
||||
assert!(app.engine.session().markers.is_empty(), "Enter must not add a background marker");
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), 3);
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), 3, "Escape must cancel a simultaneous Enter");
|
||||
}
|
||||
}
|
||||
|
||||
/// Row shortcuts change the form without submitting when Enter arrives in the same frame.
|
||||
#[test]
|
||||
fn new_tracks_row_shortcuts_take_precedence_over_simultaneous_enter() {
|
||||
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
|
||||
let rows = egui::Modifiers { command: true, mac_cmd: true, shift: true, ..Default::default() };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
assert_eq!(tracks.rows.len(), 1, "the last row cannot be removed");
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
assert_eq!(tracks.rows.len(), 2);
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
assert_eq!(tracks.rows.len(), 1);
|
||||
assert!(app.engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
/// Escape prevents a simultaneous Enter from starting a background bounce.
|
||||
#[test]
|
||||
fn simultaneous_enter_and_escape_does_not_start_a_bounce() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx, true);
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert!(app.bounce.is_none());
|
||||
}
|
||||
|
||||
/// Escape prevents a simultaneous Save from scheduling the deferred session action.
|
||||
#[test]
|
||||
fn simultaneous_save_and_escape_does_not_continue_after_saving() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::fonts::install(&ctx, true);
|
||||
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
|
||||
let tracks = app.engine.session().tracks.len();
|
||||
app.dialogs.open = Some(Dialog::SaveChanges { then: ("session.close".into(), json!({})) });
|
||||
for _ in 0..3 {
|
||||
dialog_frame(&ctx, &mut app, vec![]);
|
||||
}
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
|
||||
assert!(app.dialogs.open.is_none());
|
||||
assert!(app.dialogs.after_save.is_none());
|
||||
assert_eq!(app.engine.session().tracks.len(), tracks);
|
||||
}
|
||||
|
||||
/// Windows and Linux chords cover every editable field and row action.
|
||||
#[test]
|
||||
fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() {
|
||||
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
|
||||
let (ctx, mut app) = tracks_dialog(os);
|
||||
let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
|
||||
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl)]);
|
||||
assert_eq!(track_fields(&app).1, "Stereo");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, ctrl)]);
|
||||
assert_eq!(track_fields(&app).1, "Mono");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]);
|
||||
assert_eq!(track_fields(&app).2, "aux");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl)]);
|
||||
assert_eq!(track_fields(&app).2, "audio");
|
||||
|
||||
let ctrl_alt = egui::Modifiers { alt: true, ..ctrl };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl_alt)]);
|
||||
assert_eq!(track_fields(&app).3, "ticks");
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl_alt)]);
|
||||
assert_eq!(track_fields(&app).3, "samples");
|
||||
|
||||
for alt in [false, true] {
|
||||
let rows = egui::Modifiers { shift: true, alt, ..ctrl };
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
|
||||
assert_eq!(tracks.rows.len(), 2);
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows)]);
|
||||
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
|
||||
assert_eq!(tracks.rows.len(), 1);
|
||||
}
|
||||
|
||||
let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open else { panic!("missing dialog") };
|
||||
tracks.rows[0].kind = "video".into();
|
||||
tracks.rows[0].timebase = "ticks".into();
|
||||
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl), key(egui::Key::ArrowUp, ctrl_alt)]);
|
||||
assert_eq!(track_fields(&app).1, "Mono", "Video has no format control");
|
||||
assert_eq!(track_fields(&app).3, "ticks", "Video has no timebase control");
|
||||
}
|
||||
}
|
||||
|
||||
/// A name that is not valid Unicode is not opened under a lossy rendering (another file): the
|
||||
/// status line names its bytes.
|
||||
|
||||
@@ -0,0 +1,613 @@
|
||||
//! One or more independent New Tracks rows, submitted as a single engine command.
|
||||
|
||||
use egui::{Event, Key, TextStyle, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use soundcraft_model::{ChannelFormat, TrackKind};
|
||||
|
||||
const MAX_TRACKS: usize = 128;
|
||||
const NAME_WIDTH: f32 = 260.0;
|
||||
const ROW_BUTTON_WIDTH: f32 = 22.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// One independently configured row in the New Tracks dialog.
|
||||
pub struct NewTrackRow {
|
||||
key: u64,
|
||||
pub count: u32,
|
||||
pub format: String,
|
||||
pub kind: String,
|
||||
pub timebase: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
impl Default for NewTrackRow {
|
||||
/// Starts one mono audio row with the program defaults.
|
||||
fn default() -> Self {
|
||||
Self { key: 0, count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
|
||||
}
|
||||
}
|
||||
|
||||
impl NewTrackRow {
|
||||
/// Builds one `track.new` row without values belonging to hidden controls.
|
||||
fn params(&self) -> Value {
|
||||
let mut params = json!({"count": self.count, "kind": self.kind, "name": self.name});
|
||||
if has_format(&self.kind) {
|
||||
params["format"] = json!(self.format);
|
||||
}
|
||||
if has_timebase(&self.kind) {
|
||||
params["timebase"] = json!(self.timebase);
|
||||
}
|
||||
params
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// State for the multi-row New Tracks dialog.
|
||||
pub struct NewTracks {
|
||||
pub rows: Vec<NewTrackRow>,
|
||||
active: usize,
|
||||
next_key: u64,
|
||||
focus_count: bool,
|
||||
}
|
||||
|
||||
impl Default for NewTracks {
|
||||
/// Starts the dialog with one row whose count field receives focus.
|
||||
fn default() -> Self {
|
||||
Self { rows: vec![NewTrackRow::default()], active: 0, next_key: 1, focus_count: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl NewTracks {
|
||||
/// Returns the row currently targeted by keyboard shortcuts.
|
||||
pub fn active_row(&self) -> Option<&NewTrackRow> {
|
||||
self.rows.get(self.active)
|
||||
}
|
||||
|
||||
/// Inserts a default row after `index` and moves focus to its count.
|
||||
fn insert_after(&mut self, index: usize) {
|
||||
if self.rows.len() >= MAX_TRACKS || self.rows.get(index).is_none() {
|
||||
return;
|
||||
}
|
||||
let Some(next) = self.next_key.checked_add(1) else { return };
|
||||
self.rows.insert(index + 1, NewTrackRow { key: self.next_key, ..NewTrackRow::default() });
|
||||
self.next_key = next;
|
||||
self.active = index + 1;
|
||||
self.focus_count = true;
|
||||
}
|
||||
|
||||
/// Removes `index` while retaining at least one row and a valid active row.
|
||||
fn remove(&mut self, index: usize) {
|
||||
if self.rows.len() <= 1 || self.rows.get(index).is_none() {
|
||||
return;
|
||||
}
|
||||
self.rows.remove(index);
|
||||
if index == self.active {
|
||||
self.active = index.min(self.rows.len().saturating_sub(1));
|
||||
self.focus_count = true;
|
||||
} else if index < self.active {
|
||||
self.active = self.active.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the row, format, type and timebase shortcuts and reports a row action.
|
||||
fn keys(&mut self, ctx: &egui::Context) -> bool {
|
||||
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
|
||||
let mut row_action = false;
|
||||
ctx.input_mut(|i| {
|
||||
i.events.retain(|event| {
|
||||
let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true };
|
||||
if !m.command {
|
||||
return true;
|
||||
}
|
||||
if m.shift {
|
||||
match key {
|
||||
Key::ArrowDown => self.insert_after(self.active),
|
||||
Key::ArrowUp => self.remove(self.active),
|
||||
_ => return true,
|
||||
}
|
||||
row_action = true;
|
||||
return false;
|
||||
}
|
||||
if m.alt && m.ctrl && mac {
|
||||
return true;
|
||||
}
|
||||
let Some(row) = self.rows.get_mut(self.active) else { return true };
|
||||
let forward = matches!(key, Key::ArrowRight | Key::ArrowDown);
|
||||
match key {
|
||||
Key::ArrowLeft | Key::ArrowRight if !m.alt => {
|
||||
if has_format(&row.kind) {
|
||||
let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0);
|
||||
if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) {
|
||||
row.format = next.label().to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
Key::ArrowUp | Key::ArrowDown if m.alt => {
|
||||
if has_timebase(&row.kind) {
|
||||
row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string();
|
||||
}
|
||||
}
|
||||
Key::ArrowUp | Key::ArrowDown => {
|
||||
let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0);
|
||||
if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) {
|
||||
set_new_track_kind(&mut row.kind, &mut row.name, *next);
|
||||
}
|
||||
}
|
||||
_ => return true,
|
||||
}
|
||||
false
|
||||
});
|
||||
});
|
||||
row_action
|
||||
}
|
||||
|
||||
/// Draws every creation row and returns one atomic `track.new` request when submitted.
|
||||
pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option<Value> {
|
||||
let shortcut_row_action = self.keys(ui.ctx());
|
||||
let widths = RowWidths::new(ui);
|
||||
ui.set_min_width(widths.total(ui));
|
||||
// The old row must not consume text on the first frame after adding a new one.
|
||||
if self.focus_count && ui.is_enabled() && !ui.is_sizing_pass() {
|
||||
ui.memory_mut(|m| {
|
||||
if let Some(id) = m.focused() {
|
||||
m.surrender_focus(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
let mut insert = None;
|
||||
let mut remove = None;
|
||||
let len = self.rows.len();
|
||||
egui::ScrollArea::vertical().id_salt("new_tracks_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
for (index, row) in self.rows.iter_mut().enumerate() {
|
||||
// Explicit row IDs keep editing state with the row when rows above it are removed.
|
||||
let id = egui::Id::unique(("new_tracks_row", row.key));
|
||||
let rect = egui::Rect::from_min_size(ui.available_rect_before_wrap().min, vec2(ui.available_width(), ui.spacing().interact_size.y));
|
||||
ui.scope_builder(egui::UiBuilder::new().scope_id(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
|
||||
let label = ui.label("Create");
|
||||
let counter = ui
|
||||
.scope_builder(egui::UiBuilder::new().scope_id(id.with("count")), |ui| {
|
||||
ui.spacing_mut().interact_size.x = widths.count;
|
||||
if self.focus_count && index == self.active && ui.is_enabled() && !ui.is_sizing_pass() {
|
||||
ui.memory_mut(|m| m.request_focus(ui.next_auto_id()));
|
||||
self.focus_count = false;
|
||||
}
|
||||
ui.add(egui::DragValue::new(&mut row.count).speed(1.0).range(1..=128)).labelled_by(label.id)
|
||||
})
|
||||
.inner;
|
||||
row.count = row.count.clamp(1, 128);
|
||||
if counter.has_focus() || counter.clicked() {
|
||||
self.active = index;
|
||||
}
|
||||
if counter.gained_focus() {
|
||||
counter.scroll_to_me(Some(egui::Align::Center));
|
||||
}
|
||||
ui.label("new");
|
||||
let format = if has_format(&row.kind) {
|
||||
Some(
|
||||
craft_ui::ComboBox::from_id_salt("nt_fmt")
|
||||
.width(widths.format)
|
||||
.selected_text(&row.format)
|
||||
.show_ui(ui, |ui| {
|
||||
for f in ChannelFormat::ALL {
|
||||
ui.selectable_value(&mut row.format, f.label().to_string(), f.label());
|
||||
}
|
||||
})
|
||||
.response
|
||||
.accessible_name("Channel format"),
|
||||
)
|
||||
} else {
|
||||
ui.allocate_space(vec2(widths.format, ui.spacing().interact_size.y));
|
||||
None
|
||||
};
|
||||
let kind = craft_ui::ComboBox::from_id_salt("nt_kind")
|
||||
.width(widths.kind)
|
||||
.selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label()))
|
||||
.show_ui(ui, |ui| {
|
||||
for k in TrackKind::ALL {
|
||||
if ui.selectable_label(row.kind == k.id(), k.label()).clicked() {
|
||||
set_new_track_kind(&mut row.kind, &mut row.name, k);
|
||||
}
|
||||
}
|
||||
})
|
||||
.response
|
||||
.accessible_name("Track kind");
|
||||
let timebase = if has_timebase(&row.kind) {
|
||||
ui.label("in");
|
||||
Some(
|
||||
craft_ui::ComboBox::from_id_salt("nt_tb")
|
||||
.width(widths.timebase)
|
||||
.selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" })
|
||||
.show_ui(ui, |ui| {
|
||||
ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples");
|
||||
ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks");
|
||||
})
|
||||
.response
|
||||
.accessible_name("Timebase"),
|
||||
)
|
||||
} else {
|
||||
ui.allocate_space(vec2(text_width(ui, "in"), ui.spacing().interact_size.y));
|
||||
ui.allocate_space(vec2(widths.timebase, ui.spacing().interact_size.y));
|
||||
None
|
||||
};
|
||||
let label = ui.label("Name:");
|
||||
let name = crate::widgets::named_text_field(
|
||||
ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id),
|
||||
"Name:",
|
||||
);
|
||||
if format.iter().chain(timebase.iter()).chain([&kind, &name]).any(|r| r.clicked() || r.has_focus()) {
|
||||
self.active = index;
|
||||
}
|
||||
let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y);
|
||||
if ui.add_enabled(len > 1, egui::Button::new("−").min_size(size)).on_hover_text("Remove this row").clicked() {
|
||||
remove = Some(index);
|
||||
}
|
||||
if ui.add_enabled(len < MAX_TRACKS, egui::Button::new("+").min_size(size)).on_hover_text("Add a row below").clicked() {
|
||||
insert = Some(index);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
let row_action = shortcut_row_action || remove.is_some() || insert.is_some();
|
||||
if let Some(index) = remove {
|
||||
self.active = index;
|
||||
self.remove(index);
|
||||
ui.ctx().request_repaint();
|
||||
} else if let Some(index) = insert {
|
||||
self.insert_after(index);
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
let total = self.rows.iter().fold(0_u32, |total, row| total.saturating_add(row.count));
|
||||
let can_create = !self.rows.is_empty() && total <= MAX_TRACKS as u32;
|
||||
if !can_create {
|
||||
ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time.");
|
||||
}
|
||||
// Enter on a focused row button changes that row only; it must not also submit the dialog.
|
||||
if crate::dialogs::buttons_enabled(ui, "Create", enter && !row_action, can_create) && !row_action {
|
||||
Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::<Vec<_>>()}))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure all choices with the current UI font, rather than the selected label.
|
||||
struct RowWidths {
|
||||
count: f32,
|
||||
format: f32,
|
||||
kind: f32,
|
||||
timebase: f32,
|
||||
}
|
||||
|
||||
impl RowWidths {
|
||||
/// Measures every possible choice so row columns stay fixed while values change.
|
||||
fn new(ui: &Ui) -> Self {
|
||||
Self {
|
||||
count: (styled_text_width(ui, "128", &ui.style().drag_value_text_style) + 2.0 * ui.spacing().button_padding.x)
|
||||
.ceil()
|
||||
.max(ui.spacing().interact_size.x),
|
||||
format: combo_width(ui, ChannelFormat::ALL.iter().map(|f| f.label())),
|
||||
kind: combo_width(ui, TrackKind::ALL.iter().map(|k| k.label())),
|
||||
timebase: combo_width(ui, ["Samples", "Ticks"].into_iter()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the complete width required by one row, including labels and buttons.
|
||||
fn total(&self, ui: &Ui) -> f32 {
|
||||
let labels: f32 = ["Create", "new", "in", "Name:"].iter().map(|s| text_width(ui, s)).sum();
|
||||
labels + self.count + self.format + self.kind + self.timebase + NAME_WIDTH + 2.0 * ROW_BUTTON_WIDTH + 10.0 * ui.spacing().item_spacing.x + 8.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Measures ordinary button text with the current theme and font scale.
|
||||
fn text_width(ui: &Ui, text: &str) -> f32 {
|
||||
styled_text_width(ui, text, &TextStyle::Button)
|
||||
}
|
||||
|
||||
/// Measures one string using the supplied egui text style.
|
||||
fn styled_text_width(ui: &Ui, text: &str, style: &TextStyle) -> f32 {
|
||||
let font = style.resolve(ui.style());
|
||||
ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, ui.visuals().text_color()).size().x)
|
||||
}
|
||||
|
||||
/// Computes a stable ComboBox width from all labels it may display.
|
||||
fn combo_width<'a>(ui: &Ui, labels: impl Iterator<Item = &'a str>) -> f32 {
|
||||
let longest = labels.map(|s| text_width(ui, s)).fold(0.0, f32::max);
|
||||
(longest + ui.spacing().icon_width + ui.spacing().icon_spacing + 2.0 * ui.spacing().button_padding.x + 2.0).ceil()
|
||||
}
|
||||
|
||||
/// Changes kind and updates the default name without replacing a custom name.
|
||||
pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: TrackKind) {
|
||||
if TrackKind::from_id(kind).is_some_and(|old| name == old.default_name()) {
|
||||
*name = next.default_name().to_string();
|
||||
}
|
||||
*kind = next.id().to_string();
|
||||
}
|
||||
|
||||
/// Reports whether a track kind exposes a channel-format control.
|
||||
fn has_format(kind: &str) -> bool {
|
||||
!matches!(TrackKind::from_id(kind), Some(TrackKind::Midi | TrackKind::Vca | TrackKind::Video))
|
||||
}
|
||||
|
||||
/// Reports whether a track kind exposes a samples/ticks control.
|
||||
fn has_timebase(kind: &str) -> bool {
|
||||
!matches!(TrackKind::from_id(kind), Some(TrackKind::Video))
|
||||
}
|
||||
|
||||
/// Moves one position through a circular choice list in either direction.
|
||||
fn cycle_index(index: usize, len: usize, forward: bool) -> usize {
|
||||
if forward {
|
||||
if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 }
|
||||
} else {
|
||||
index.checked_sub(1).unwrap_or(len.saturating_sub(1))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Services, SoundApp, dialogs::Dialog};
|
||||
use egui::{Modifiers, accesskit::Role};
|
||||
use egui_kittest::{Harness, kittest::Queryable};
|
||||
|
||||
/// Opens a settled New Tracks dialog in a macOS UI harness.
|
||||
fn harness() -> Harness<'static, SoundApp> {
|
||||
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
|
||||
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|
||||
|ui, app: &mut SoundApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.ctx.set_os(egui::os::OperatingSystem::Mac);
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// Borrows the dialog model from a test harness.
|
||||
fn tracks<'a>(h: &'a Harness<'_, SoundApp>) -> &'a NewTracks {
|
||||
let Some(Dialog::NewTracks(tracks)) = &h.state().dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
tracks
|
||||
}
|
||||
|
||||
/// Mutably borrows the dialog model from a test harness.
|
||||
fn tracks_mut<'a>(h: &'a mut Harness<'_, SoundApp>) -> &'a mut NewTracks {
|
||||
let Some(Dialog::NewTracks(tracks)) = &mut h.state_mut().dialogs.open else { panic!("missing New Tracks dialog") };
|
||||
tracks
|
||||
}
|
||||
|
||||
/// Sends one keyboard chord and lets a resized dialog settle.
|
||||
fn chord(h: &mut Harness<SoundApp>, m: Modifiers, key: Key) {
|
||||
h.key_press_modifiers(m, key);
|
||||
// A centered window needs another frame to settle after its row count changes.
|
||||
h.run_steps(4);
|
||||
}
|
||||
|
||||
/// Every selectable label retains its column's maximum measured width.
|
||||
#[test]
|
||||
fn every_choice_keeps_the_same_field_widths_and_name_position() {
|
||||
let mut h = harness();
|
||||
let format = h.get_by_value("Mono").rect();
|
||||
let kind = h.get_by_value("Audio Track").rect();
|
||||
let timebase = h.get_by_value("Samples").rect();
|
||||
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert!(name.width() >= NAME_WIDTH);
|
||||
for f in ChannelFormat::ALL {
|
||||
tracks_mut(&mut h).rows[0].format = f.label().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_value(f.label()).rect().width(), format.width(), "{}", f.label());
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
|
||||
}
|
||||
for k in TrackKind::ALL {
|
||||
tracks_mut(&mut h).rows[0].kind = k.id().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label());
|
||||
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
|
||||
}
|
||||
tracks_mut(&mut h).rows[0].kind = "audio".to_string();
|
||||
tracks_mut(&mut h).rows[0].timebase = "ticks".to_string();
|
||||
tracks_mut(&mut h).rows[0].count = 128;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_value("Ticks").rect().width(), timebase.width());
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
|
||||
h.ctx.global_style_mut(|s| s.drag_value_text_style = TextStyle::Heading);
|
||||
tracks_mut(&mut h).rows[0].count = 1;
|
||||
h.run_steps(4);
|
||||
let larger_font_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
tracks_mut(&mut h).rows[0].count = 128;
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name);
|
||||
}
|
||||
|
||||
/// Hidden controls retain their layout space for kinds that do not use them.
|
||||
#[test]
|
||||
fn inapplicable_fields_are_hidden_without_moving_the_other_columns() {
|
||||
let mut h = harness();
|
||||
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
let all_fields = h.get_all_by_role(Role::ComboBox).count();
|
||||
|
||||
for kind in [TrackKind::Midi, TrackKind::Vca] {
|
||||
tracks_mut(&mut h).rows[0].kind = kind.id().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 1, "{}", kind.label());
|
||||
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
|
||||
}
|
||||
|
||||
tracks_mut(&mut h).rows[0].kind = TrackKind::Video.id().to_string();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 2);
|
||||
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
|
||||
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
|
||||
}
|
||||
|
||||
/// Submitted rows omit controls that are hidden for their selected track kind.
|
||||
#[test]
|
||||
fn inapplicable_fields_are_omitted_from_row_parameters() {
|
||||
let row = NewTrackRow {
|
||||
count: 2,
|
||||
format: "Stereo".into(),
|
||||
kind: TrackKind::Midi.id().into(),
|
||||
timebase: "ticks".into(),
|
||||
name: "MIDI".into(),
|
||||
..NewTrackRow::default()
|
||||
};
|
||||
assert_eq!(row.params(), json!({"count": 2, "kind": "midi", "timebase": "ticks", "name": "MIDI"}));
|
||||
|
||||
let row = NewTrackRow { kind: TrackKind::Vca.id().into(), name: "VCA".into(), ..row };
|
||||
assert_eq!(row.params(), json!({"count": 2, "kind": "vca", "timebase": "ticks", "name": "VCA"}));
|
||||
|
||||
let row = NewTrackRow { kind: TrackKind::Video.id().into(), name: "Video".into(), ..row };
|
||||
assert_eq!(row.params(), json!({"count": 2, "kind": "video", "name": "Video"}));
|
||||
}
|
||||
|
||||
/// Both supported add/remove shortcut variants preserve independent row state.
|
||||
#[test]
|
||||
fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() {
|
||||
for alt in [false, true] {
|
||||
let mut h = harness();
|
||||
let row_chord = Modifiers { command: true, mac_cmd: true, ctrl: true, shift: true, alt };
|
||||
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
|
||||
chord(&mut h, row_chord, Key::ArrowDown);
|
||||
assert_eq!(tracks(&h).rows.len(), 2);
|
||||
assert_eq!(tracks(&h).active, 1);
|
||||
h.event(Event::Text("7".into()));
|
||||
h.run_steps(2);
|
||||
assert_eq!(tracks(&h).rows[1].count, 7);
|
||||
assert_eq!(tracks(&h).rows[0].count, 1);
|
||||
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, ..Default::default() }, Key::ArrowDown);
|
||||
assert_eq!(tracks(&h).rows[1].kind, "aux");
|
||||
assert_eq!(tracks(&h).rows[0].kind, "audio");
|
||||
chord(&mut h, row_chord, Key::ArrowUp);
|
||||
assert_eq!(tracks(&h).rows.len(), 1);
|
||||
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
|
||||
chord(&mut h, row_chord, Key::ArrowUp);
|
||||
assert_eq!(tracks(&h).rows.len(), 1, "the last row cannot be removed");
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// Each row's add and remove buttons act at that row's position.
|
||||
#[test]
|
||||
fn plus_and_minus_buttons_operate_on_their_own_row() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::Button, "+").click();
|
||||
h.run_steps(4);
|
||||
let names: Vec<_> = h.get_all_by_role_and_label(Role::TextInput, "Name:").map(|n| n.rect()).collect();
|
||||
assert_eq!(names.len(), 2);
|
||||
assert_eq!(names[0].min.x, names[1].min.x);
|
||||
assert_eq!(names[0].width(), names[1].width());
|
||||
assert!(names[1].min.y - names[0].min.y <= 32.0, "rows must stay compact: {names:?}");
|
||||
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
|
||||
tracks_mut(&mut h).rows[1].name = "Effects".into();
|
||||
h.get_all_by_role_and_label(Role::Button, "+").next().unwrap().click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Audio", "Effects"]);
|
||||
h.get_all_by_role_and_label(Role::Button, "−").nth(1).unwrap().click();
|
||||
h.run_steps(4);
|
||||
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Effects"]);
|
||||
}
|
||||
|
||||
/// Enter on a focused row button changes the rows without also creating tracks.
|
||||
#[test]
|
||||
fn enter_on_row_buttons_does_not_submit_the_dialog() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::Button, "+").focus();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert_eq!(tracks(&h).rows.len(), 2);
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
|
||||
h.get_all_by_role_and_label(Role::Button, "−").next().unwrap().focus();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert_eq!(tracks(&h).rows.len(), 1);
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
/// Enter on the focused Cancel button closes the dialog without creating tracks.
|
||||
#[test]
|
||||
fn enter_on_cancel_does_not_submit_the_dialog() {
|
||||
let mut h = harness();
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").focus();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
}
|
||||
|
||||
/// Removing a preceding row does not transfer widget state to another row.
|
||||
#[test]
|
||||
fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() {
|
||||
let mut h = harness();
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
h.event(Event::Text("42".into()));
|
||||
h.run_steps(2);
|
||||
let focused = h.ctx.memory(|m| m.focused());
|
||||
let key = tracks(&h).rows[1].key;
|
||||
tracks_mut(&mut h).remove(0);
|
||||
h.run_steps(2);
|
||||
assert_eq!(tracks(&h).rows[0].key, key);
|
||||
assert_eq!(tracks(&h).rows[0].count, 42);
|
||||
assert_eq!(h.ctx.memory(|m| m.focused()), focused);
|
||||
chord(&mut h, Modifiers::NONE, Key::ArrowUp);
|
||||
assert_eq!(tracks(&h).rows[0].count, 43);
|
||||
}
|
||||
|
||||
/// A clicked row receives later shortcuts without altering its custom name.
|
||||
#[test]
|
||||
fn clicking_a_different_row_targets_its_chords_and_keeps_its_custom_name() {
|
||||
let mut h = harness();
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
|
||||
h.get_all_by_role_and_label(Role::TextInput, "Name:").next().unwrap().click();
|
||||
h.run_steps(2);
|
||||
let cmd = Modifiers { command: true, mac_cmd: true, ..Default::default() };
|
||||
chord(&mut h, cmd, Key::ArrowDown);
|
||||
chord(&mut h, cmd, Key::ArrowRight);
|
||||
chord(&mut h, Modifiers { alt: true, ..cmd }, Key::ArrowUp);
|
||||
let rows = &tracks(&h).rows;
|
||||
assert_eq!(
|
||||
(&rows[0].kind, &rows[0].format, &rows[0].timebase, &rows[0].name),
|
||||
(&"aux".to_string(), &"Stereo".to_string(), &"ticks".to_string(), &"Dialogue".to_string())
|
||||
);
|
||||
assert_eq!((&rows[1].kind, &rows[1].format, &rows[1].timebase), (&"audio".to_string(), &"Mono".to_string(), &"samples".to_string()));
|
||||
assert_eq!((rows[0].count, rows[1].count), (1, 1));
|
||||
}
|
||||
|
||||
/// Submitting several rows creates and undoes them as one engine action.
|
||||
#[test]
|
||||
fn create_submits_every_row_and_undo_removes_the_whole_creation() {
|
||||
let mut h = harness();
|
||||
tracks_mut(&mut h).rows[0].count = 2;
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
let row = &mut tracks_mut(&mut h).rows[1];
|
||||
row.kind = "aux".into();
|
||||
row.format = "Stereo".into();
|
||||
row.name = "Reverb".into();
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
assert_eq!(
|
||||
h.state().engine.session().tracks.iter().map(|t| t.kind).collect::<Vec<_>>(),
|
||||
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux]
|
||||
);
|
||||
assert_eq!(h.state().engine.session().tracks[2].name, "Reverb");
|
||||
assert!(h.state().engine.session().markers.is_empty());
|
||||
assert!(h.state_mut().engine.undo());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
assert!(!h.state().engine.can_undo());
|
||||
}
|
||||
|
||||
/// The aggregate 128-track limit disables creation while leaving cancellation usable.
|
||||
#[test]
|
||||
fn too_many_tracks_disable_create_but_leave_cancel_available() {
|
||||
let mut h = harness();
|
||||
tracks_mut(&mut h).rows[0].count = 128;
|
||||
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
|
||||
chord(&mut h, Modifiers::NONE, Key::Enter);
|
||||
assert!(h.state().dialogs.open.is_some());
|
||||
assert!(h.state().engine.session().tracks.is_empty());
|
||||
h.get_by_role_and_label(Role::Button, "Cancel").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().dialogs.open.is_none());
|
||||
}
|
||||
}
|
||||
@@ -51,8 +51,9 @@ pub(crate) use process::{EventList, MemoryStream, ParamChanges};
|
||||
const MAX_CLASSES: i32 = 1024;
|
||||
/// Most parameters read from one controller.
|
||||
pub(crate) const MAX_PARAMS: i32 = 4096;
|
||||
/// Most audio buses per direction we provide buffers for.
|
||||
const MAX_BUSES: i32 = 16;
|
||||
/// Most audio buses per direction we provide buffers for. Multi-out instruments declare dozens
|
||||
/// of (mostly inactive) aux buses, e.g. 32 for a drum sampler; every one still gets buffers.
|
||||
const MAX_BUSES: i32 = 64;
|
||||
/// Most channels per audio bus.
|
||||
const MAX_BUS_CHANNELS: u32 = 64;
|
||||
/// Capacity of the per-block event lists (preallocated: `process` never allocates).
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path {
|
||||
let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder);
|
||||
std::fs::create_dir_all(&bin).unwrap();
|
||||
std::fs::copy(&built, bin.join(exe)).unwrap();
|
||||
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5);
|
||||
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 6);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes):
|
||||
//! Test-only VST3 plugin for soundcraft-vst3-host (six audio module classes):
|
||||
//!
|
||||
//! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller
|
||||
//! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain"
|
||||
@@ -11,9 +11,9 @@
|
||||
//! its edit controller). No audio input, one stereo output, one event input; every output
|
||||
//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and
|
||||
//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too.
|
||||
//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects
|
||||
//! advertising wide layouts (16 buses of 64 channels, 17 buses of 2 channels, one bus of 65
|
||||
//! channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
|
||||
//! - "Fixture Wide 16x64", "Fixture Wide 32 buses", "Fixture Wide 65 buses", "Fixture Wide 65
|
||||
//! channels" (`Fx`): effects advertising wide layouts (16 buses of 64 channels, 32 and 65 buses
|
||||
//! of 2 channels, one bus of 65 channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
|
||||
//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not.
|
||||
//! Report 3 samples of latency.
|
||||
//!
|
||||
@@ -34,8 +34,9 @@ const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666);
|
||||
const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666);
|
||||
const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE65B_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666);
|
||||
const WIDE32_CID: TUID = uid(0x5C0A1D06, 0x11112222, 0x33334444, 0x55556666);
|
||||
|
||||
fn copy_c8(src: &str, dst: &mut [c_char]) {
|
||||
for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) {
|
||||
@@ -736,12 +737,13 @@ impl Class for Factory {
|
||||
type Interfaces = (IPluginFactory2,);
|
||||
}
|
||||
|
||||
const CLASSES: [(TUID, &str, &str, &str); 6] = [
|
||||
const CLASSES: [(TUID, &str, &str, &str); 7] = [
|
||||
(GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"),
|
||||
(GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""),
|
||||
(SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"),
|
||||
(WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"),
|
||||
(WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"),
|
||||
(WIDE32_CID, "Audio Module Class", "Fixture Wide 32 buses", "Fx"),
|
||||
(WIDE65B_CID, "Audio Module Class", "Fixture Wide 65 buses", "Fx"),
|
||||
(WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"),
|
||||
];
|
||||
|
||||
@@ -785,8 +787,9 @@ impl IPluginFactoryTrait for Factory {
|
||||
.to_com_ptr::<FUnknown>()
|
||||
} else if cid == SYNTH_CID {
|
||||
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
|
||||
} else if let Some((buses, channels)) =
|
||||
[(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout))
|
||||
} else if let Some((buses, channels)) = [(WIDE16_CID, (16, 64)), (WIDE32_CID, (32, 2)), (WIDE65B_CID, (65, 2)), (WIDE65_CID, (1, 65))]
|
||||
.into_iter()
|
||||
.find_map(|(c, layout)| (c == cid).then_some(layout))
|
||||
{
|
||||
ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
|
||||
} else {
|
||||
|
||||
@@ -15,7 +15,7 @@ fn id_of(name: &str) -> String {
|
||||
fn scan_lists_both_audio_modules_but_not_the_controller_class() {
|
||||
let dir = fixture_dir();
|
||||
let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]);
|
||||
assert_eq!(found.len(), 5, "{found:?}");
|
||||
assert_eq!(found.len(), 6, "{found:?}");
|
||||
let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap();
|
||||
// `uid()` lays out the first 8 bytes differently on Windows (COM GUID order).
|
||||
let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" };
|
||||
@@ -320,8 +320,8 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
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 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
|
||||
for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] {
|
||||
// 65 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
|
||||
for name in ["Fixture Wide 65 buses", "Fixture Wide 65 channels"] {
|
||||
let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 0, "{name}: not activated");
|
||||
@@ -331,6 +331,18 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_buses_like_multi_out_instruments_are_activated() {
|
||||
// Multi-out instruments declare dozens of buses (SSD Sampler 5: 32); only the main one is
|
||||
// active, but the plugin must still load, get buffers for every bus and play (#1247).
|
||||
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 32 buses")).unwrap();
|
||||
p.prepare(44_100.0, 64, 2);
|
||||
assert_eq!(p.latency(), 3, "32-bus plugin activated");
|
||||
let mut io = vec![vec![0.5f32; 64]; 2];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io.iter().flatten().all(|&x| x == 1.0), "32-bus layout delivered as advertised: {:?}", &io[0][..4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
|
||||
let id = id_of("Fixture Gain");
|
||||
|
||||
@@ -77,6 +77,8 @@ link stays. An existing file that is not an input is replaced without asking.
|
||||
|---|---|---|
|
||||
| `SOUNDCRAFT_CONTROL_PORT` | `app --port` (CLI), `--control` (desktop app) | A port or `host:port`, an IPv6 host in brackets (`[::1]:7979`). The desktop app takes the port of a loopback `host:port` (`127.0.0.1`, `localhost`). Its control server listens on `127.0.0.1` only, so it ignores another host from the variable, with a log warning, and starts no control server; given to `--control`, another host is a usage error |
|
||||
|
||||
| `SOUNDCRAFT_CONTROL_TOKEN` | none | The secret every control request carries as `token`. The desktop app refuses requests without it; unset, it makes one per launch and prints it after the port line (`SOUNDCRAFT_CONTROL_TOKEN=…`). `app` and `mcp --connect` send the variable's value |
|
||||
|
||||
- A flag wins over its variable.
|
||||
- An empty variable counts as unset.
|
||||
- A bad value is a usage error that names the variable.
|
||||
|
||||
@@ -6,7 +6,7 @@ input), and every command is reachable from:
|
||||
|
||||
| Surface | How |
|
||||
|---|---|
|
||||
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`) |
|
||||
| Desktop app control channel | `soundcraft --control <port>` (`0` = pick a free port; printed as `SOUNDCRAFT_CONTROL_PORT=…`, then the launch's `SOUNDCRAFT_CONTROL_TOKEN=…` unless the variable was set) |
|
||||
| CLI against a running app | `soundcraft-cli app --port <port> <method or command id> [JSON]` (see [cli.md](cli.md)) |
|
||||
| Headless CLI | `soundcraft-cli run --demo --cmd 'mix.volume={"track":"Kick","db":-6}' --bounce out.wav` |
|
||||
| MCP (headless) | `soundcraft-cli mcp [--demo]` |
|
||||
@@ -29,12 +29,17 @@ input delivery or screen-reader acceptance. Native screenshots use the presented
|
||||
Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply per line:
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}}
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "track.new", "params": {"count": 2, "format": "Stereo"}}, "token": "…"}
|
||||
{"id": 1, "ok": true, "result": {"tracks": [12, 13]}}
|
||||
```
|
||||
|
||||
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`.
|
||||
|
||||
`token` is the launch's secret: `SOUNDCRAFT_CONTROL_TOKEN` when the app was started with it, otherwise
|
||||
the one the app made and printed after the port line. Any local account can reach the loopback port,
|
||||
so a request without the right token is refused (`"error": "unauthorized: …"`) and its connection
|
||||
closed. `soundcraft-cli app` and `mcp --connect` read the variable.
|
||||
|
||||
## Methods
|
||||
|
||||
| Method | Params | What it does |
|
||||
@@ -71,6 +76,7 @@ Scripted input (`ui.click`, `ui.drag`, `ui.scroll`, `ui.zoom`, `ui.key`, `ui.tex
|
||||
## Examples
|
||||
|
||||
```sh
|
||||
export SOUNDCRAFT_CONTROL_TOKEN=$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
|
||||
P=$(soundcraft --demo --control 0 | sed -n 's/SOUNDCRAFT_CONTROL_PORT=//p') # or read stderr
|
||||
soundcraft-cli app --port $P mix.solo '{"track":"Bass"}'
|
||||
soundcraft-cli app --port $P transport.play
|
||||
|
||||
@@ -8,6 +8,7 @@ Claude and other agents can make music with it. The server is the `mcp` subcomma
|
||||
soundcraft-cli mcp # headless engine (no window, no audio device)
|
||||
soundcraft-cli mcp --demo # headless, starting from the demo session
|
||||
soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # headless, confined to folders
|
||||
export SOUNDCRAFT_CONTROL_TOKEN=… # the secret both sides share (see control-protocol.md)
|
||||
soundcraft --control 7801 & # the desktop app…
|
||||
soundcraft-cli mcp --connect 7801 # …driven over MCP: same tools plus screenshots and clicks
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user