Compare commits

..
31 Commits
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 b35dd0f9eb Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:25:27 -05:00
FredApps e5d3174929 Merge pull request #637 from danielmigueltejedor/fix/gnome-window-decorations
Fix GNOME/Wayland window controls
2026-11-02 19:32:54 +01:00
FredApps 27329587c6 Merge current main into fix/gnome-window-decorations 2026-11-02 19:10:30 +01:00
FredApps 1dbc185e01 Merge pull request #559 from SlyLaM/fix/new-tracks-keyboard-ux
feat(ui): improve the New Tracks creation workflow
2026-11-02 18:23:48 +01:00
Brandon Thomas 8444edf075 Merge pull request #1248 from Silvertree2010/fix/1247-vst3-multi-out-instruments-e-g-s
Accept VST3 multi-out instruments with up to 64 audio buses
2026-11-02 00:30:05 -05:00
NetterSilverandClaude Opus 5.5 6c3a0dfc50 Accept VST3 multi-out instruments with up to 64 audio buses
Fixes #1247

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 06:19:39 +01:00
Brandon Thomas d557d18118 Merge pull request #1223 from TheGalitube/fix/h7732-aiff-parsing-rejects-valid-chann
AIFF: test that a negative channel count is rejected
2026-11-01 23:06:45 -05:00
Brandon Thomas 98cda6ecaf Merge pull request #1032 from Silvertree2010/fix/h2086-midi-export-writes-notes-that-a
Export only the MIDI notes a trimmed clip still plays
2026-11-01 23:06:40 -05:00
Brandon Thomas 920ec2c3f3 Merge pull request #1234 from Silvertree2010/fix/h5095-score-export-drops-every-note-wh
Keep score export notes when bars are shorter than a sixteenth
2026-11-01 23:06:37 -05:00
Brandon ThomasandClaude Opus 5.5 5e8898976e Test a grid of 1/16, 1/32 and 1/64 bars in a row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:02 -05:00
Brandon ThomasandClaude Opus 5.5 c426b889bb Test MIDI export of a clip trimmed at both ends
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:05:08 -05:00
Sly LaMenace bdb73895a5 fix(ui): make dialog cancellation atomic 2026-11-01 13:55:09 +00:00
Sly LaMenace 1a4a88fb0d fix(ui): cancel pending dialog actions on Escape 2026-11-01 13:40:45 +00:00
Sly LaMenace 9e902b48fe fix(ui): let dialog cancellation win over Enter 2026-11-01 12:30:51 +00:00
Sly LaMenace 265e526f6a fix(ui): keep row actions from submitting New Tracks 2026-11-01 11:46:36 +00:00
Sly LaMenace d2298ff974 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
2026-11-01 11:32:53 +00:00
Max MittelstädtandGPT-5.6-Luna cf40296888 fix: reject unsigned AIFF channel counts
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-5.6-Terra <noreply@openai.com>
2026-11-01 09:13:52 +00:00
NetterSilverandClaude Opus 5.5 b32065a109 Keep score export notes when bars are shorter than a sixteenth
Found while testing crates/engine/src/score.rs:~150 (measure_grid, `if ub <= ua { break; }`), used by layout()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:34:16 +01:00
NetterSilverandClaude Sonnet 5.5 97e2603683 fix: release a held sustain at the end of a trimmed MIDI clip on export
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 16:48:51 +00:00
Max MittelstädtandGPT-6-Luna 73e3478ae3 Accept unsigned AIFF channel counts
Found while testing crates/audio-io/src/aiff.rs:84

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-31 07:06:09 +00:00
NetterSilverandClaude Haiku 5.5 4740e8d4c4 Export only the MIDI notes a trimmed clip still plays
Found while testing crates/engine/src/io.rs:350-362 (export_midi)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:24:39 +01:00
Daniel fd9c673694 Merge upstream main and reconcile Linux Wayland CSD dependencies
Preserve Unix X11 support, add Linux Adwaita no-title controls,
refresh Cargo.lock and keep MIT notice in existing packaging paths.
2026-10-28 10:48:57 +01:00
Daniel 1f8b267722 Merge upstream main and resolve GNOME Wayland controls conflicts 2026-10-25 23:57:47 +01:00
Sly LaMenace 95f25979eb docs(ui): document New Tracks changes 2026-10-24 21:57:11 +00:00
Sly LaMenace 929a7c5307 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-24 21:50:52 +00:00
Sly LaMenace ee63b09cbe fix(ui): omit hidden New Tracks row fields 2026-10-24 21:45:19 +00:00
Daniel 4071975aa4 Merge upstream main; retain GNOME window controls
Integrate updated SoundCraft dependencies and packaging while preserving Linux winit CSD, required lockfile crates and license notices.
2026-10-24 10:22:45 +02:00
Sly LaMenace 9e9177f1af fix(ui): hide inapplicable New Tracks fields 2026-10-20 14:33:48 +00:00
Sly LaMenace 3ea1f34a1d feat(ui): add multiple New Tracks rows with fixed field widths 2026-10-20 14:33:48 +00:00
Sly LaMenace 6e8c039097 fix(ui): improve New Tracks keyboard navigation and field sizing 2026-10-20 14:33:15 +00:00
Daniel 5d7197a2d0 fix(linux): keep GNOME Wayland controls without redundant NOTICE copy 2026-10-20 03:53:35 +02:00
18 changed files with 1298 additions and 111 deletions
Generated
+67 -18
View File
@@ -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
View File
@@ -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"
+31
View File
@@ -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.
+10
View File
@@ -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
+5 -1
View File
@@ -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
+9
View File
@@ -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])]);
+33 -3
View File
@@ -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> {
+25 -3
View File
@@ -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);
}
}
}
+63 -3
View File
@@ -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.
+1
View File
@@ -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;
+68
View File
@@ -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)]);
}
+82
View File
@@ -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
View File
@@ -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, &params);
}
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.
+613
View File
@@ -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());
}
}
+3 -2
View File
@@ -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).
+1 -1
View File
@@ -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
})
}
+12 -9
View File
@@ -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 -3
View File
@@ -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");