mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-03 00:41:02 -06:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57f8e788cb | ||
|
|
5afc2aab66 | ||
|
|
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 |
Generated
+67
-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,7 +4297,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"clap",
|
||||
@@ -4304,7 +4321,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-au-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
@@ -4314,7 +4331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-audio-io"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4326,7 +4343,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-automation"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"log",
|
||||
@@ -4339,7 +4356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading 0.9.0",
|
||||
@@ -4352,7 +4369,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_complete",
|
||||
@@ -4369,7 +4386,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-dsp"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"proptest",
|
||||
@@ -4380,7 +4397,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-engine"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-docpath",
|
||||
"log",
|
||||
@@ -4403,7 +4420,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-midi"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4413,7 +4430,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-mix"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"log",
|
||||
"rayon",
|
||||
@@ -4429,7 +4446,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-model"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4442,7 +4459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-playback"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"log",
|
||||
@@ -4457,7 +4474,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-time"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"serde",
|
||||
@@ -4467,7 +4484,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-ui-egui"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -4496,7 +4513,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-video"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"image",
|
||||
@@ -4508,7 +4525,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-vst3-host"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
"log",
|
||||
@@ -4524,7 +4541,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-web"
|
||||
version = "0.16.0"
|
||||
version = "0.17.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -4569,6 +4586,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 +4962,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 +6031,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 +6189,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
|
||||
|
||||
@@ -27,9 +27,13 @@ serde_json.workspace = true
|
||||
log.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
[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
|
||||
|
||||
|
||||
@@ -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])]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+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");
|
||||
|
||||
Reference in New Issue
Block a user