Compare commits

...
Author SHA1 Message Date
FredApps 2bde8b9dbc Merge pull request #971 from lucasegen/fix/playhead-record-colour
Draw the playback cursor neutral and keep red for recording
2026-11-13 02:17:51 +01:00
MarekSpodBiedry c542fe931d Hide the input selector on the Master strip (#1271) (#1356)
* Hide the input selector on the Master strip (#1271)

* Keep the Master I/O section at full height so its fader lines up with other strips (#1271)
2026-11-13 01:52:31 +01:00
Ashok Ramandashokram-si 1110e6804a io: a missing movie is offline with its checked path (#923) (#1156)
* io: a missing movie is offline with its checked path (#923)

relink_videos resolved a stored movie path, found no file, and then left
the raw stored string in the session with offline unset. The video
worker opened that string as given, relative to the working directory,
so a session path reached the file system without going through
stored_path.

Store the resolved path in that arm and mark the movie offline, as the
refused-path arm already does; the Video track then shows
"media offline: file not found" instead of trying the path. Test: a
relative stored path that names no file.

* io: keep a missing movie's stored path as it was

Review follow-up: the first commit replaced the stored string with the
resolved absolute path. Saving then wrote this folder's path, and a
session moved together with its movie no longer found it under the same
relative path (relinking only looks beside the session and in
"Video Files").

Marking the movie offline is what keeps the Video track from opening the
stored string, so leave the string as stored. The test now saves the
session into another folder, puts the movie back there under the same
relative path and checks it opens.

* Test: every outcome of relinking a movie

Its file is there; it is gone with a copy next to the session or in Video
Files (relinked); it is gone with no copy (offline, path kept); its stored
path is refused, with or without a copy.

---------

Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com>
2026-11-13 01:51:36 +01:00
Ashok Ramandashokram-si 6a87da9cf7 io: check a MIDI file in full before the session changes (#911) (#1157)
* io: check a MIDI file in full before the session changes (#911)

import_midi_bytes wrote the file's tempo and meter changes and added a
track per SMF track as it went, then returned an error for a track
longer than a day. Through execute the session is restored on error,
but the web shell calls the function directly and only pushes its undo
step on success, so a refused file left the earlier tracks and the new
tempo map in place with no way back.

Build the tempo map apart from the session, check every track's length
with it, and only then commit the map and place the clips. Test: a file
whose second track is too long leaves the session equal to before.

* io: a refused MIDI file isn't an edit either

The checks before the import ran after session_mut, which already counts
as an edit: it bumps the revision, marks the engine unsaved and may copy
the session. They now read through session(), and session_mut is taken
only once nothing can fail. The test checks the revision and unsaved
mark too.

---------

Co-authored-by: ashokram-si <347905169+ashokram-si@users.noreply.github.com>
2026-11-13 01:51:06 +01:00
.CooCoo 9c05488a09 Read send destinations only while the menu is open (#1142) 2026-11-13 01:50:37 +01:00
MarekSpodBiedry 16110013c3 Import Audio: Browse selects several files at once (#1089) (#1206) 2026-11-13 01:50:08 +01:00
.CooCoo 1c4a8380e5 Dialogs: Enter presses the default button only when no button has the focus (#1243)
* Dialogs: Enter presses the default button only when no button has the focus

egui treats Enter on a focused button, checkbox, radio button or combo
box as a click, and the dialogs also pressed their default button on the
same key: Enter on a focused Cancel ran the dialog's OK (Strip Silence
stripped the clips, Bounce Mix started the bounce), Enter on Don't Save
saved first, and Enter on Browse... opened the picker and then pressed
OK or Bounce.

- Enter presses the default button only on a new key press, and only
  when no widget has the keyboard focus or a text field has it, so a
  held Enter's repeats do not press it.
- On the first frame a dialog is drawn, it clears the keyboard focus a
  widget in the main window may still hold, and ignores Enter: that
  Enter opened the dialog from a menu item or the command palette.
- Every way a dialog opens goes through Dialogs::set, so a dialog that
  replaces an open one, the Unsaved Changes prompt, the Spot dialog and
  Tempo Change get the same first frame.

* Dialogs: a Spot dialog that a clip click opens also ignores the Enter of its first frame

With the Grabber or Trim tool in Spot mode, a click on a clip set the
open dialog directly, so a Spot dialog that replaced an open dialog kept
the shown state and could take an Enter pressed in the same frame. Both
clicks now open it through Dialogs::set.

* Tests: Enter right after Strip Silence and Rename Selected Clips switch presses the new default button

* Tests: Enter on a focused default button, and the first frame of the remaining dialogs that replace another

A focused Create runs its command once. Clear, Auto Rename, Modify Group, the unsaved changes question and Tempo Change each ignore the Enter of their first frame when they replace an open dialog. Enter on a focused Browse in Bounce Mix starts no bounce.
2026-11-13 01:48:53 +01:00
Lorie faa38be657 Windows: Include portable.txt in portable ZIP packages (#1341) 2026-11-13 01:48:24 +01:00
Jonathan Cardoso 2aa342859a mix.volume: say what each track got and when a value was clamped (#1353)
* mix.volume: say what each track got and when a value was clamped

The fader stops at -144 and +12 dB, and mix.volume replied only with a track
count, so +15 dB played at +12 with nothing to show it. The reply now lists
each target's volume and sets clamped when a target or a group member was
held at a stop.

* mix.volume: list every requested track in the reply

A target moved as a group sibling of an earlier one was skipped before it was
added to volumes, so tracks [3, 6] in one group listed only track 3. Build
volumes after the loop from each requested track's final volume.
2026-11-13 01:47:53 +01:00
gyatskovandGennadij Yatskov d5d3846a78 vst3: Add missing scan paths (#1450)
* Add missing VST3 paths for Linux

* Add missing VST3 paths for Windows

* Add missing VST3 paths for macOS

* Make os_search_paths private

* Use hermetic search_paths in unix test and remove os_search_paths

* Rewrote the Unix test to use the hermetic search_paths with a fixed executable, a None case, and no process state.
* Replaced os_search_paths with default_search_paths_with, injecting the environment lookup and executable so tests
  now cover the COMMONPROGRAMW6432 → COMMONPROGRAMFILES fallback, COMMONPROGRAMFILES(X86), LOCALAPPDATA, app-local folders, and VST3_PATH.

* Add docstring to env_from

---------

Co-authored-by: Gennadij Yatskov <gennadij@yatskov.de>
2026-11-13 01:46:59 +01:00
NetterSilverandClaude Haiku 5.5 f1cf95b354 Support the 1/4t grid in event.quantize (#1453)
Found while testing crates/engine/src/cmd/event.rs:233-248 (grid_ticks, used by quantize)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-13 01:46:22 +01:00
Javier Brines GarcíaandClaude Opus 5.5 a64f3998b2 test(cli): accept a connect timeout as an unreachable app (#1418) (#1421)
On Windows the client's 800 ms connect timeout can expire before a closed
loopback port is refused, so the CLI reports "connection timed out" without
an "(os error N)" suffix. Both assert_refused helpers now accept either
reason, and still check exit 1, empty stdout and the exact address prefix.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:45:49 +01:00
Javier Brines GarcíaandClaude Opus 5.5 a7030f1d2f docs(engine): say a new mix.send starts at -144 dB (#1410) (#1426)
The help for mix.send said `level_db?: 0`, but a new send has always started
at -144 dB (silent): SendSlot::new sets it, the Mix window relies on it, and
Preferences > Mixing shows "Sends Default to -inf dB". The help now gives the
range and the real default, and a test checks the help and a new send agree.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 01:43:48 +01:00
oracola91 21d02347e1 Control server: close the connection after a line that isn't a request (#1414)
A line that was not JSON got a "bad JSON" error reply and the server kept
reading the same connection. A JSON value that was not an object, or had no
string `method`, was forwarded to the UI as a request with an empty method.

Now a line that is not a JSON object with a string `method` gets one error
reply and the connection is closed, as in the other Craft apps' control
servers. Control clients send one request object per line, so a peer that
sends anything else is not one, and nothing it sends after that line is run.
Valid requests and blank lines are handled as before.

The protocol doc says so next to the error format.
2026-11-13 01:43:08 +01:00
.CooCoo 4a5eb1514a Plugins: Alt-click returns a parameter to its default value (#1081)
* Strip Silence: the sliders move a tenth as far while Ctrl or Command is held

While Ctrl (Command on a Mac) is held, a drag on one of the four Strip
Silence sliders moves the value a tenth as far as the same drag
without the key, on the slider and on its value box. Pressing or
releasing the key during a drag leaves the value where it is, and the
drag goes on from there. Releasing the mouse button leaves the value
too. A drag without the key works as before.

The new widgets::fine_slider uses the modifier test and the tenth of
widgets::fader. Threshold and Clip Start Pad show one decimal place,
and a drag on the slider during which the key was held rounds the
value to the places the box shows.

* Plugins: parameter sliders move a tenth as far while Ctrl or Command is held

While Ctrl (Command on a Mac) is held, a drag on a parameter slider in
the plugin window, the Clip Effects window or an AudioSuite plugin's
window moves the value a tenth as far as the same drag without the key,
as the Strip Silence sliders do. A logarithmic slider moves a tenth as
far along the slider, and a whole-number slider, such as the Sampler's
Root Note, stays on whole numbers. A drag without the key works as
before.

widgets::FineSlider is a new builder with the slider options these
windows use (no value box, logarithmic, whole numbers, a salt for
unlabeled sliders); widgets::fine_slider keeps its signature and calls
it.

* Plugins: Alt-click returns a parameter to its default value

An Alt-click (Option-click on a Mac) on a parameter in the plugin window,
the Clip Effects window or an AudioSuite plugin's window sets the
parameter's default value, as an Alt-click sets a fader to 0 dB. It works
on a slider, on a slider's value box and on a choice list. The click on a
value box does not open it for typing, and the click on a choice list does
not open the list. In the plugin window and the Clip Effects window the
reset runs the same command as a drag, so it is one undo step. While Alt
is held, a drag on one of these sliders leaves the value where it is.

widgets::FineSlider has a new default_value option for this.

* Plugins: FineSlider::default_value describes what a held Alt does

While Alt is held, a drag leaves the value where it is, and an Alt-click
on the value box does not open it for typing. An Alt-click on the rail
sets the default.

* Dialogs: Enter on a focused Cancel or Don't Save no longer runs OK

Enter on a focused button clicks it and also presses the dialog's OK.
In Strip Silence, Enter on Cancel closed the dialog and still stripped
the selected clips. Cancel and Escape now drop the command and the save
that OK asked for, in every dialog. In Unsaved Changes, Enter on a
focused Don't Save closes without saving instead of opening Save.

* Read send destinations only while the menu is open

* Tests: pressing Ctrl during a value-box drag keeps the value

The rest of the drag on the Threshold value box moves as far as a drag
with Ctrl held from the start.

* Bounce Mix: Cancel starts no bounce; fine adjust on more AudioSuite sliders

Enter on a focused Cancel also pressed Bounce, which started the bounce
before the Cancel check ran. The bounce now starts after that check.

The Normalize, Time Shift and Pitch Shift sliders in the AudioSuite
window move a tenth as far while Ctrl or Command is held.

* Tests: give the Enter-on-Cancel bounce test its own scratch folder

It shared bounce-cancel with cancel_or_escape_stops_a_bounce_and_leaves_no_file,
so the two tests removed each other's folder when run in parallel.

* Clip Effects: log-taper parameter sliders use a logarithmic rail

The EQ and Dynamics sliders in the Clip Effects window now follow the
parameter's taper, as the plug-in window does, so a frequency, Q, ratio,
attack or release slider moves along a logarithmic rail.

* Dialogs: leave Enter on a focused Cancel or Don't Save to its own pull request

The dialog changes for Enter on a focused Cancel or Don't Save, the
Bounce Mix change that started the bounce after the Cancel check, and
their tests in save_changes.rs, strip_silence_dialog.rs and bounce.rs
are removed from this branch. The fine adjustment on the sliders stays.

* Plugins: tests cover Alt reset and Ctrl fine adjustment on more slider kinds

Tests now cover Ctrl without Command, Alt pressed during a drag, one
undo step for a choice-list reset, whole-number and logarithmic sliders,
Alt-click and Ctrl-drag on the AudioSuite and Clip Effects value boxes,
and Alt on a Strip Silence slider, which behaves like a plain click or
drag.

The ROADMAP entry limits the choice-list reset to the plugin window, and
the docs/ui-parity.md fine-adjustment row names the Clip Effects and
AudioSuite sliders.

* Plugins: a short Ctrl-drag at the low end of a log slider with a value box moves the value

A fine drag rounded to at most three decimal places, so a one-point
drag near the bottom of a range such as Attack at 0.01 ms rounded back
to where it started. The rounding now allows up to six places.

* Strip Silence: the fine slider's response reports each change of a drag

The value moved by a drag that the key has touched is set before the
slider reads it, so the Response's changed() stayed false on those
passes. fine_slider now marks the response when the value changed.

Tests: a Control-only drag, and changed() on every pass of a Ctrl-drag.
The value box test releases the button once and checks the value after.

* Tests: plugin sliders with Control alone and with Ctrl toggled during a drag

The plugin window's logarithmic and whole-number sliders keep their value
when Ctrl is pressed or released during a drag, and a drag with Control
alone is fine. The tenth-as-far test names the parameters it drags and
checks the others do not change, and the low-end test checks the value
after the release.
2026-11-13 01:38:35 +01:00
Dylan M. Taylor 6a0949d9d0 Review fixes after the craft-audio switch; MIDI files from craft-midi (#1513)
* Review fixes after the craft-audio switch; pin craft-audio at craft-libs main

Follow-up to #1506, for its review comments:

- convert: a decode that concealed damaged packets is reported as a warning,
  and --in-place refuses to replace the source with it.
- convert: the decoded speaker layout goes to the encoder, so 5.1 with side
  surrounds stays side surrounds in WAV and FLAC.
- FileFormat::of names an MP4 or QuickTime file by its codec: AAC or HE-AAC
  is Aac, ALAC is Alac, anything else (Opus, AC-3, PCM) is Other.
- Bounce keeps a picked extension that already names the format (Mix.aifc,
  Mix.AIFF), so the file written is the one the save dialog confirmed.
- rust-version is 1.95: craft-audio, craft-media and craft-ui declare it, so
  1.90 was not a version the workspace builds with.

craft-audio and craft-media move to craft-libs main 7db4645d.

* MIDI: take Standard MIDI Files and the sequence model from craft-midi

craft-midi in craft-libs was moved from this crate (storytold/craft-libs#884):
smf.rs, sequence.rs, the error type and their tests. soundcraft-midi now
re-exports them, so every caller keeps its imports, and keeps what is
SoundCraft's own: the editing operations, the display names and XorShift.

The SMF and Sequence tests are deleted here; they run in craft-midi, which
also adds one for the track limit. The serde names do not change (the same
derives behind craft-midi's serde feature), so sessions read as before.
2026-11-11 19:51:41 -05:00
Dylan M. Taylor 4782cfa600 chore: bump to 0.20.1 for the Flatpak updater patch release (#1512) 2026-11-11 19:16:01 -05:00
lucasegen e4f2d888ab Merge remote-tracking branch 'upstream/main' into fix/playhead-record-colour
# Conflicts:
#	crates/ui-egui/src/edit_window/tests.rs
2026-11-03 16:06:51 +01:00
lucasegenandClaude Opus 5.5 4f99e2a686 Edge the Universe's playback line so it reads over every track colour
The Universe paints clips in their raw track colours, and the neutral playback line is as light as some of them (1.1:1 over the first one in the dark theme): a dark edge on each side keeps it visible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:06:53 +01:00
lucasegenandClaude Opus 5.5 f3873d4e2c Merge upstream main into fix/playhead-record-colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:30:37 +01:00
lucasegenandClaude Opus 5.5 e641cbe906 Merge upstream main into fix/playhead-record-colour
Upstream split edit_window.rs into modules: the playhead colour moves to edit_window/overlay.rs and its tests to edit_window/tests.rs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 02:46:26 +01:00
lucasegenandClaude Opus 5.5 1230881dea Merge upstream main into fix/playhead-record-colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 10:15:48 +01:00
lucasegenandClaude Opus 5.5 9317106247 Merge upstream main into fix/playhead-record-colour
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:41:41 +01:00
lucasegen 00fe7eccd7 Merge upstream main into fix/playhead-record-colour 2026-10-23 22:22:02 +02:00
lucasegenandClaude Opus 5.5 e383e89653 Draw the playback cursor neutral and keep red for recording
The Edit window drew the playhead red whenever the transport ran. Red now
means recording only: plain playback uses its own neutral grey, the new
`Tokens::playback` (rgb 140,140,142 on Dark, 110,113,119 on Light), which
reads over clips and the rulers. It is not the insertion colour (230 on Dark,
35,36,40 on Light): the edit cursor can stand beside the moving line, and the
two must never read as the same line. A test pins a contrast of at least 2.5:1
between them and 3:1 of the line on the lanes, in both themes.
`Tokens::playhead` stays red, for the record line and for the Sampler's
"S" marker. The Universe overview line follows the same rule.

Conflict note: open PR #111 (persistent playhead) goes the other way and
draws the red playhead at rest too. It rewrites the same lines of
`overlay()` and `universe()`, and it already conflicts with main, so it
needs a rebase and a decision on the colour before either lands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:35:20 +02:00
61 changed files with 2453 additions and 1426 deletions
Generated
+29 -22
View File
@@ -1064,7 +1064,7 @@ dependencies = [
[[package]]
name = "craft-audio"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
dependencies = [
"craft-media",
"log",
@@ -1109,7 +1109,16 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "craft-midi"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
dependencies = [
"serde",
"thiserror 2.0.21",
@@ -4344,7 +4353,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"chrono",
"clap",
@@ -4368,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"log",
"serde",
@@ -4378,7 +4387,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"clap",
"craft-audio",
@@ -4391,7 +4400,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4404,7 +4413,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"clap",
"clap_complete",
@@ -4422,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"log",
"proptest",
@@ -4433,7 +4442,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"craft-docpath",
@@ -4457,17 +4466,15 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-midi",
"proptest",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-mix"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"log",
@@ -4483,7 +4490,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"proptest",
@@ -4496,7 +4503,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"cpal",
"craft-audio",
@@ -4511,7 +4518,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"proptest",
"serde",
@@ -4521,7 +4528,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"craft-ui",
@@ -4551,7 +4558,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-media",
"image",
@@ -4563,7 +4570,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4579,7 +4586,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"eframe",
@@ -6227,7 +6234,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.20.0"
version = "0.20.1"
dependencies = [
"serde_json",
]
+6 -4
View File
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.20.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/soundcraft"
[workspace.dependencies]
@@ -51,10 +51,12 @@ cpal = "0.18"
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed" }
# Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares
# types with craft-media, so both stay on one rev.
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", features = ["decode", "lossless-encode", "peaks", "serde"] }
# Standard MIDI Files and the note/controller model, shared with the other craft apps.
craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["serde"] }
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["decode", "lossless-encode", "peaks", "serde"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
+1 -2
View File
@@ -146,8 +146,7 @@ where things are.
## Getting started
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
minimum remains Rust 1.90):
from source with Rust 1.95 or later:
```sh
git clone https://github.com/storytold/soundcraft
+7 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-27 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Alt-click returns a plugin parameter to its default, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -125,6 +125,9 @@ mutated and truncated movies. Field reports from the first public week are track
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** An Alt-click (Option-click on a Mac) on a parameter in the plugin window, the Clip Effects window or an AudioSuite plugin's window returns it to its default value, on a slider or a slider's value box, and in the plugin window on a choice list too, as an Alt-click returns a fader to 0 dB. In the plugin window and the Clip Effects window the reset is one undo step. While Alt is held, a drag on a slider leaves the value where it is. No readiness percentage change.
- **2026-10-24:** The parameter sliders of the plugin window, the Clip Effects window and AudioSuite plugins move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, as the Strip Silence sliders do. Logarithmic sliders move a tenth as far along the slider, and whole-number sliders stay on whole numbers. No readiness percentage change.
- **2026-10-24:** The Strip Silence window's four sliders move a tenth as far for the same drag while Ctrl (Command on a Mac) is held, on the slider and on its value box. Pressing or releasing the key during a drag leaves the value where it is. The Threshold and Clip Start Pad boxes show one decimal place. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
- **2026-10-24:** While the Strip Silence window is open, the Edit window draws a rectangle around each range that Strip would keep on the selected tracks, padded by Clip Start Pad and Clip End Pad, and the rectangles follow the sliders as they move. They are removed when the window closes. `edit.strip_silence_preview` returns the same ranges without changing the session. No readiness percentage change.
- **2026-10-24:** The Strip Silence window has a Rename button. It opens Rename Selected Clips, where a name, a starting number, a count of zeros and a suffix set the names of the clips that Strip, Extract and Separate leave in the selection (for example SFX023.Reel1, SFX024.Reel1). The app keeps the scheme for later uses of Strip Silence until the app quits, or until Clear and then OK empty it. No readiness percentage change.
@@ -180,6 +183,9 @@ mutated and truncated movies. Field reports from the first public week are track
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Alt-click returns a plugin parameter to its default; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: fine adjustment on plugin parameter sliders; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence fine adjustment; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence rectangles; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Strip Silence Rename; readiness estimates unchanged |
+14
View File
@@ -15,6 +15,15 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
let (info, mut buf) = craft_audio::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
// The decoder replaces packets it cannot decode with silence of their length and counts them.
// Such a file is not a copy of what was recorded: never let it replace its source, and say so
// when it is written elsewhere.
if info.damaged > 0 {
if args.in_place {
return Err(ConvertError::Damaged { input: input.clone(), packets: info.damaged }.into());
}
log::warn!("{}: {} damaged packet(s) decoded as silence", input.display(), info.damaged);
}
let source_rate = buf.sample_rate;
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
@@ -34,6 +43,11 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
// samples, so it moves to the output rate.
let mut opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither);
// The source's speakers, not the usual ones for its channel count (5.1 with side surrounds
// stays that). Channels with no speaker assignment get the format's default.
if info.layout.is_ordered() {
opts = opts.with_layout(info.layout);
}
opts.bwf = info.bwf.map(|mut b| {
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
b
+2
View File
@@ -48,4 +48,6 @@ pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> {
pub(crate) enum ConvertError {
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), craft_audio::ENCODE_EXTENSIONS.join(", "))]
OutputFormat { output: PathBuf, ext: String },
#[error("{}: {packets} damaged packet(s) would be written as silence; not replacing the file (convert to another file to keep what decodes)", input.display())]
Damaged { input: PathBuf, packets: u64 },
}
+52
View File
@@ -118,3 +118,55 @@ fn convert_keeps_the_bwf_time_stamp() {
assert_eq!(bext(&out), Some(bwf.with_time_reference(158_760_000)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Twelve MPEG-1 Layer I frames (mono, 32 kHz, 48 bytes each) of silence. The seventh has the
/// forbidden bit allocation 15 in every subband: the decoder writes silence for it and counts it.
fn mp1_with_a_damaged_frame() -> Vec<u8> {
let header = [0xFF, 0xFF, 0x18, 0xC0];
let good = [&header[..], &[0; 44]].concat();
let bad = [&header[..], &[0xFF; 16], &[0; 28]].concat();
(0..12).flat_map(|i| if i == 6 { bad.clone() } else { good.clone() }).collect()
}
#[test]
fn convert_warns_about_damaged_packets_and_never_replaces_the_source_with_them() {
let dir = tmpdir("damaged");
let src = dir.join("take.mp1");
let bytes = mp1_with_a_damaged_frame();
std::fs::write(&src, &bytes).unwrap();
let run = |args: &[&std::ffi::OsStr]| {
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).env("SOUNDCRAFT_NO_PREFS", "1").arg("convert").args(args).output().unwrap()
};
// To another file: written, with a warning that says how much was lost.
let out = dir.join("take.wav");
let o = run(&[src.as_os_str(), out.as_os_str()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let warning = format!("warning: {}: 1 damaged packet(s) decoded as silence\n", src.display());
assert_eq!(String::from_utf8_lossy(&o.stderr), warning);
assert_eq!(steps16(&out).len(), 12 * 384);
// Over the source: refused, and the source is untouched.
let o = run(&[src.as_os_str(), src.as_os_str(), "--in-place".as_ref()]);
assert_eq!(o.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.starts_with(&format!("error: {}: 1 damaged packet(s) would be written as silence", src.display())), "{stderr}");
assert_eq!(std::fs::read(&src).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_keeps_the_speaker_layout() {
use craft_audio::ChannelLayout;
let dir = tmpdir("layout");
let (src, out) = (dir.join("side.wav"), dir.join("out.wav"));
// 5.1 with side surrounds: not the layout a reader assumes for six channels.
let opts = EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE);
std::fs::write(&src, craft_audio::encode(&AudioBuffer::new(48_000, 6, 480), &opts).unwrap()).unwrap();
let layout = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().layout;
assert_eq!(layout(&src), ChannelLayout::SURROUND_5_1_SIDE);
convert(&src, &out, &[]);
assert_eq!(layout(&out), ChannelLayout::SURROUND_5_1_SIDE);
let flac = dir.join("out.flac");
convert(&src, &flac, &[]);
assert_eq!(layout(&flac), ChannelLayout::SURROUND_5_1_SIDE);
let _ = std::fs::remove_dir_all(&dir);
}
+6 -2
View File
@@ -80,12 +80,16 @@ fn not_found() -> std::io::Error {
std::io::Error::from_raw_os_error(2)
}
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
/// Exit 1 with the unreachable message for `port`. The reason is either the OS's refusal, whose text
/// differs by platform, or the client's connect timeout, which on Windows can expire before a
/// closed loopback port is refused.
#[track_caller]
fn assert_refused(got: &Invoke, port: u16) {
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
let reason = got.stderr.strip_prefix(&prefix).unwrap_or_else(|| panic!("{}", got.stderr));
let refused = reason.contains(" (os error ") && reason.ends_with(")\n");
assert!(refused || reason == "connection timed out\n", "{}", got.stderr);
}
// ---- logging ----
+6 -2
View File
@@ -53,12 +53,16 @@ fn not_found() -> std::io::Error {
std::io::Error::from_raw_os_error(2)
}
/// Exit 1 with the refused-connection message for `port`; the OS's text for the refusal differs by platform.
/// Exit 1 with the unreachable message for `port`. The reason is either the OS's refusal, whose text
/// differs by platform, or the client's connect timeout, which on Windows can expire before a
/// closed loopback port is refused.
#[track_caller]
fn assert_refused(got: &Invoke, port: u16) {
assert_eq!((got.code, got.stdout.as_str()), (1, ""));
let prefix = format!("error: cannot reach SoundCraft at 127.0.0.1:{port}: ");
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
let reason = got.stderr.strip_prefix(&prefix).unwrap_or_else(|| panic!("{}", got.stderr));
let refused = reason.contains(" (os error ") && reason.ends_with(")\n");
assert!(refused || reason == "connection timed out\n", "{}", got.stderr);
}
#[test]
+43 -7
View File
@@ -1,5 +1,7 @@
//! Loopback JSON-lines control server: one request per line, one reply per line.
//! This is the transport the MCP server (`soundcraft-cli mcp --connect PORT`) wraps.
//! A line that is not a JSON object with a string `method` gets one error reply, and the
//! connection is closed.
//!
//! Lines are bounded ([`MAX_LINE`]): a longer one is refused and the connection closed, without
//! buffering the rest. So is the number of connections served at once ([`MAX_CONNECTIONS`]).
@@ -103,7 +105,8 @@ fn read_bounded_line(reader: &mut impl BufRead, buf: &mut Vec<u8>, max: usize) -
}
/// Answer newline-delimited JSON requests for one connection: bound each line, forward it to the UI thread with a 90
/// s reply deadline, and stop at EOF, invalid UTF-8 or a closed request queue. Bad JSON still gets an error reply.
/// s reply deadline, and stop at EOF, invalid UTF-8, a closed request queue or a line that isn't a request (see
/// [`parse_request`]). That line gets one error reply first.
fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context) {
let Ok(read) = stream.try_clone() else { return };
let mut out = stream;
@@ -123,11 +126,8 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(line) {
Ok(msg) => {
let id = msg.get("id").cloned().unwrap_or(Value::Null);
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
let params = msg.get("params").cloned().unwrap_or(json!({}));
let reply = match parse_request(line) {
Ok((id, method, params)) => {
let (req, rrx) = ControlRequest::new(method, params);
match tx.try_send(req) {
Ok(()) => {
@@ -143,7 +143,12 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
Err(TrySendError::Disconnected(_)) => break,
}
}
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
// Control clients send one request object per line. A peer that sends anything else isn't
// one: answer once and close, so nothing it sends after that line runs.
Err(e) => {
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("{e}; closing the connection")}));
break;
}
};
if writeln!(out, "{reply}").is_err() {
break;
@@ -151,6 +156,15 @@ fn serve(stream: TcpStream, tx: SyncSender<ControlRequest>, ctx: egui::Context)
}
}
/// A request line's `(id, method, params)`. The line must be a JSON object with a string `method`;
/// otherwise the error says what it is instead.
fn parse_request(line: &str) -> Result<(Value, String, Value), String> {
let msg = serde_json::from_str::<Value>(line).map_err(|e| format!("bad JSON: {e}"))?;
let Some(msg) = msg.as_object() else { return Err("not a request: expected a JSON object".into()) };
let Some(method) = msg.get("method").and_then(Value::as_str) else { return Err("not a request: no string `method`".into()) };
Ok((msg.get("id").cloned().unwrap_or(Value::Null), method.to_string(), msg.get("params").cloned().unwrap_or(json!({}))))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -226,4 +240,26 @@ mod tests {
assert!(line.contains("too many control connections"), "{line}");
drop(held);
}
#[test]
fn a_line_that_is_not_a_request_ends_the_connection() {
let addr = serve_echo();
for junk in ["not json", "[1, 2]", "\"ping\"", r#"{"id": 5}"#, r#"{"id": 5, "method": 7}"#] {
let mut c = TcpStream::connect(addr).unwrap();
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
// One write, so both lines arrive together and the request after the junk is waiting.
c.write_all(format!("{junk}\n{{\"id\":6,\"method\":\"ping\"}}\n").as_bytes()).unwrap();
let mut r = BufReader::new(c);
let mut line = String::new();
r.read_line(&mut line).unwrap();
let v: Value = serde_json::from_str(&line).unwrap();
assert_eq!(v["ok"].as_bool(), Some(false), "{junk}: {line}");
line.clear();
let closed = match r.read_line(&mut line) {
Ok(n) => n == 0,
Err(e) => e.kind() == std::io::ErrorKind::ConnectionReset,
};
assert!(closed, "{junk}: the connection stays open and the next request is answered: {line}");
}
}
}
+3
View File
@@ -156,6 +156,9 @@ fn services() -> Services {
};
d.pick_file()
})),
pick_open_many: Some(Box::new(|_purpose: &str| {
rfd::FileDialog::new().add_filter("Audio", &FileFormat::readable_extensions()).pick_files().unwrap_or_default()
})),
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
let mut d = match purpose {
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", craft_audio::ENCODE_EXTENSIONS),
+7
View File
@@ -240,6 +240,7 @@ fn grid_ticks(p: &Value) -> i64 {
"1/1" | "1" | "bar" => TICKS_PER_QUARTER * 4,
"1/2" => TICKS_PER_QUARTER * 2,
"1/4" => TICKS_PER_QUARTER,
"1/4t" => TICKS_PER_QUARTER * 2 / 3,
"1/8" => TICKS_PER_QUARTER / 2,
"1/8t" => TICKS_PER_QUARTER / 3,
"1/16t" => TICKS_PER_QUARTER / 6,
@@ -432,6 +433,12 @@ mod tests {
use crate::demo::demo_engine;
use serde_json::json;
#[test]
fn grid_ticks_accepts_the_quarter_triplet() {
use super::{TICKS_PER_QUARTER, grid_ticks};
assert_eq!(grid_ticks(&json!({"grid": "1/4t"})), TICKS_PER_QUARTER * 2 / 3);
}
#[test]
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
use soundcraft_time::Range;
+16 -4
View File
@@ -114,7 +114,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assign Send",
[],
None,
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
"{track?, slot?: 0..9|a..j, bus: name, level_db?: -144..12, default -144 (silent; kept when re-targeting), pre_fader?: false}",
has_selection,
send
),
@@ -244,6 +244,15 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
return Err(bad("mix.volume", "`db` or `delta_db` required"));
}
let s = e.session_mut();
// The fader stops at -144 and +12 dB. The reply says when a value was held there: +15 dB used
// to play at +12 with nothing to show it.
let clamped = std::cell::Cell::new(false);
let fader = |db: f32| {
if !(-144.0..=12.0).contains(&db) {
clamped.set(true);
}
db.clamp(-144.0, 12.0)
};
// Tracks already moved as a group sibling of an earlier target must not move again.
let mut done: Vec<TrackId> = Vec::new();
for t in &tracks {
@@ -257,11 +266,14 @@ fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
(None, Some(d)) => cur + d as f32,
_ => cur,
};
let new = if new.is_finite() { new.clamp(-144.0, 12.0) } else { cur };
let new = if new.is_finite() { fader(new) } else { cur };
let d = new - cur;
set_grouped(s, *t, "volume", |tr| tr.mixer.volume_db = if tr.id == *t { new } else { (tr.mixer.volume_db + d).clamp(-144.0, 12.0) });
set_grouped(s, *t, "volume", |tr| tr.mixer.volume_db = if tr.id == *t { new } else { fader(tr.mixer.volume_db + d) });
}
Ok(json!({"tracks": tracks.len()}))
// Every requested track at its final volume, also one that moved as a group sibling of an
// earlier target and was skipped above.
let volumes: Vec<Value> = tracks.iter().filter_map(|t| s.track(*t)).map(|tr| json!({"track": tr.id.0, "db": tr.mixer.volume_db})).collect();
Ok(json!({"tracks": tracks.len(), "volumes": volumes, "clamped": clamped.get()}))
}
fn pan(e: &mut Engine, p: &Value) -> Result<Value> {
+34
View File
@@ -263,6 +263,18 @@ fn retargeting_a_send_keeps_its_settings() {
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
#[test]
fn a_new_send_starts_at_the_documented_level() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Vox", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Rev"})).unwrap();
e.execute("mix.send", &json!({"track": "Vox", "bus": "Rev"})).unwrap();
let level = e.session().tracks[0].mixer.sends[0].as_ref().unwrap().level_db;
assert_eq!(level, -144.0);
let doc = crate::cmd::command_specs().iter().find(|c| c.id == "mix.send").unwrap().params;
assert!(doc.contains("level_db?: -144..12, default -144 (silent;"), "{doc}");
}
fn grouped_demo() -> Engine {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &json!({"tracks": [3], "db": 0.0})).unwrap();
@@ -294,6 +306,28 @@ fn volume_absolute_on_a_whole_group_keeps_offsets() {
assert_eq!(volumes(&e), [-10.0, -12.0]);
}
/// The fader stops at +12 dB; the reply says what each track got, and when a value was held at a stop.
#[test]
fn volume_held_at_the_fader_stop_is_reported() {
let mut e = grouped_demo();
let r = e.execute("mix.volume", &json!({"tracks": [3], "db": -6.0})).unwrap();
assert_eq!((r["volumes"].clone(), r["clamped"].clone()), (json!([{"track": 3, "db": -6.0}]), json!(false)));
let r = e.execute("mix.volume", &json!({"tracks": [3], "db": 15.0})).unwrap();
assert_eq!((r["volumes"].clone(), r["clamped"].clone()), (json!([{"track": 3, "db": 12.0}]), json!(true)));
assert_eq!(volumes(&e), [12.0, 12.0]);
// A group member held at the stop counts too.
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = 6.0;
let r = e.execute("mix.volume", &json!({"tracks": [3], "db": 10.0})).unwrap();
assert_eq!(r["clamped"], json!(true));
assert_eq!(volumes(&e), [10.0, 12.0]);
// Both grouped targets are listed, each at the value it ended with.
let mut e = grouped_demo();
e.session_mut().track_mut(TrackId(6)).unwrap().mixer.volume_db = -2.0;
let r = e.execute("mix.volume", &json!({"tracks": [3, 6], "db": -10.0})).unwrap();
assert_eq!(r["volumes"], json!([{"track": 3, "db": -10.0}, {"track": 6, "db": -12.0}]));
}
#[test]
fn clear_with_unknown_param_keeps_lanes() {
let (mut e, t) = tone_session(&[(0, -6.0), (100, -3.0)]);
+9 -3
View File
@@ -206,8 +206,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u
/// Resolve each movie referenced by the session file `session` ([`stored_path`]): keep the
/// resolved path when its file exists, else look for a file of the same name next to the session
/// (moved projects) and relink. A movie whose stored path is refused and has no such copy is
/// marked offline. Returns the movies still missing.
/// (moved projects) and relink. A movie whose stored path is refused, or resolves to a file that
/// is not there, and has no such copy is marked offline. Returns the movies still missing.
pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
let session_dir = session.parent().unwrap_or(Path::new(""));
let mut missing = Vec::new();
@@ -228,7 +228,13 @@ pub fn relink_videos(s: &mut Session, session: &Path) -> Vec<String> {
.find(|c| !name.as_os_str().is_empty() && file_found(c));
match (near, p) {
(Some(c), _) => v.path = c.to_string_lossy().into_owned(),
(None, Ok(p)) => missing.push(format!("{} (video)", p.display())),
// The movie is gone: mark it offline, so the Video track never opens the stored
// string (which the working directory would resolve). The string itself is kept as
// stored, so saving does not swap a path relative to the session for this folder's.
(None, Ok(p)) => {
missing.push(format!("{} (video)", p.display()));
v.offline = Some("file not found".into());
}
(None, Err(why)) => {
missing.push(format!("{} (video): {why}", v.path));
v.offline = Some(why);
+29 -13
View File
@@ -8,6 +8,10 @@ pub(super) const MAX_MIDI_TEMPOS: usize = 10_000;
pub(super) const MAX_MIDI_TRACKS: usize = 512;
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
///
/// The file is checked in full before the session changes, so a refused file (too many tempo or
/// meter changes or tracks, a track longer than a day) leaves the session as it was: callers that
/// keep their own undo step, like the web shell, never see a half-applied import.
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
if tempo_map {
@@ -23,24 +27,43 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
if tracks_to_import > MAX_MIDI_TRACKS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks")));
}
let s = e.session_mut();
// Phase 1, read only: the tempo map the clips will be placed with, built apart from the
// session. `session_mut` would already count as an edit (revision, unsaved mark).
let s = e.session();
let mut tempo = s.tempo.clone();
if tempo_map {
for (tick, bpm) in &smf.tempos {
if s.tempo.tempo_at_tick(*tick) == *bpm {
if tempo.tempo_at_tick(*tick) == *bpm {
continue;
}
let _ = s.tempo.set_tempo(*tick, *bpm);
let _ = tempo.set_tempo(*tick, *bpm);
}
for (tick, n, d) in &smf.meters {
let _ = s.tempo.set_meter(*tick, u32::from(*n), u32::from(*d));
let _ = tempo.set_meter(*tick, u32::from(*n), u32::from(*d));
}
}
let base_tick = s.tempo.samples_to_ticks(at, s.sample_rate);
let mut out = Vec::new();
let base_tick = tempo.samples_to_ticks(at, s.sample_rate);
let mut clips = Vec::new();
for (i, tr) in smf.tracks.iter().enumerate() {
if tr.sequence.notes.is_empty() && tr.sequence.ctrls.is_empty() {
continue;
}
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
let len = tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
if len > s.sample_rate.samples(86_400.0) {
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
}
clips.push((i, tr, len));
}
// Phase 2: nothing can fail from here on.
let s = e.session_mut();
if tempo_map {
s.tempo = tempo;
}
let mut out = Vec::new();
for (i, tr, len) in clips {
let mut name = if tr.name.trim().is_empty() { format!("MIDI {}", i + 1) } else { tr.name.clone() };
soundcraft_model::truncate_chars(&mut name, soundcraft_model::MAX_NAME_CHARS);
let id = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some(&name));
@@ -48,13 +71,6 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
if let Some(t) = s.track_mut(id) {
t.instrument = Some(soundcraft_model::Insert::new(if drums { "drum_synth" } else { "subtractive_synth" }));
}
// A controller on the clip's last tick would not play, so the clip runs one tick past the last one.
let past_ctrls = tr.sequence.ctrls.iter().map(|c| c.tick.saturating_add(1)).max().unwrap_or(0);
let end_tick = base_tick.saturating_add(tr.sequence.end_tick().max(past_ctrls).max(1));
let len = s.tempo.tick_to_samples(end_tick, s.sample_rate).saturating_sub(at);
if len > s.sample_rate.samples(86_400.0) {
return Err(EngineError::Io(format!("MIDI track {}: longer than 24 hours", i + 1)));
}
let cid = s.new_clip_id();
crate::edit::place_clip(s, id, Clip::midi(cid, name, at, len.max(1), tr.sequence.clone()));
out.push(id);
+27
View File
@@ -186,6 +186,33 @@ mod name_tests {
assert!(err.to_string().contains("longer than 24 hours"), "{err}");
}
#[test]
fn a_refused_midi_file_leaves_the_session_untouched() {
// Track 1 fits; track 2 is longer than a day. Before the import is refused, nothing of
// track 1, nor the file's tempo and meter changes, may have reached the session.
let note = |length| soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length };
let short = soundcraft_midi::Sequence { notes: vec![note(960)], ..Default::default() };
let held = soundcraft_midi::Sequence { notes: vec![note((1 << 28) - 1)], ..Default::default() };
let smf = soundcraft_midi::Smf {
tracks: vec![
soundcraft_midi::SmfTrack { name: "fits".into(), sequence: short },
soundcraft_midi::SmfTrack { name: "too long".into(), sequence: held },
],
tempos: vec![(0, 91.0)],
meters: vec![(0, 7, 8)],
..Default::default()
};
let mut e = Engine::default();
let before = e.session().clone();
let (revision, dirty) = (e.revision, e.is_dirty());
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap_err();
assert!(err.to_string().contains("MIDI track 2: longer than 24 hours"), "{err}");
assert_eq!(e.session().tracks.len(), before.tracks.len());
assert_eq!(e.session().tempo, before.tempo);
assert_eq!(*e.session(), before);
assert_eq!((e.revision, e.is_dirty()), (revision, dirty), "a refused file is not an edit");
}
#[test]
fn midi_with_more_meter_changes_than_scores_have_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
+93
View File
@@ -520,6 +520,99 @@ mod open_tests {
assert!(r.session().pool.get(r.session().sources[0].id).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
/// Every outcome of [`relink_videos`] for a movie: its file is there; it is gone but a file of
/// the same name is next to the session or in its `Video Files` (relinked); it is gone with
/// no copy (offline, path kept); its stored path is refused, with or without such a copy.
#[test]
fn each_movie_is_kept_relinked_or_offline() {
let dir = std::env::temp_dir().join(format!("soundcraft-relink-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("Movies")).unwrap();
std::fs::create_dir_all(dir.join("Video Files")).unwrap();
for f in ["Movies/here.mov", "near.mov", "Video Files/filed.mov", "remote_copy.mov"] {
std::fs::write(dir.join(f), b"movie").unwrap();
}
let session = dir.join("Song.scraft");
let mut s = Session::default();
let stored = [
"Movies/here.mov",
"Old/near.mov",
"Old/filed.mov",
"Old/gone.mov",
"file://192.0.2.1/s/remote.mov",
"file://192.0.2.1/s/remote_copy.mov",
];
for path in stored {
let id = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id,
name: path.into(),
path: path.into(),
width: 0,
height: 0,
frame_rate: 0.0,
duration: 0.0,
codec: String::new(),
offline: None,
});
}
let missing = relink_videos(&mut s, &session);
let got: Vec<(String, Option<String>)> = s.videos.iter().map(|v| (v.path.clone(), v.offline.clone())).collect();
let at = |f: &str| dir.join(f).to_string_lossy().into_owned();
assert_eq!(got[0], (stored_path("Movies/here.mov", &session).unwrap().to_string_lossy().into_owned(), None));
assert_eq!(got[1], (at("near.mov"), None));
assert_eq!(got[2], (at("Video Files/filed.mov"), None));
assert_eq!(got[3], ("Old/gone.mov".into(), Some("file not found".into())));
assert_eq!(got[4].0, stored[4]);
assert!(got[4].1.as_deref().is_some_and(|why| why.starts_with("not opened:")), "{:?}", got[4]);
assert_eq!(got[5], (at("remote_copy.mov"), None));
assert_eq!(missing.len(), 2, "{missing:?}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_missing_movie_is_offline_and_keeps_its_stored_path() {
// The stored path is relative to the session and names no file. The movie is offline, so
// the Video track never opens the stored string (the working directory would resolve
// it), and the string is kept: saved into another folder, the session finds the movie
// once it is back there under the same relative path.
let dir = std::env::temp_dir().join(format!("soundcraft-missing-movie-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let (first, moved) = (dir.join("a"), dir.join("b"));
std::fs::create_dir_all(&first).unwrap();
std::fs::create_dir_all(moved.join("Movies")).unwrap();
let mut s = Session::default();
let id = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource {
id,
name: "gone".into(),
path: "Movies/gone.mov".into(),
width: 0,
height: 0,
frame_rate: 0.0,
duration: 0.0,
codec: String::new(),
offline: None,
});
let session = first.join("Song.scraft");
std::fs::write(&session, s.to_json().unwrap()).unwrap();
let mut e = Engine::default();
let missing = open_session(&mut e, &session).unwrap();
assert_eq!(missing, vec![format!("{} (video)", stored_path("Movies/gone.mov", &session).unwrap().display())]);
let v = &e.session().videos[0];
assert_eq!((v.path.as_str(), v.offline.as_deref()), ("Movies/gone.mov", Some("file not found")));
// Saved next to the movie's new home, the session opens it.
let session = moved.join("Song.scraft");
save_session(&mut e, session.to_str().unwrap()).unwrap();
std::fs::write(moved.join("Movies").join("gone.mov"), b"movie").unwrap();
let mut r = Engine::default();
assert!(open_session(&mut r, &session).unwrap().is_empty());
let v = &r.session().videos[0];
assert_eq!(v.path, stored_path("Movies/gone.mov", &session).unwrap().to_string_lossy());
assert!(v.offline.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(all(test, unix))]
+1 -3
View File
@@ -8,12 +8,10 @@ rust-version.workspace = true
repository.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
craft-midi.workspace = true
[dev-dependencies]
proptest.workspace = true
serde_json.workspace = true
[lints]
workspace = true
+4 -34
View File
@@ -1,10 +1,9 @@
//! SoundCraft MIDI.
//!
//! - [`Note`], [`CtrlEvent`] and [`Sequence`]: the MIDI content of a region / track, in ticks at
//! [`PPQ`] (960) ticks per quarter note.
//! - [`read_smf`] / [`write_smf`]: Standard MIDI File import (format 0/1, format 2 best-effort) and
//! export (format 1, 960 PPQ). The reader treats its input as hostile: every length is checked,
//! variable-length quantities are bounded and event counts are capped.
//! [`PPQ`] (960) ticks per quarter note; [`read_smf`] / [`write_smf`]: Standard MIDI File import
//! and export. These come from `craft-midi` in craft-libs, shared with the other Craft apps, and
//! are re-exported so the rest of SoundCraft names them from here.
//! - [`ops`]: MIDI editing operations (quantize, transpose, velocity, duration, split).
//! - [`note_name`], [`gm_drum_name`], [`gm_program_name`]: display names.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -12,36 +11,7 @@
mod names;
pub mod ops;
mod rng;
mod sequence;
mod smf;
pub use craft_midi::{CtrlEvent, CtrlKind, MAX_EVENTS, MAX_TRACKS, MidiError, Note, PPQ, Sequence, Smf, SmfTrack, read_smf, write_smf};
pub use names::{gm_drum_name, gm_program_name, note_name};
pub use rng::XorShift;
pub use sequence::{CtrlEvent, CtrlKind, Note, Sequence};
pub use smf::{MAX_EVENTS, MAX_TRACKS, Smf, SmfTrack, read_smf, write_smf};
/// Ticks per quarter note used throughout the model.
pub const PPQ: u32 = 960;
/// Errors from reading or writing MIDI data.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MidiError {
#[error("not a Standard MIDI File (missing MThd header)")]
NotSmf,
#[error("unexpected end of data at byte {0}")]
Truncated(usize),
#[error("variable-length quantity longer than 4 bytes at byte {0}")]
VlqTooLong(usize),
#[error("chunk length {len} at byte {offset} exceeds the remaining data")]
BadChunkLength { offset: usize, len: u64 },
#[error("invalid MIDI data at byte {offset}: {reason}")]
Invalid { offset: usize, reason: &'static str },
#[error("unsupported MIDI file: {0}")]
Unsupported(&'static str),
#[error("too many events (limit {0})")]
TooManyEvents(usize),
#[error("too many tracks (limit {0})")]
TooManyTracks(usize),
#[error("cannot write MIDI file: {0}")]
Write(String),
}
-141
View File
@@ -1,141 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::collections::hash_map::Entry;
/// A MIDI note. Times are in model ticks ([`crate::PPQ`] per quarter note).
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Note {
/// MIDI pitch 0..=127 (60 = middle C, shown as "C3").
pub pitch: u8,
/// Note-on velocity 1..=127.
pub velocity: u8,
/// Note-off (release) velocity 0..=127; 64 when the source had none.
pub release_velocity: u8,
/// MIDI channel 0..=15.
pub channel: u8,
/// Start in ticks.
pub start: i64,
/// Length in ticks; always > 0.
pub length: i64,
}
impl Note {
/// Tick at which the note ends (`start + length`, saturating).
pub fn end(&self) -> i64 {
self.start.saturating_add(self.length)
}
}
/// Kind of a continuous / channel controller event.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum CtrlKind {
/// Control change with the controller number 0..=127. Value 0..=127.
Cc(u8),
/// Pitch bend. Value is centred: -8192..=8191 (0 = no bend).
PitchBend,
/// Channel (mono) aftertouch. Value 0..=127.
ChannelPressure,
/// Polyphonic aftertouch for the given pitch. Value 0..=127.
PolyPressure(u8),
/// Program change. Value is the program number 0..=127.
Program,
}
/// A controller / channel event at a tick.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct CtrlEvent {
pub tick: i64,
pub channel: u8,
pub kind: CtrlKind,
pub value: i32,
}
/// The MIDI content of a region or track.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Sequence {
/// Notes, sorted by `(start, pitch)` after [`Sequence::sort`].
pub notes: Vec<Note>,
/// Controller events, sorted by tick after [`Sequence::sort`].
pub ctrls: Vec<CtrlEvent>,
}
fn kind_key(k: CtrlKind) -> (u8, u8) {
match k {
CtrlKind::Cc(n) => (0, n),
CtrlKind::PitchBend => (1, 0),
CtrlKind::ChannelPressure => (2, 0),
CtrlKind::PolyPressure(p) => (3, p),
CtrlKind::Program => (4, 0),
}
}
impl Sequence {
/// Sorts notes by `(start, pitch, channel, length, velocity)` and controller events by tick
/// (stable, so same-tick events keep their order).
pub fn sort(&mut self) {
self.notes.sort_by_key(|a| (a.start, a.pitch, a.channel, a.length, a.velocity));
self.ctrls.sort_by_key(|c| c.tick);
}
/// The last tick covered by any note end or controller event (0 when empty).
pub fn end_tick(&self) -> i64 {
let n = self.notes.iter().map(Note::end).max();
let c = self.ctrls.iter().map(|c| c.tick).max();
match (n, c) {
(Some(a), Some(b)) => a.max(b),
(Some(a), None) | (None, Some(a)) => a,
(None, None) => 0,
}
}
/// Notes whose start lies in the half-open range `[start, end)`.
pub fn notes_in(&self, start: i64, end: i64) -> impl Iterator<Item = &Note> + '_ {
self.notes.iter().filter(move |n| n.start >= start && n.start < end)
}
/// Notes that sound anywhere inside the half-open range `[start, end)`.
pub fn notes_overlapping(&self, start: i64, end: i64) -> impl Iterator<Item = &Note> + '_ {
self.notes.iter().filter(move |n| n.start < end && n.end() > start)
}
/// Removes duplicate notes (same channel, pitch and start; the longest one is kept) and
/// duplicate controller events (same tick, channel and kind; the last one is kept, matching
/// what a synth would end up with). Returns how many events were removed. The sequence is
/// left sorted.
pub fn remove_duplicates(&mut self) -> usize {
let before = self.notes.len() + self.ctrls.len();
// Longest first within each (start, pitch, channel) group, then keep the first.
self.notes.sort_by(|a, b| (a.start, a.pitch, a.channel, b.length).cmp(&(b.start, b.pitch, b.channel, a.length)));
self.notes.dedup_by(|later, kept| later.start == kept.start && later.pitch == kept.pitch && later.channel == kept.channel);
self.ctrls.sort_by_key(|c| c.tick);
let mut kept: Vec<CtrlEvent> = Vec::with_capacity(self.ctrls.len());
// The latest tick and `kept` index of each channel+kind, so a same-tick repeat is found
// without rescanning the tick's events (a tick can hold many thousands of distinct keys).
// An entry from an earlier tick counts as absent; keeping the tick instead of clearing the
// map per tick keeps the work per event constant after a dense tick.
let mut last_at: HashMap<(u8, (u8, u8)), (i64, usize)> = HashMap::new();
for c in self.ctrls.drain(..) {
// Within a tick, replace an earlier event of the same channel+kind.
match last_at.entry((c.channel, kind_key(c.kind))) {
Entry::Occupied(mut e) => {
let (tick, idx) = *e.get();
match kept.get_mut(idx) {
Some(slot) if tick == c.tick => *slot = c,
_ => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
Entry::Vacant(e) => {
e.insert((c.tick, kept.len()));
kept.push(c);
}
}
}
self.ctrls = kept;
self.sort();
before - (self.notes.len() + self.ctrls.len())
}
}
-635
View File
@@ -1,635 +0,0 @@
//! Standard MIDI File reading and writing (from the public SMF 1.0 specification).
use std::collections::VecDeque;
use crate::{CtrlEvent, CtrlKind, MidiError, Note, PPQ, Sequence};
/// Maximum number of events (across all tracks) [`read_smf`] will parse.
pub const MAX_EVENTS: usize = 5_000_000;
/// Maximum number of track chunks [`read_smf`] will accept.
pub const MAX_TRACKS: usize = 4096;
/// Release velocity used when a note ends with a note-on of velocity 0 (per the spec, a
/// note-on with velocity 0 is a note-off with velocity 64).
const DEFAULT_RELEASE: u8 = 64;
/// One track of a Standard MIDI File.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SmfTrack {
/// Name from the first Sequence/Track Name meta event (empty if none).
pub name: String,
pub sequence: Sequence,
}
/// A Standard MIDI File in model terms. All ticks are at [`PPQ`] (960) per quarter note.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Smf {
/// Format of the source file (0, 1 or 2). [`write_smf`] always writes format 1.
pub format: u16,
pub tracks: Vec<SmfTrack>,
/// `(tick, beats per minute)`.
pub tempos: Vec<(i64, f64)>,
/// `(tick, numerator, denominator)`, e.g. `(0, 6, 8)`.
pub meters: Vec<(i64, u8, u8)>,
/// `(tick, text)` from Marker meta events.
pub markers: Vec<(i64, String)>,
/// `(tick, sharps (+) / flats (-) -7..=7, is_minor)`.
pub key_sigs: Vec<(i64, i8, bool)>,
}
// ---------------------------------------------------------------------------------------------
// Reading
// ---------------------------------------------------------------------------------------------
struct Cursor<'a> {
data: &'a [u8],
pos: usize,
/// Absolute offset of `data[0]` in the file, for error messages.
base: usize,
}
impl<'a> Cursor<'a> {
fn at(&self) -> usize {
self.base.saturating_add(self.pos)
}
fn is_empty(&self) -> bool {
self.pos >= self.data.len()
}
fn u8(&mut self) -> Result<u8, MidiError> {
let b = *self.data.get(self.pos).ok_or(MidiError::Truncated(self.at()))?;
self.pos += 1;
Ok(b)
}
fn peek(&self) -> Result<u8, MidiError> {
self.data.get(self.pos).copied().ok_or(MidiError::Truncated(self.at()))
}
fn bytes(&mut self, n: usize) -> Result<&'a [u8], MidiError> {
let end = self.pos.checked_add(n).ok_or(MidiError::Truncated(self.at()))?;
let s = self.data.get(self.pos..end).ok_or(MidiError::Truncated(self.at()))?;
self.pos = end;
Ok(s)
}
fn u16(&mut self) -> Result<u16, MidiError> {
let b = self.bytes(2)?;
Ok(u16::from_be_bytes([b.first().copied().unwrap_or(0), b.get(1).copied().unwrap_or(0)]))
}
fn u32(&mut self) -> Result<u32, MidiError> {
let b = self.bytes(4)?;
let mut a = [0u8; 4];
a.copy_from_slice(b);
Ok(u32::from_be_bytes(a))
}
/// Variable-length quantity: at most 4 bytes (28 bits) per the spec.
fn vlq(&mut self) -> Result<u32, MidiError> {
let start = self.at();
let mut v: u32 = 0;
for _ in 0..4 {
let b = self.u8()?;
v = (v << 7) | u32::from(b & 0x7F);
if b & 0x80 == 0 {
return Ok(v);
}
}
Err(MidiError::VlqTooLong(start))
}
}
/// Converts file ticks to model ticks: `model = file * num / den` (rounded).
#[derive(Clone, Copy)]
struct TickConv {
num: i128,
den: i128,
}
impl TickConv {
fn from_division(division: u16, offset: usize) -> Result<Self, MidiError> {
if division & 0x8000 == 0 {
if division == 0 {
return Err(MidiError::Invalid { offset, reason: "division of 0 ticks per quarter note" });
}
Ok(Self { num: i128::from(PPQ), den: i128::from(division) })
} else {
// SMPTE: high byte is negative frames/sec (two's complement), low byte ticks/frame.
// Approximated at 120 bpm: one quarter note = 0.5 s, so
// model = file / ticks_per_sec * 2 * PPQ.
let [hi, lo] = division.to_be_bytes();
let fps = -i128::from(hi as i8);
let tpf = i128::from(lo);
if !matches!(fps, 24 | 25 | 29 | 30) || tpf == 0 {
return Err(MidiError::Invalid { offset, reason: "invalid SMPTE division" });
}
let two_ppq = 2 * i128::from(PPQ);
if fps == 29 {
// 29.97 drop-frame: ticks/sec = 30000/1001 * tpf
Ok(Self { num: two_ppq * 1001, den: 30000 * tpf })
} else {
Ok(Self { num: two_ppq, den: fps * tpf })
}
}
}
fn convert(&self, file_tick: u64) -> i64 {
let v = (i128::from(file_tick) * self.num + self.den / 2) / self.den;
i64::try_from(v).unwrap_or(i64::MAX)
}
}
struct Globals {
tempos: Vec<(i64, f64)>,
meters: Vec<(i64, u8, u8)>,
markers: Vec<(i64, String)>,
key_sigs: Vec<(i64, i8, bool)>,
}
/// Parses a Standard MIDI File.
///
/// - Formats 0 and 1 are fully supported; format 2 is read best-effort (all tracks share one
/// timeline). Each `MTrk` chunk becomes one [`SmfTrack`]; for format 1 a leading conductor
/// track without notes or controllers is folded into the global tempo/meter/marker lists.
/// - Ticks are converted to [`PPQ`]; an SMPTE division is approximated at 120 bpm.
/// - Running status, note-on velocity 0 as note-off, unmatched note-offs (ignored), overlapping
/// same-pitch notes (matched first-in first-out) and hanging notes (closed at the end of the
/// track) are handled. Zero-length notes get a length of 1 tick.
/// - Unknown chunks and meta events are skipped. Any malformed structure (bad header, truncated
/// chunk, VLQ longer than 4 bytes, data byte without running status, ...) returns an error.
pub fn read_smf(bytes: &[u8]) -> Result<Smf, MidiError> {
let mut cur = Cursor { data: bytes, pos: 0, base: 0 };
if cur.bytes(4).map_err(|_| MidiError::NotSmf)? != b"MThd" {
return Err(MidiError::NotSmf);
}
let header_len = cur.u32()?;
if header_len < 6 {
return Err(MidiError::Invalid { offset: 4, reason: "header chunk shorter than 6 bytes" });
}
let header_len = usize::try_from(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::from(header_len) })?;
let header = cur.bytes(header_len).map_err(|_| MidiError::BadChunkLength { offset: 4, len: u64::try_from(header_len).unwrap_or(u64::MAX) })?;
let mut hc = Cursor { data: header, pos: 0, base: 8 };
let format = hc.u16()?;
let _ntrks = hc.u16()?;
let division = hc.u16()?;
if format > 2 {
return Err(MidiError::Unsupported("SMF format greater than 2"));
}
let conv = TickConv::from_division(division, 12)?;
let mut globals = Globals { tempos: Vec::new(), meters: Vec::new(), markers: Vec::new(), key_sigs: Vec::new() };
let mut tracks = Vec::new();
let mut event_budget = MAX_EVENTS;
while !cur.is_empty() {
let chunk_at = cur.at();
let id = cur.bytes(4)?;
let len = cur.u32()?;
let remaining = bytes.len().saturating_sub(cur.pos);
let len_usize = usize::try_from(len).unwrap_or(usize::MAX);
if len_usize > remaining {
return Err(MidiError::BadChunkLength { offset: chunk_at, len: u64::from(len) });
}
let body_at = cur.at();
let body = cur.bytes(len_usize)?;
if id != b"MTrk" {
continue; // unknown chunk types must be ignored
}
if tracks.len() >= MAX_TRACKS {
return Err(MidiError::TooManyTracks(MAX_TRACKS));
}
let track = read_track(body, body_at, conv, &mut globals, &mut event_budget)?;
tracks.push(track);
}
// A lone track is only a conductor track if it carried global data and has no name; otherwise it is kept.
let globals_present = !(globals.tempos.is_empty() && globals.meters.is_empty() && globals.markers.is_empty() && globals.key_sigs.is_empty());
if format == 1
&& (tracks.len() > 1 || globals_present)
&& tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty() && (tracks.len() > 1 || t.name.is_empty()))
{
tracks.remove(0);
}
// Keep global lists ordered by tick (stable: file order within a tick).
globals.tempos.sort_by_key(|t| t.0);
globals.meters.sort_by_key(|t| t.0);
globals.markers.sort_by_key(|t| t.0);
globals.key_sigs.sort_by_key(|t| t.0);
Ok(Smf { format, tracks, tempos: globals.tempos, meters: globals.meters, markers: globals.markers, key_sigs: globals.key_sigs })
}
/// Open note-ons for one (channel, pitch), oldest first: `(model tick, velocity)`.
type OpenNotes = Vec<VecDeque<(i64, u8)>>;
fn close_note(open: &mut OpenNotes, notes: &mut Vec<Note>, channel: u8, pitch: u8, tick: i64, release: u8) {
let key = usize::from(channel & 0x0F) * 128 + usize::from(pitch & 0x7F);
let Some(stack) = open.get_mut(key) else { return };
if stack.is_empty() {
return; // unmatched note-off: ignored
}
let Some((start, velocity)) = stack.pop_front() else { return };
let length = tick.saturating_sub(start).max(1);
notes.push(Note { pitch, velocity, release_velocity: release, channel, start, length });
}
fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget: &mut usize) -> Result<SmfTrack, MidiError> {
let mut cur = Cursor { data: body, pos: 0, base };
let mut name: Option<String> = None;
let mut seq = Sequence::default();
let mut open: OpenNotes = vec![VecDeque::new(); 16 * 128];
let mut file_tick: u64 = 0;
let mut tick: i64 = 0;
let mut running: Option<u8> = None;
while !cur.is_empty() {
if *budget == 0 {
return Err(MidiError::TooManyEvents(MAX_EVENTS));
}
*budget -= 1;
let delta = cur.vlq()?;
file_tick = file_tick.saturating_add(u64::from(delta));
tick = conv.convert(file_tick);
let event_at = cur.at();
let first = cur.peek()?;
let status = if first & 0x80 != 0 {
cur.pos += 1;
first
} else {
running.ok_or(MidiError::Invalid { offset: event_at, reason: "data byte without running status" })?
};
match status {
0xFF => {
running = None;
let kind = cur.u8()?;
let len = cur.vlq()?;
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
match kind {
0x2F => break, // End of Track
0x03 if name.is_none() => {
name = Some(String::from_utf8_lossy(data).into_owned());
}
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
0x51 => {
if let [a, b, c] = data {
let us = (u32::from(*a) << 16) | (u32::from(*b) << 8) | u32::from(*c);
if us > 0 {
g.tempos.push((tick, 60_000_000.0 / f64::from(us)));
}
}
}
0x58 => {
if let [nn, dd, ..] = data
&& *nn > 0
&& *dd <= 7
{
g.meters.push((tick, *nn, 1u8 << *dd));
}
}
0x59 => {
if let [sf, mi, ..] = data {
let sf = *sf as i8;
if (-7..=7).contains(&sf) {
g.key_sigs.push((tick, sf, *mi == 1));
}
}
}
_ => {}
}
}
0xF0 | 0xF7 => {
running = None;
let len = cur.vlq()?;
cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
}
0x80..=0xEF => {
running = Some(status);
let channel = status & 0x0F;
let d1 = cur.u8()?;
if d1 & 0x80 != 0 {
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
}
let needs_two = !matches!(status & 0xF0, 0xC0 | 0xD0);
let d2 = if needs_two {
let d = cur.u8()?;
if d & 0x80 != 0 {
return Err(MidiError::Invalid { offset: event_at, reason: "status byte where a data byte was expected" });
}
d
} else {
0
};
match status & 0xF0 {
0x80 => close_note(&mut open, &mut seq.notes, channel, d1, tick, d2),
0x90 if d2 == 0 => close_note(&mut open, &mut seq.notes, channel, d1, tick, DEFAULT_RELEASE),
0x90 => {
if let Some(stack) = open.get_mut(usize::from(channel) * 128 + usize::from(d1)) {
stack.push_back((tick, d2));
}
}
0xA0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PolyPressure(d1), value: i32::from(d2) }),
0xB0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Cc(d1), value: i32::from(d2) }),
0xC0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::Program, value: i32::from(d1) }),
0xD0 => seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::ChannelPressure, value: i32::from(d1) }),
_ => {
// 0xE0 pitch bend: LSB then MSB, centred on 8192.
let v = (i32::from(d2) << 7 | i32::from(d1)) - 8192;
seq.ctrls.push(CtrlEvent { tick, channel, kind: CtrlKind::PitchBend, value: v });
}
}
}
_ => return Err(MidiError::Invalid { offset: event_at, reason: "system real-time/common status in a track" }),
}
}
// Close hanging notes at the end of the track.
for (key, stack) in open.iter_mut().enumerate() {
let channel = u8::try_from(key / 128).unwrap_or(0);
let pitch = u8::try_from(key % 128).unwrap_or(0);
for (start, velocity) in stack.drain(..) {
let length = tick.saturating_sub(start).max(1);
seq.notes.push(Note { pitch, velocity, release_velocity: DEFAULT_RELEASE, channel, start, length });
}
}
seq.sort();
Ok(SmfTrack { name: name.unwrap_or_default(), sequence: seq })
}
// ---------------------------------------------------------------------------------------------
// Writing
// ---------------------------------------------------------------------------------------------
const MAX_VLQ: u64 = 0x0FFF_FFFF;
fn push_vlq(out: &mut Vec<u8>, v: u64) -> Result<(), MidiError> {
if v > MAX_VLQ {
return Err(MidiError::Write(format!("value {v} does not fit in a MIDI variable-length quantity")));
}
let mut buf = [0u8; 4];
let mut n = 0;
let mut x = v;
loop {
if let Some(slot) = buf.get_mut(n) {
*slot = (x & 0x7F) as u8;
}
n += 1;
x >>= 7;
if x == 0 || n == 4 {
break;
}
}
for i in (0..n).rev() {
let b = buf.get(i).copied().unwrap_or(0);
out.push(if i > 0 { b | 0x80 } else { b });
}
Ok(())
}
/// An event ready to be serialised: `(tick, order, bytes)`. `order` breaks ties at one tick.
struct WEvent {
tick: u64,
order: u8,
bytes: Vec<u8>,
}
fn clamp_tick(t: i64) -> u64 {
u64::try_from(t.max(0)).unwrap_or(0)
}
fn meta(kind: u8, data: &[u8]) -> Result<Vec<u8>, MidiError> {
let mut b = vec![0xFF, kind];
push_vlq(&mut b, u64::try_from(data.len()).unwrap_or(u64::MAX))?;
b.extend_from_slice(data);
Ok(b)
}
fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiError> {
events.sort_by_key(|e| (e.tick, e.order));
let mut body = Vec::new();
let mut last_tick = 0u64;
let mut running: Option<u8> = None;
for e in &events {
push_vlq(&mut body, e.tick - last_tick)?;
last_tick = e.tick;
let status = e.bytes.first().copied().unwrap_or(0);
if (0x80..=0xEF).contains(&status) {
if running == Some(status) {
body.extend_from_slice(e.bytes.get(1..).unwrap_or(&[]));
} else {
body.extend_from_slice(&e.bytes);
}
running = Some(status);
} else {
running = None;
body.extend_from_slice(&e.bytes);
}
}
// End of Track.
body.extend_from_slice(&[0x00, 0xFF, 0x2F, 0x00]);
let len = u32::try_from(body.len()).map_err(|_| MidiError::Write("track larger than 4 GiB".into()))?;
out.extend_from_slice(b"MTrk");
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(&body);
Ok(())
}
/// The conductor track: tempos, meters, key signatures and markers.
fn conductor_events(smf: &Smf) -> Result<Vec<WEvent>, MidiError> {
let mut ev = Vec::new();
for &(t, bpm) in &smf.tempos {
if !bpm.is_finite() || bpm <= 0.0 {
return Err(MidiError::Write(format!("invalid tempo {bpm} bpm")));
}
let us = (60_000_000.0 / bpm).round().clamp(1.0, f64::from(0x00FF_FFFFu32)) as u32;
let [_, a, b, c] = us.to_be_bytes();
ev.push(WEvent { tick: clamp_tick(t), order: 0, bytes: meta(0x51, &[a, b, c])? });
}
for &(t, num, den) in &smf.meters {
if den == 0 || !den.is_power_of_two() {
return Err(MidiError::Write(format!("meter denominator {den} is not a power of two")));
}
let dd = den.trailing_zeros() as u8;
ev.push(WEvent { tick: clamp_tick(t), order: 1, bytes: meta(0x58, &[num, dd, 24, 8])? });
}
for &(t, sf, minor) in &smf.key_sigs {
let sf = sf.clamp(-7, 7);
ev.push(WEvent { tick: clamp_tick(t), order: 2, bytes: meta(0x59, &[sf as u8, u8::from(minor)])? });
}
for (t, text) in &smf.markers {
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
}
Ok(ev)
}
/// Trim notes still sounding when the next same-pitch note starts, and drop duplicates.
fn trim_overlaps(notes: &[Note], ends: &mut [u64], dropped: &mut [bool]) {
// The reader pairs offs with ons first-in first-out, so a note still sounding when the next
// same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch
// notes start on the same tick they cannot be told apart, so only the longer one is written.
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by_key(|&i| {
notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| {
(n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0)))
})
});
let mut prev: Option<usize> = None;
for &b in &order {
let Some(nb) = notes.get(b) else { continue };
if let Some(a) = prev
&& let Some(na) = notes.get(a)
&& na.channel & 0x0F == nb.channel & 0x0F
&& na.pitch.min(127) == nb.pitch.min(127)
{
let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start));
if sa == sb {
if let Some(d) = dropped.get_mut(b) {
*d = true;
}
continue;
}
if let Some(ea) = ends.get_mut(a)
&& sb < *ea
{
*ea = sb;
}
}
prev = Some(b);
}
}
/// Writes a format-1 Standard MIDI File at [`PPQ`] (960) ticks per quarter note.
///
/// Track 1 is a conductor track holding tempos, meters, key signatures and markers; every
/// [`SmfTrack`] follows as its own track. Negative ticks are clamped to 0, data values are
/// clamped to their MIDI ranges, and note-on velocity 0 is written as 1 (0 would mean
/// note-off). At each tick note-offs are written before controllers, then note-ons.
///
/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two
/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit.
pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
// The conductor track is omitted when there is nothing to put in it and no content tracks, so an
// empty `Smf` is written as a file with zero tracks and reads back as zero tracks.
let has_conductor =
!(smf.tempos.is_empty() && smf.meters.is_empty() && smf.key_sigs.is_empty() && smf.markers.is_empty() && smf.tracks.is_empty());
let ntrks =
u16::try_from(smf.tracks.len().saturating_add(usize::from(has_conductor))).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?;
let mut out = Vec::new();
out.extend_from_slice(b"MThd");
out.extend_from_slice(&6u32.to_be_bytes());
out.extend_from_slice(&1u16.to_be_bytes());
out.extend_from_slice(&ntrks.to_be_bytes());
out.extend_from_slice(&u16::try_from(PPQ).unwrap_or(960).to_be_bytes());
// Conductor track.
let ev = conductor_events(smf)?;
if has_conductor {
write_track(&mut out, ev)?;
}
for track in &smf.tracks {
let mut ev = Vec::new();
if !track.name.is_empty() {
ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? });
}
let notes = &track.sequence.notes;
let mut ends: Vec<u64> = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect();
let mut dropped = vec![false; notes.len()];
trim_overlaps(notes, &mut ends, &mut dropped);
for (i, n) in notes.iter().enumerate() {
if dropped.get(i).copied().unwrap_or(false) {
continue;
}
let ch = n.channel & 0x0F;
let pitch = n.pitch.min(127);
let start = clamp_tick(n.start);
let end = ends.get(i).copied().unwrap_or(start.saturating_add(1));
ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] });
ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity.min(127)] });
}
for c in &track.sequence.ctrls {
let ch = c.channel & 0x0F;
let v7 = c.value.clamp(0, 127) as u8;
let bytes = match c.kind {
CtrlKind::Cc(num) => vec![0xB0 | ch, num.min(127), v7],
CtrlKind::Program => vec![0xC0 | ch, v7],
CtrlKind::ChannelPressure => vec![0xD0 | ch, v7],
CtrlKind::PolyPressure(p) => vec![0xA0 | ch, p.min(127), v7],
CtrlKind::PitchBend => {
let v = (c.value.saturating_add(8192)).clamp(0, 16383);
vec![0xE0 | ch, (v & 0x7F) as u8, ((v >> 7) & 0x7F) as u8]
}
};
ev.push(WEvent { tick: clamp_tick(c.tick), order: 2, bytes });
}
write_track(&mut out, ev)?;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn vlq_round_trip() {
for v in [0u64, 0x40, 0x7F, 0x80, 0x2000, 0x3FFF, 0x4000, 0x1F_FFFF, 0x20_0000, MAX_VLQ] {
let mut b = Vec::new();
push_vlq(&mut b, v).unwrap();
let mut c = Cursor { data: &b, pos: 0, base: 0 };
assert_eq!(u64::from(c.vlq().unwrap()), v);
assert!(c.is_empty());
}
assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err());
}
#[test]
fn empty_smf_round_trips_with_no_tracks() {
let r = read_smf(&write_smf(&Smf::default()).unwrap()).unwrap();
assert_eq!(r.tracks.len(), 0);
let one = Smf { tracks: vec![SmfTrack::default()], ..Smf::default() };
let r = read_smf(&write_smf(&one).unwrap()).unwrap();
assert_eq!(r.tracks.len(), 1);
let cond = Smf { tempos: vec![(0, 90.0)], ..Smf::default() };
let r = read_smf(&write_smf(&cond).unwrap()).unwrap();
assert_eq!(r.tracks.len(), 0);
assert_eq!(r.tempos.len(), 1);
}
#[test]
fn spec_vlq_examples() {
let mut b = Vec::new();
push_vlq(&mut b, 0x0800_0000).unwrap();
assert_eq!(b, [0xC0, 0x80, 0x80, 0x00]);
let mut b = Vec::new();
push_vlq(&mut b, 0x2000).unwrap();
assert_eq!(b, [0xC0, 0x00]);
}
#[test]
fn same_pitch_overlaps_do_not_swap_ends() {
let mk = |start, length| Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start, length };
let smf = Smf {
format: 1,
tracks: vec![SmfTrack {
name: String::new(),
sequence: Sequence { notes: vec![mk(0, 1000), mk(100, 200), mk(1000, 100), mk(2000, 500), mk(2000, 900)], ..Sequence::default() },
}],
tempos: vec![],
meters: vec![],
markers: vec![],
key_sigs: vec![],
};
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
let mut got: Vec<(i64, i64)> = back.tracks.iter().flat_map(|t| t.sequence.notes.iter().map(|n| (n.start, n.length))).collect();
got.sort_unstable();
// The earlier note is trimmed to the start of the next same-pitch note; later notes keep their length;
// of two notes starting together only the longer survives.
assert_eq!(got, vec![(0, 100), (100, 200), (1000, 100), (2000, 900)]);
}
}
-1
View File
@@ -1,4 +1,3 @@
//! Integration tests, built as one test binary: each file is a module.
mod ops;
mod smf;
-104
View File
@@ -187,107 +187,3 @@ fn split_by_pitch() {
assert_eq!(inside.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![60, 48]);
assert_eq!(outside.iter().map(|n| n.pitch).collect::<Vec<_>>(), vec![36, 72]);
}
#[test]
fn sequence_helpers() {
let mut seq = Sequence {
notes: vec![n(64, 100, 50), n(60, 100, 10), n(60, 100, 30), n(60, 0, 10), Note { channel: 2, ..n(60, 0, 10) }],
ctrls: vec![
CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 1 },
CtrlEvent { tick: 5, channel: 0, kind: CtrlKind::Cc(1), value: 2 },
CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(1), value: 3 },
CtrlEvent { tick: 500, channel: 0, kind: CtrlKind::Cc(2), value: 4 },
],
};
assert_eq!(seq.end_tick(), 500);
seq.sort();
assert_eq!(starts(&seq.notes), vec![0, 0, 100, 100, 100]);
assert_eq!(seq.notes[2].pitch, 60);
assert_eq!(seq.notes_in(0, 100).count(), 2);
assert_eq!(seq.notes_overlapping(105, 106).count(), 3);
let removed = seq.remove_duplicates();
assert_eq!(removed, 2);
assert_eq!(seq.notes.len(), 4);
let kept = seq.notes.iter().find(|n| n.start == 100 && n.pitch == 60).unwrap();
assert_eq!(kept.length, 30);
assert_eq!(seq.ctrls.iter().map(|c| c.value).collect::<Vec<_>>(), vec![2, 3, 4]);
assert_eq!(seq.remove_duplicates(), 0);
assert_eq!(Sequence::default().end_tick(), 0);
}
#[test]
fn sequence_serde_round_trip() {
let seq = Sequence { notes: vec![n(60, 0, 1)], ctrls: vec![CtrlEvent { tick: 1, channel: 0, kind: CtrlKind::PolyPressure(60), value: 9 }] };
let json = serde_json::to_string(&seq).unwrap();
assert_eq!(serde_json::from_str::<Sequence>(&json).unwrap(), seq);
}
#[test]
fn remove_duplicates_handles_a_large_same_tick_controller_group() {
// Every channel x controller number at one tick, twice: the second copy of each wins. A
// rescan of the tick's kept events per incoming event would make this quadratic.
let keys: Vec<(u8, u8)> = (0..=255u8).flat_map(|ch| (0..=255u8).map(move |cc| (ch, cc))).collect();
let ctrls = [0, 1]
.iter()
.flat_map(|&pass| keys.iter().map(move |&(channel, cc)| CtrlEvent { tick: 7, channel, kind: CtrlKind::Cc(cc), value: pass }))
.collect();
let mut seq = Sequence { notes: Vec::new(), ctrls };
let start = std::time::Instant::now();
assert_eq!(seq.remove_duplicates(), keys.len());
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert_eq!(seq.ctrls.len(), keys.len());
assert!(seq.ctrls.iter().all(|c| c.value == 1));
}
#[test]
fn remove_duplicates_matches_a_per_tick_rescan_on_mixed_ticks() {
// The straightforward per-tick rescan the indexed version replaced, kept as an oracle.
fn reference(mut ctrls: Vec<CtrlEvent>) -> Vec<CtrlEvent> {
ctrls.sort_by_key(|c| c.tick);
let key = |k: CtrlKind| match k {
CtrlKind::Cc(n) => (0, n),
CtrlKind::PitchBend => (1, 0),
CtrlKind::ChannelPressure => (2, 0),
CtrlKind::PolyPressure(p) => (3, p),
CtrlKind::Program => (4, 0),
};
let mut kept: Vec<CtrlEvent> = Vec::new();
for c in ctrls {
match kept.iter_mut().rev().take_while(|k| k.tick == c.tick).find(|k| k.channel == c.channel && key(k.kind) == key(c.kind)) {
Some(slot) => *slot = c,
None => kept.push(c),
}
}
kept
}
// Few ticks, channels and kinds, so the same key recurs both within a tick (interleaved with
// other keys) and across ticks; unsorted input from a fixed xorshift stream.
let mut state = 0x9E37_79B9_7F4A_7C15u64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let kinds = |r: u64| match r % 5 {
0 => CtrlKind::Cc((r >> 8) as u8 % 4),
1 => CtrlKind::PitchBend,
2 => CtrlKind::ChannelPressure,
3 => CtrlKind::PolyPressure((r >> 8) as u8 % 4),
_ => CtrlKind::Program,
};
for round in 0..200 {
let mut ctrls = Vec::new();
for i in 0..(next() % 64) {
let r = next();
ctrls.push(CtrlEvent { tick: (r % 6) as i64 - 2, channel: (r >> 16) as u8 % 3, kind: kinds(r >> 24), value: (round * 100 + i) as i32 });
}
let mut seq = Sequence { notes: Vec::new(), ctrls: ctrls.clone() };
let removed = seq.remove_duplicates();
let expected = reference(ctrls.clone());
assert_eq!(seq.ctrls, expected, "round {round}");
assert_eq!(removed, ctrls.len() - expected.len(), "round {round}");
}
}
-375
View File
@@ -1,375 +0,0 @@
use proptest::prelude::*;
use soundcraft_midi::*;
fn vlq(mut v: u32) -> Vec<u8> {
let mut out = vec![(v & 0x7F) as u8];
v >>= 7;
while v > 0 {
out.insert(0, (v & 0x7F) as u8 | 0x80);
v >>= 7;
}
out
}
fn header(format: u16, ntrks: u16, division: u16) -> Vec<u8> {
let mut b = b"MThd".to_vec();
b.extend_from_slice(&6u32.to_be_bytes());
b.extend_from_slice(&format.to_be_bytes());
b.extend_from_slice(&ntrks.to_be_bytes());
b.extend_from_slice(&division.to_be_bytes());
b
}
fn track(body: &[u8]) -> Vec<u8> {
let mut b = b"MTrk".to_vec();
b.extend_from_slice(&(body.len() as u32).to_be_bytes());
b.extend_from_slice(body);
b
}
fn file(format: u16, division: u16, bodies: &[Vec<u8>]) -> Vec<u8> {
let mut b = header(format, bodies.len() as u16, division);
for body in bodies {
b.extend(track(body));
}
b
}
fn note(pitch: u8, start: i64, length: i64) -> Note {
Note { pitch, velocity: 100, release_velocity: 64, channel: 0, start, length }
}
#[test]
fn round_trip() {
let mut seq = Sequence {
notes: vec![
note(60, 0, 480),
Note { pitch: 64, velocity: 90, release_velocity: 30, channel: 3, start: 480, length: 960 },
note(67, 960, 1),
note(60, 1920, 240),
],
ctrls: vec![
CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Program, value: 5 },
CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 100 },
CtrlEvent { tick: 20, channel: 1, kind: CtrlKind::PitchBend, value: -8192 },
CtrlEvent { tick: 30, channel: 1, kind: CtrlKind::PitchBend, value: 8191 },
CtrlEvent { tick: 40, channel: 1, kind: CtrlKind::PitchBend, value: 0 },
CtrlEvent { tick: 50, channel: 2, kind: CtrlKind::ChannelPressure, value: 77 },
CtrlEvent { tick: 60, channel: 2, kind: CtrlKind::PolyPressure(61), value: 12 },
],
};
seq.sort();
let smf = Smf {
format: 1,
tracks: vec![
SmfTrack { name: "Piano".into(), sequence: seq.clone() },
SmfTrack { name: "Bass ♪".into(), sequence: Sequence { notes: vec![note(36, 100, 50)], ctrls: vec![] } },
],
tempos: vec![(0, 120.0), (3840, 90.0)],
meters: vec![(0, 4, 4), (3840, 6, 8)],
markers: vec![(0, "Intro".into()), (7680, "Verse".into())],
key_sigs: vec![(0, -3, true), (3840, 2, false)],
};
let bytes = write_smf(&smf).unwrap();
let back = read_smf(&bytes).unwrap();
assert_eq!(back.format, 1);
assert_eq!(back.tracks.len(), 2);
assert_eq!(back.tracks[0].name, "Piano");
assert_eq!(back.tracks[0].sequence, seq);
assert_eq!(back.tracks[1].name, "Bass ♪");
assert_eq!(back.tracks[1].sequence.notes, vec![note(36, 100, 50)]);
assert_eq!(back.tempos.len(), 2);
assert!((back.tempos[0].1 - 120.0).abs() < 1e-9);
assert!((back.tempos[1].1 - 90.0).abs() < 1e-3);
assert_eq!(back.tempos[1].0, 3840);
assert_eq!(back.meters, smf.meters);
assert_eq!(back.markers, smf.markers);
assert_eq!(back.key_sigs, smf.key_sigs);
// Writing again is byte-identical.
let mut again = back.clone();
again.tempos = smf.tempos.clone();
assert_eq!(write_smf(&again).unwrap(), bytes);
}
#[test]
fn first_track_name_wins_and_ignored_meta_preserves_following_notes() {
let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat();
for first in [&b"First"[..], &b""[..], &[0xFF][..]] {
let body = [
meta(0x03, first),
meta(0x03, b"Second"),
meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored.
vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0],
]
.concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
assert_eq!(smf.tracks.len(), 1);
assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first));
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]);
}
}
#[test]
fn write_rejects_bad_values() {
let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() };
assert!(write_smf(&bad_tempo).is_err());
let nan_tempo = Smf { tempos: vec![(0, f64::NAN)], ..Default::default() };
assert!(write_smf(&nan_tempo).is_err());
let bad_meter = Smf { meters: vec![(0, 4, 3)], ..Default::default() };
assert!(write_smf(&bad_meter).is_err());
let huge_gap = Smf {
tracks: vec![SmfTrack { name: String::new(), sequence: Sequence { notes: vec![note(60, 0x1000_0000, 10)], ctrls: vec![] } }],
..Default::default()
};
assert!(write_smf(&huge_gap).is_err());
}
#[test]
fn division_conversion_480_to_960() {
// Note at tick 480 (one quarter at 480 PPQ), length 240.
let body = [vlq(480), vec![0x90, 60, 100], vlq(240), vec![0x80, 60, 0], vec![0, 0xFF, 0x2F, 0]].concat();
let smf = read_smf(&file(0, 480, &[body])).unwrap();
assert_eq!(smf.tracks.len(), 1);
assert_eq!(smf.tracks[0].sequence.notes, vec![Note { pitch: 60, velocity: 100, release_velocity: 0, channel: 0, start: 960, length: 480 }]);
}
#[test]
fn division_conversion_96_and_smpte() {
let body = [vlq(96), vec![0x90, 60, 100], vlq(48), vec![0x80, 60, 64]].concat();
let smf = read_smf(&file(0, 96, std::slice::from_ref(&body))).unwrap();
assert_eq!(smf.tracks[0].sequence.notes[0].start, 960);
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
// SMPTE 25 fps, 40 ticks/frame = 1000 ticks/s; 96 ticks = 96 ms; at 120 bpm 0.5 s = 960 ticks.
let div = u16::from_be_bytes([(-25i8) as u8, 40]);
let body = [vlq(500), vec![0x90, 60, 100], vlq(250), vec![0x80, 60, 64]].concat();
let smf = read_smf(&file(0, div, &[body])).unwrap();
assert_eq!(smf.tracks[0].sequence.notes[0].start, 960);
assert_eq!(smf.tracks[0].sequence.notes[0].length, 480);
}
#[test]
fn smpte_division_rejects_undefined_frame_rates() {
let body = vec![0, 0x90, 60, 100, 40, 0x80, 60, 64, 0, 0xFF, 0x2F, 0];
for high in 0x80..=0xFF {
if [(-24i8) as u8, (-25i8) as u8, (-29i8) as u8, (-30i8) as u8].contains(&high) {
continue;
}
let division = u16::from_be_bytes([high, 40]);
assert!(matches!(read_smf(&file(0, division, std::slice::from_ref(&body))), Err(MidiError::Invalid { offset: 12, .. })), "{division:#06x}");
}
}
#[test]
fn smpte_division_keeps_all_four_standard_frame_rates() {
for (fps, file_ticks, model_ticks) in [(24i8, 960, 1920), (25, 1000, 1920), (29, 1200, 1922), (30, 1200, 1920)] {
let body = [vec![0, 0x90, 60, 100], vlq(file_ticks), vec![0x80, 60, 64, 0, 0xFF, 0x2F, 0]].concat();
let division = u16::from_be_bytes([(-fps) as u8, 40]);
let smf = read_smf(&file(0, division, std::slice::from_ref(&body))).unwrap();
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, model_ticks)], "{fps} fps");
assert!(read_smf(&file(0, u16::from_be_bytes([(-fps) as u8, 0]), std::slice::from_ref(&body))).is_err());
}
}
#[test]
fn running_status_and_velocity_zero_note_off() {
// Note-on with running status for both on and off (velocity 0).
let body = [
vec![0x00, 0x91, 60, 100], // on C3 ch1
vec![0x00, 64, 90], // running: on E3
vlq(960),
vec![60, 0], // running: note-on vel 0 = off C3
vec![0x00, 64, 0],
vec![0x00, 0xB1, 7, 100], // CC7
vec![0x10, 10, 64], // running CC10
]
.concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
let seq = &smf.tracks[0].sequence;
assert_eq!(
seq.notes,
vec![
Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 1, start: 0, length: 960 },
Note { pitch: 64, velocity: 90, release_velocity: 64, channel: 1, start: 0, length: 960 },
]
);
assert_eq!(
seq.ctrls,
vec![
CtrlEvent { tick: 960, channel: 1, kind: CtrlKind::Cc(7), value: 100 },
CtrlEvent { tick: 976, channel: 1, kind: CtrlKind::Cc(10), value: 64 },
]
);
}
#[test]
fn meta_cancels_running_status() {
let body = [vec![0x00, 0x90, 60, 100], vec![0x00, 0xFF, 0x01, 0x01, b'x'], vec![0x00, 60, 0]].concat();
assert!(read_smf(&file(0, 960, &[body])).is_err());
}
#[test]
fn unmatched_note_off_ignored_and_hanging_notes_closed() {
let body = [
vec![0x00, 0x80, 50, 0], // unmatched off
vec![0x00, 0x90, 60, 100], // hanging
vlq(100),
vec![0x90, 62, 100],
vlq(100),
vec![0x80, 62, 10],
vlq(800),
vec![0xFF, 0x2F, 0x00],
]
.concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
let notes = &smf.tracks[0].sequence.notes;
assert_eq!(notes.len(), 2);
assert_eq!(notes[0], Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 1000 });
assert_eq!(notes[1], Note { pitch: 62, velocity: 100, release_velocity: 10, channel: 0, start: 100, length: 100 });
}
#[test]
fn overlapping_same_pitch_fifo_and_zero_length() {
let body = [
vec![0x00, 0x90, 60, 100],
vec![0x10, 0x90, 60, 80],
vec![0x10, 0x80, 60, 0],
vec![0x10, 0x80, 60, 0],
vec![0x00, 0x90, 70, 1],
vec![0x00, 0x80, 70, 0],
]
.concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
let notes = &smf.tracks[0].sequence.notes;
assert_eq!((notes[0].start, notes[0].length, notes[0].velocity), (0, 32, 100));
assert_eq!((notes[1].start, notes[1].length, notes[1].velocity), (16, 32, 80));
assert_eq!((notes[2].pitch, notes[2].length), (70, 1));
}
#[test]
fn format1_conductor_folded_and_unknown_chunks_skipped() {
let conductor = [
vec![0x00, 0xFF, 0x51, 0x03, 0x07, 0xA1, 0x20], // 500000 us = 120 bpm
vec![0x00, 0xFF, 0x58, 0x04, 3, 2, 24, 8], // 3/4
vec![0x00, 0xFF, 0x2F, 0x00],
]
.concat();
let t1 = [vec![0x00, 0xFF, 0x03, 0x03], b"Org".to_vec(), vec![0x00, 0x90, 60, 100, 0x60, 0x80, 60, 0]].concat();
let mut bytes = header(1, 2, 960);
bytes.extend(track(&conductor));
bytes.extend(b"XFIH\x00\x00\x00\x03abc");
bytes.extend(track(&t1));
let smf = read_smf(&bytes).unwrap();
assert_eq!(smf.tracks.len(), 1);
assert_eq!(smf.tracks[0].name, "Org");
assert_eq!(smf.tempos, vec![(0, 120.0)]);
assert_eq!(smf.meters, vec![(0, 3, 4)]);
}
#[test]
fn sysex_skipped() {
let body = [vec![0x00, 0xF0, 0x03, 0x7E, 0x7F, 0xF7], vec![0x00, 0x90, 60, 100, 0x10, 0x80, 60, 0]].concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
assert_eq!(smf.tracks[0].sequence.notes.len(), 1);
}
#[test]
fn malformed_inputs_are_errors() {
assert!(matches!(read_smf(b""), Err(MidiError::NotSmf)));
assert!(matches!(read_smf(b"RIFF\x00\x00\x00\x06"), Err(MidiError::NotSmf)));
// Header truncated.
assert!(read_smf(b"MThd\x00\x00\x00\x06\x00\x01").is_err());
// Header length < 6.
assert!(read_smf(b"MThd\x00\x00\x00\x02\x00\x01").is_err());
// Division 0.
assert!(read_smf(&header(0, 0, 0)).is_err());
// Format 3.
assert!(read_smf(&header(3, 0, 96)).is_err());
// Chunk length larger than the data.
let mut b = header(0, 1, 96);
b.extend(b"MTrk\xFF\xFF\xFF\xF0\x00\x90");
assert!(matches!(read_smf(&b), Err(MidiError::BadChunkLength { .. })));
// Truncated chunk header.
let mut b = header(0, 1, 96);
b.extend(b"MTr");
assert!(read_smf(&b).is_err());
// Huge VLQ (5 continuation bytes).
let body = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x7F, 0x90, 60, 100];
assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::VlqTooLong(_))));
// Meta length past the end of the track.
let body = vec![0x00, 0xFF, 0x03, 0x7F, b'a'];
assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::Truncated(_))));
// Truncated channel message.
let body = vec![0x00, 0x90, 60];
assert!(read_smf(&file(0, 96, &[body])).is_err());
// Data byte with no running status.
let body = vec![0x00, 60, 100];
assert!(read_smf(&file(0, 96, &[body])).is_err());
// System common inside a track.
let body = vec![0x00, 0xF2, 0, 0];
assert!(read_smf(&file(0, 96, &[body])).is_err());
// Header only (no tracks) is fine.
assert_eq!(read_smf(&header(1, 0, 96)).unwrap().tracks.len(), 0);
}
#[test]
fn event_cap_reached_is_error() {
// MAX_EVENTS + 1 one-byte running-status program changes (2 bytes each with delta).
let mut body = vec![0x00, 0xC0, 0x00];
body.reserve(MAX_EVENTS * 2);
for _ in 0..MAX_EVENTS {
body.extend_from_slice(&[0x00, 0x01]);
}
assert!(matches!(read_smf(&file(0, 96, &[body])), Err(MidiError::TooManyEvents(_))));
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(512))]
#[test]
fn random_bytes_never_panic(data in proptest::collection::vec(any::<u8>(), 0..512)) {
let _ = read_smf(&data);
}
#[test]
fn random_track_bodies_never_panic(body in proptest::collection::vec(any::<u8>(), 0..512), div in any::<u16>(), fmt in 0u16..4) {
let _ = read_smf(&file(fmt, div, &[body]));
}
#[test]
fn written_files_read_back(notes in proptest::collection::vec((0u8..128, 1u8..128, 0u8..16, 0i64..100_000, 1i64..10_000), 0..64)) {
let mut seq = Sequence {
notes: notes.iter().map(|&(pitch, velocity, channel, start, length)| Note { pitch, velocity, release_velocity: 64, channel, start, length }).collect(),
ctrls: vec![],
};
seq.remove_duplicates();
let smf = Smf { format: 1, tracks: vec![SmfTrack { name: "t".into(), sequence: seq.clone() }], ..Default::default() };
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
// Overlapping same-pitch notes may re-pair, but count and starts survive.
let got = back.tracks.first().map(|t| t.sequence.notes.clone()).unwrap_or_default();
prop_assert_eq!(got.len(), seq.notes.len());
let mut a: Vec<_> = got.iter().map(|n| (n.start, n.pitch, n.channel)).collect();
let mut b: Vec<_> = seq.notes.iter().map(|n| (n.start, n.pitch, n.channel)).collect();
a.sort();
b.sort();
prop_assert_eq!(a, b);
}
}
/// #472: out-of-range pitches, release velocities and controller numbers clamp to 127 instead of
/// wrapping into other notes and controllers (128 & 0x7F would be 0).
#[test]
fn writer_clamps_out_of_range_data_bytes() {
for raw in [127u8, 128, 255] {
let mut seq = Sequence::default();
seq.notes.push(Note { pitch: raw, velocity: 100, release_velocity: raw, channel: 0, start: 0, length: 960 });
seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::Cc(raw), value: 64 });
seq.ctrls.push(CtrlEvent { tick: 0, channel: 0, kind: CtrlKind::PolyPressure(raw), value: 64 });
let smf = Smf { tracks: vec![SmfTrack { name: String::new(), sequence: seq }], ..Smf::default() };
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
let s = &back.tracks.last().unwrap().sequence;
assert_eq!((s.notes[0].pitch, s.notes[0].release_velocity), (127, 127), "input {raw}");
let kinds: Vec<CtrlKind> = s.ctrls.iter().map(|c| c.kind).collect();
assert!(kinds.contains(&CtrlKind::Cc(127)) && kinds.contains(&CtrlKind::PolyPressure(127)), "input {raw}: {kinds:?}");
}
}
+28 -2
View File
@@ -33,8 +33,13 @@ impl FileFormat {
Container::MpegAudio => FileFormat::Mp3,
Container::Ogg => FileFormat::Ogg,
Container::Caf => FileFormat::Caf,
Container::Mp4 | Container::QuickTime if info.codec == CodecId::Alac => FileFormat::Alac,
Container::Mp4 | Container::QuickTime | Container::Adts => FileFormat::Aac,
Container::Adts => FileFormat::Aac,
// MP4 and QuickTime hold many codecs: the codec names the kind.
Container::Mp4 | Container::QuickTime => match info.codec {
CodecId::Alac => FileFormat::Alac,
CodecId::Aac | CodecId::HeAac => FileFormat::Aac,
_ => FileFormat::Other,
},
_ => FileFormat::Other,
}
}
@@ -165,6 +170,27 @@ mod tests {
}
}
/// An MP4 is AAC only when its audio is: Opus, AC-3 or PCM in MP4 is not.
#[test]
fn an_mp4_is_named_by_its_codec() {
let wav = encode(&AudioBuffer::new(48_000, 1, 480), &EncodeOptions::default()).unwrap();
let mut info = serde_json::to_value(probe(&wav, None).unwrap()).unwrap();
for (container, codec, format) in [
("Mp4", "Aac", FileFormat::Aac),
("Mp4", "HeAac", FileFormat::Aac),
("QuickTime", "Alac", FileFormat::Alac),
("Mp4", "Opus", FileFormat::Other),
("Mp4", "Ac3", FileFormat::Other),
("QuickTime", "Pcm", FileFormat::Other),
("Adts", "Aac", FileFormat::Aac),
] {
info["container"] = container.into();
info["codec"] = codec.into();
let info: craft_audio::AudioInfo = serde_json::from_value(info.clone()).unwrap();
assert_eq!(FileFormat::of(&info), format, "{codec} in {container}");
}
}
#[test]
fn a_probed_file_maps_to_its_format() {
let buf = AudioBuffer::new(48_000, 1, 480);
+65 -15
View File
@@ -82,6 +82,8 @@ pub struct Dialogs {
strip_values: StripValues,
/// The Strip Silence rectangles the Edit window draws while Strip Silence is open.
strip_rectangles: StripRectangles,
/// True while a dialog was drawn on the previous frame.
shown: bool,
}
impl Dialogs {
@@ -122,15 +124,15 @@ impl Dialogs {
}
pub fn open_rename_track(&mut self, id: TrackId, name: &str) {
self.open = Some(Dialog::RenameTrack { id, name: name.to_string() });
self.set(Dialog::RenameTrack { id, name: name.to_string() });
}
pub fn open_rename_clip(&mut self, id: ClipId, name: &str) {
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
self.set(Dialog::RenameClip { id, name: name.to_string() });
}
pub fn open_rename_marker(&mut self, id: soundcraft_model::MarkerId, name: &str) {
self.open = Some(Dialog::RenameMarker { id, name: name.to_string() });
self.set(Dialog::RenameMarker { id, name: name.to_string() });
}
/// Groups list › Modify Group…: the creation dialog, pre-filled from an existing group. False
@@ -139,24 +141,31 @@ impl Dialogs {
let s = e.session();
let Some(g) = s.group(id) else { return false };
let members = s.tracks.iter().map(|t| (t.id.0, t.name.clone(), g.members.contains(&t.id))).collect();
self.open = Some(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members });
self.set(Dialog::Group { id: Some(id), name: g.name.clone(), edit: g.edit, mix: g.mix, members });
true
}
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
self.open = Some(Dialog::ClearClips { clips, sources, message });
self.set(Dialog::ClearClips { clips, sources, message });
true
}
/// Opens `d` in place of any open dialog. Its first frame clears the keyboard focus and ignores Enter, as for a
/// dialog opened when none was open.
pub(crate) fn set(&mut self, d: Dialog) {
self.open = Some(d);
self.shown = false;
}
/// Clip List › Auto Rename: the clips' naming scheme, filled in from the first clip's. False
/// when none of the clips is in the session.
pub fn open_auto_rename(&mut self, e: &Engine, clips: Vec<ClipId>) -> bool {
let s = e.session();
let Some((_, first)) = clips.iter().find_map(|id| s.find_clip(*id)) else { return false };
let names = first.auto_name.and_then(|k| s.auto_names.get(&k)).map_or_else(StripNames::default, StripNames::from);
self.open = Some(Dialog::AutoRename { clips, names });
self.set(Dialog::AutoRename { clips, names });
true
}
@@ -165,7 +174,7 @@ impl Dialogs {
self.after_save = None;
let home = default_dir();
let name = e.session().name.clone();
self.open = Some(match id {
let d = match id {
"clip.clear" => {
let ed = &e.session().edit;
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
@@ -246,7 +255,8 @@ impl Dialogs {
Some(d) => d,
None => return false,
},
});
};
self.set(d);
true
}
}
@@ -286,6 +296,15 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<
Some(text)
}
/// The file a bounce in `format` writes for the path in the form: the path itself when its
/// extension already names the format (`Mix.aifc` and `Mix.AIFF` stay as picked, so the file
/// the save dialog confirmed is the one written), otherwise the path with the format's extension.
pub fn bounce_path(path: &str, format: EncodeFormat) -> std::path::PathBuf {
let p = std::path::Path::new(path);
let named = p.extension().and_then(std::ffi::OsStr::to_str).and_then(craft_audio::encode_format_for);
if named == Some(format) { p.to_path_buf() } else { p.with_extension(format.extension()) }
}
/// 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 EncodeFormat) {
ui.horizontal(|ui| {
@@ -312,7 +331,25 @@ fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, for
fn path_prompt_form(app: &mut SoundApp, ui: &mut egui::Ui, cmd: &str, path: &mut String, key: &str, enter: bool) -> Option<(String, Value)> {
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(path).desired_width(420.0)), "Path");
let mut picked = None;
// Import Audio picks several files at once; every other open dialog takes one.
let multi = cmd == "file.import_audio" && app.services.pick_open_many.is_some();
if multi
&& let Some(pick) = &app.services.pick_open_many
&& ui.button("Browse…").clicked()
{
let mut files = pick(cmd);
if files.len() > 1 {
let texts: Result<Vec<&str>, _> = files.iter().map(|f| unicode(f)).collect();
match texts {
Ok(texts) => return Some((cmd.to_string(), json!({"paths": texts}))),
Err(e) => app.ui.status = e.to_string(),
}
} else {
picked = files.pop();
}
}
if let Some(pick) = &app.services.pick_open
&& !multi
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
&& key != "dir"
@@ -367,10 +404,10 @@ fn strip_silence_form(
let mut rename_from = None;
let [threshold, min_ms, pre_ms, post_ms] = values;
let before = [*threshold, *min_ms, *pre_ms, *post_ms];
ui.add(egui::Slider::new(threshold, -96.0..=0.0).text("Threshold dB"));
ui.add(egui::Slider::new(min_ms, 0.0..=4000.0).text("Min strip duration ms"));
ui.add(egui::Slider::new(pre_ms, 0.0..=500.0).text("Clip start pad ms"));
ui.add(egui::Slider::new(post_ms, 0.0..=2000.0).text("Clip end pad ms"));
crate::widgets::fine_slider(ui, threshold, -96.0..=0.0, "Threshold dB");
crate::widgets::fine_slider(ui, min_ms, 0.0..=4000.0, "Min strip duration ms");
crate::widgets::fine_slider(ui, pre_ms, 0.0..=500.0, "Clip start pad ms");
crate::widgets::fine_slider(ui, post_ms, 0.0..=2000.0, "Clip end pad ms");
let values = [*threshold, *min_ms, *pre_ms, *post_ms];
// The Edit window is drawn before this window, so it draws the rectangles for new values in the next frame.
if values != before {
@@ -416,7 +453,16 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
if !matches!(app.dialogs.open, Some(Dialog::StripSilence { .. } | Dialog::StripSilenceRename { .. })) {
app.dialogs.strip_rectangles = StripRectangles::default();
}
let Some(mut d) = app.dialogs.open.take() else { return };
let Some(mut d) = app.dialogs.open.take() else {
app.dialogs.shown = false;
return;
};
// On the first frame a dialog is drawn, clear the keyboard focus a widget outside it may still hold, and ignore Enter:
// an Enter in that frame is the one that opened the dialog from a menu item or the command palette.
let first_frame = !std::mem::replace(&mut app.dialogs.shown, true);
if first_frame {
ctx.memory_mut(|m| m.stop_text_input());
}
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut bounce_params: Option<Value> = None;
@@ -449,7 +495,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::SaveChanges { .. } => "Unsaved Changes",
}
.to_string();
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
// egui treats Enter on a focused button, checkbox, radio button or combo box as a click, so Enter presses the default
// button only when no widget has the keyboard focus or a text field has it. A held Enter's repeats do not press it.
let fresh_enter =
|i: &egui::InputState| i.events.iter().any(|e| matches!(e, egui::Event::Key { key: egui::Key::Enter, pressed: true, repeat: false, .. }));
let enter = !first_frame && ctx.input(fresh_enter) && (ctx.memory(|m| m.focused().is_none()) || ctx.text_edit_focused());
let esc = ctx.input(|i| i.key_pressed(egui::Key::Escape));
egui::Window::new(egui::RichText::new(&title).font(bold(13.0)))
.collapsible(false)
@@ -511,7 +561,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
.color(Tokens::current().text_dim),
);
if buttons(ui, "Bounce", enter) {
let p = std::path::Path::new(path.as_str()).with_extension(format.extension());
let p = bounce_path(path, *format);
bounce_params = Some(
json!({"path": p.to_string_lossy(), "format": format.extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
);
+15 -4
View File
@@ -18,21 +18,32 @@ pub(super) fn audiosuite_window(app: &mut SoundApp, ctx: &egui::Context) {
if let Some(info) = soundcraft_dsp::plugin_info(&process) {
for p in info.params {
let v = params.entry(p.id.to_string()).or_insert(p.default);
ui.add(egui::Slider::new(v, p.min..=p.max).text(p.name));
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))
.text(p.name)
.default_value(f64::from(p.default))
.show(ui);
*v = wide as f32;
}
} else {
match process.as_str() {
"normalize" => {
let v = params.entry("target_db".into()).or_insert(-0.1);
ui.add(egui::Slider::new(v, -24.0..=0.0).text("Peak dB"));
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, -24.0..=0.0).text("Peak dB").default_value(-0.1).show(ui);
*v = wide as f32;
}
"time_stretch" => {
let v = params.entry("ratio".into()).or_insert(1.0);
ui.add(egui::Slider::new(v, 0.25..=4.0).text("Length ratio"));
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, 0.25..=4.0).text("Length ratio").default_value(1.0).show(ui);
*v = wide as f32;
}
"pitch_shift" => {
let v = params.entry("semitones".into()).or_insert(0.0);
ui.add(egui::Slider::new(v, -24.0..=24.0).text("Semitones"));
let mut wide = f64::from(*v);
crate::widgets::FineSlider::new(&mut wide, -24.0..=24.0).text("Semitones").default_value(0.0).show(ui);
*v = wide as f32;
}
"speed_ramp" => {
for (id, label, default, choices) in SPEED_RAMP_CHOICES {
+1 -1
View File
@@ -14,7 +14,7 @@ pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
if !app.engine.is_dirty() {
return false;
}
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
app.dialogs.set(Dialog::SaveChanges { then: (id.into(), params.clone()) });
true
}
+2
View File
@@ -43,6 +43,8 @@ use layout::COLUMN_TITLES_H;
use layout::lane_rect;
pub use layout::{COLUMN_W, EditLayout, HEADER_W, LANE_H, RULER_H, edit_columns, header_width, rect, sample_at, track_height, x_of};
use overlay::overlay;
#[cfg(test)]
use overlay::playhead_colour;
pub use rulers::ruler_height;
use rulers::{column_titles, draw_rulers, views_button};
pub use scroll::zoom_toggle;
+1 -1
View File
@@ -315,7 +315,7 @@ fn trim_clip(app: &mut SoundApp, clip: ClipId, to: Samples, start: bool) {
pub(super) fn spot_or_run(app: &mut SoundApp, spot: Option<Spot>) {
let Some(spot) = spot else { return };
if app.engine.session().edit.edit_mode == EditMode::Spot {
app.dialogs.open = Some(Dialog::Spot(spot));
app.dialogs.set(Dialog::Spot(spot));
} else if let Some((id, params)) = spot.command(app.engine.session()) {
let _ = app.run(&id, params);
}
+1 -1
View File
@@ -550,7 +550,7 @@ fn io_column(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
ui.painter().rect_filled(r, 0.0, t.slot_well);
let ui = column_ui(ui, "io", r);
let row = |y: f32, h: f32| (r.height() >= y + h + 1.0).then_some(Rect::from_min_size(r.min + vec2(7.0, y), vec2(r.width() - 14.0, h)));
if let Some(input) = row(4.0, 15.0) {
if let Some(input) = row(4.0, 15.0).filter(|_| crate::mix_window::has_input_selector(track.kind)) {
let input_name = crate::mix_window::input_name(app, track);
let resp = header_selector(&ui, input, ui.scope_id().with(("in", id.0)), &input_name, t.text, true, &format!("Input for {}", track.name));
crate::mix_window::route_popup(app, &resp.on_hover_text("Input"), id, true);
+8 -2
View File
@@ -356,14 +356,20 @@ fn lane_click(app: &mut SoundApp, ui: &mut Ui, c: &LaneCtx<'_>, resp: &egui::Res
ids.push(c.id.0);
let _ = app.run("edit.select", json!({"clips": ids}));
if s.edit.edit_mode == EditMode::Spot {
app.dialogs.open = Spot::clips(s, vec![c.id], None, 0).map(Dialog::Spot);
match Spot::clips(s, vec![c.id], None, 0) {
Some(spot) => app.dialogs.set(Dialog::Spot(spot)),
None => app.dialogs.open = None,
}
}
}
}
Tool::Trim if s.edit.edit_mode == EditMode::Spot && hit.is_some() => {
if let Some(c) = &hit {
let start = at - c.start < c.end() - at;
app.dialogs.open = Spot::trim(s, c.id, start, if start { c.start } else { c.end() }).map(Dialog::Spot);
match Spot::trim(s, c.id, start, if start { c.start } else { c.end() }) {
Some(spot) => app.dialogs.set(Dialog::Spot(spot)),
None => app.dialogs.open = None,
}
}
}
Tool::Zoom => {
+7 -1
View File
@@ -4,6 +4,12 @@ use super::layout::x_of;
use super::scroll::{FollowFrame, follow_update};
use super::{Color32, Gesture, Rect, Samples, Shape, SoundApp, Stroke, StrokeKind, Tokens, TrackId, Ui, json, ops, pos2, rgb};
/// The playback cursor is red only while recording; plain playback draws it in its own neutral
/// grey, apart from the insertion colour of the edit cursor that stands beside it.
pub(super) fn playhead_colour(t: &Tokens, recording: bool) -> Color32 {
if recording { t.playhead } else { t.playback }
}
/// Selection overlay, insertion point and playhead across rulers and tracks.
/// While playing, auto-scroll keeps the playhead in view for the follow
/// modes (`page`/`continuous`/`center`) — but any outside view move (a manual
@@ -61,7 +67,7 @@ pub(super) fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
}
if app.is_playing() {
let x = x_of(s, tl, app.position());
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, t.playhead));
painter.line_segment([pos2(x, area.min.y), pos2(x, area.max.y)], Stroke::new(1.5, playhead_colour(&t, app.engine.transport.recording)));
// Session reads first (owned copies), then the layout mutation: the
// two borrow disjoint fields.
let pos = app.position();
+1 -1
View File
@@ -110,7 +110,7 @@ pub(super) fn draw_rulers(app: &mut SoundApp, ui: &mut Ui, area: Rect, tl: Rect,
{
let at = snap(&s, sample_at(&s, tl, p.x).max(0));
let bpm = s.tempo.tempo_at_tick(s.tempo.samples_to_ticks(at, sr));
app.dialogs.open = Some(crate::dialogs::Dialog::TempoChange { at, bpm });
app.dialogs.set(crate::dialogs::Dialog::TempoChange { at, bpm });
}
let mut last_label = f32::MIN;
for ev in s.tempo.tempos() {
+62
View File
@@ -1574,6 +1574,68 @@ fn alt_click_on_a_breakpoint_deletes_it() {
}
}
#[test]
fn playhead_is_red_only_while_recording() {
for t in [Tokens::DARK, Tokens::LIGHT] {
assert_eq!(playhead_colour(&t, true), t.playhead);
assert_eq!(playhead_colour(&t, false), t.playback);
assert_ne!(t.playback, t.playhead, "plain playback must not read as the record line");
}
}
/// WCAG contrast ratio between two colours (1 = identical, 21 = black on white).
fn contrast(a: Color32, b: Color32) -> f32 {
let lum = |c: Color32| {
let lin = |v: u8| {
let v = f32::from(v) / 255.0;
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
};
0.2126 * lin(c.r()) + 0.7152 * lin(c.g()) + 0.0722 * lin(c.b())
};
let (hi, lo) = (lum(a).max(lum(b)), lum(a).min(lum(b)));
(hi + 0.05) / (lo + 0.05)
}
/// The edit cursor stands still beside the moving playback line while playing: in both themes
/// they must be two clearly different greys, and the line must still read on the lanes.
#[test]
fn the_playback_line_is_set_apart_from_the_insertion_point_in_both_themes() {
for (name, t) in [("dark", Tokens::DARK), ("light", Tokens::LIGHT)] {
let apart = contrast(t.playback, t.insertion);
assert!(apart >= 2.5, "{name}: playback {:?} and insertion {:?} are only {apart:.2}:1 apart", t.playback, t.insertion);
let reads = contrast(t.playback, t.playlist_bg);
assert!(reads >= 3.0, "{name}: the playback line is only {reads:.2}:1 on the lanes");
}
}
/// The colour `overlay` gives the full-height playback line (width 1.5) in this transport state.
fn playback_line_colour(recording: bool) -> Option<Color32> {
use egui::{Rect, pos2};
let _palette = crate::theme::lock_test_palette();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
app.engine.session_mut().edit.zoom.samples_per_px = 1_000.0;
app.engine.transport.playing = true;
app.engine.transport.recording = recording;
app.engine.transport.position = 80_000;
let tl = Rect::from_min_max(pos2(100.0, 100.0), pos2(900.0, 500.0));
let area = Rect::from_min_max(pos2(100.0, 50.0), tl.max);
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| overlay(&mut app, ui, tl, area));
output.textures_delta.clear();
output.shapes.iter().find_map(|s| match &s.shape {
Shape::LineSegment { points, stroke } if points[0].x == 180.0 && stroke.width == 1.5 => Some(stroke.color),
_ => None,
})
}
#[test]
fn overlay_draws_the_playback_line_neutral_and_the_record_line_red() {
let _palette = crate::theme::lock_test_palette();
let t = Tokens::current();
assert_eq!(playback_line_colour(false), Some(t.playback));
assert_eq!(playback_line_colour(true), Some(t.playhead));
}
#[test]
fn alt_click_on_a_marker_deletes_it() {
let mut engine = soundcraft_engine::demo::demo_engine();
+6 -1
View File
@@ -1,3 +1,4 @@
use super::overlay::playhead_colour;
use super::{Color32, Rect, Samples, Sense, Session, SoundApp, Stroke, StrokeKind, Tokens, Track, Ui, json, pos2, rgb, vec2};
/// The Universe's clip blocks for one document revision and area: each track's clips merged into
@@ -82,7 +83,11 @@ pub(super) fn universe(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_stroke(vr, 2.0, Stroke::new(1.5, Tokens::current().universe_view), StrokeKind::Outside);
if app.is_playing() {
let x = r.min.x + app.position() as f32 / total * r.width();
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, r.max.y)], Stroke::new(1.0, Tokens::current().playhead));
let line = [pos2(x, r.min.y), pos2(x, r.max.y)];
// The Universe paints clips in their raw track colours, some as light as the neutral playback line: a dark
// edge on each side keeps it readable over all of them.
ui.painter().line_segment(line, Stroke::new(3.0, Color32::from_black_alpha(160)));
ui.painter().line_segment(line, Stroke::new(1.0, playhead_colour(&Tokens::current(), app.engine.transport.recording)));
}
let resp = ui.interact(r, ui.scope_id().with("universe"), Sense::click_and_drag());
if let Some(p) = resp.interact_pointer_pos()
+37 -1
View File
@@ -46,8 +46,30 @@ impl Imports {
}
/// Imports the audio file `params["path"]` names (the `file.import_audio` params) in the
/// background. Anything else (several `paths`, no path, the browser) runs the command now.
/// background; several `paths` queue one import each. Anything else (no path, the browser) runs the command now.
pub fn start(app: &mut SoundApp, params: Value) {
// Several picked files: one queue entry each, so they decode one at a time and the app stays responsive.
if !cfg!(target_arch = "wasm32")
&& params.get("path").is_none()
&& let Some(paths) = params.get("paths").and_then(Value::as_array)
{
let singles: Vec<Value> = paths
.iter()
.filter_map(Value::as_str)
.map(|p| {
let mut one = params.clone();
if let Some(o) = one.as_object_mut() {
o.remove("paths");
o.insert("path".into(), Value::from(p));
}
one
})
.collect();
if !singles.is_empty() {
app.imports.queue.extend(singles);
return;
}
}
if cfg!(target_arch = "wasm32") || params.get("path").and_then(Value::as_str).is_none() {
run_now(app, params);
return;
@@ -213,6 +235,20 @@ mod tests {
assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports");
}
/// Issue #1089: several files picked in one Browse go to the queue, one entry each.
#[test]
fn several_paths_import_one_after_another() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let (a, b, c) = (tone_wav("many-a.wav"), tone_wav("many-b.wav"), tone_wav("many-c.wav"));
start(&mut app, json!({"paths": [a, b, c]}));
assert_eq!(app.imports.queue.len(), 3);
step(&mut app, &ctx);
assert_eq!(app.imports.label().map(|l| l.1), Some(2), "the other two wait for the first");
settle(&mut app, &ctx);
assert_eq!(app.engine.session().tracks.len(), 3);
}
#[test]
fn a_failed_import_stays_on_the_status_line_while_the_next_one_starts() {
let ctx = egui::Context::default();
+31 -14
View File
@@ -371,27 +371,36 @@ fn comments_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f
y
}
/// Input, output and automation mode selectors.
/// Whether a track shows an input selector. A master fader has no input of its own:
/// it only shows the output of the path it controls.
pub(crate) fn has_input_selector(kind: TrackKind) -> bool {
kind != TrackKind::Master
}
/// Input (except on master faders), output and automation mode selectors.
fn io_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) -> f32 {
let StripCtx { track, id, narrow, x0, inner_w, .. } = *cx;
let t = Tokens::current();
if cx.shows("io") {
let has_input = has_input_selector(track.kind);
let sec = Rect::from_min_size(pos2(x0, y), vec2(inner_w, 16.0 + 2.0 * 18.0 + 30.0));
inset(ui.painter(), sec, t.strip_section);
section_label(ui, sec, "I / O", narrow);
let in_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0), vec2(inner_w - 4.0, 16.0));
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let input = input_name(app, track);
let resp = selector_box_named(
&mut c,
in_r.width(),
in_r.height(),
&input,
t.text,
SelectorLabels { name: &format!("Input for {}", track.name), value: &input, tooltip: "Input" },
);
route_popup(app, &resp, id, true);
let out_r = if has_input { Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0)) } else { in_r };
if has_input {
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let input = input_name(app, track);
let resp = selector_box_named(
&mut c,
in_r.width(),
in_r.height(),
&input,
t.text,
SelectorLabels { name: &format!("Input for {}", track.name), value: &input, tooltip: "Input" },
);
route_popup(app, &resp, id, true);
}
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
let output = route_name(app, &track.mixer.output);
let resp = selector_box_named(
@@ -404,7 +413,7 @@ fn io_section(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>, mut y: f32) ->
);
route_popup(app, &resp, id, false);
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 15.0 + 2.0 * 18.0), vec2(inner_w - 4.0, 16.0));
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(am_r));
let mode = track.mixer.automation_mode;
@@ -667,6 +676,14 @@ fn name_and_readout(app: &mut SoundApp, ui: &mut Ui, cx: &StripCtx<'_>) {
mod tests {
use super::{Readout, dragged_send_db, editable_readout, parse_db, parse_pan};
use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2};
use soundcraft_model::TrackKind;
#[test]
fn master_strip_has_no_input_selector() {
assert!(!super::has_input_selector(TrackKind::Master));
assert!(super::has_input_selector(TrackKind::Audio));
assert!(super::has_input_selector(TrackKind::Aux));
}
#[test]
fn typed_db_accepts_console_notation_and_clamps() {
+2 -1
View File
@@ -335,7 +335,6 @@ enum SendFooter {
/// that scrolls, "new bus..." (and "Remove send" for an existing send) fixed under it.
/// `current` is the send's destination and its name, if the slot has a send.
fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: TrackId, slot: usize, current: Option<(Route, String)>) {
let busses = app.engine.session().busses.clone();
let which = (b'A' + slot as u8) as char;
let has_send = current.is_some();
let header = |ui: &mut Ui| {
@@ -355,6 +354,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
}
}
let search = MenuSearch::show(ui, egui::Id::unique(("send-bus-search", id.0, slot)), "Search buses…");
let busses = &app.engine.session().busses;
let visible: Vec<_> = busses.iter().filter(|bus| search.matches(&bus.name)).collect();
(search, visible)
};
@@ -402,6 +402,7 @@ fn send_route_menu(app: &mut SoundApp, ui: &Ui, field: &egui::Response, id: Trac
}
match footer {
Some(SendFooter::NewBus) => {
let busses = &app.engine.session().busses;
let mut n = busses.len() + 1;
while busses.iter().any(|bus| bus.name.eq_ignore_ascii_case(&format!("Bus {n}")))
|| app.engine.session().track_by_name(&format!("Bus {n}")).is_some()
+12 -2
View File
@@ -78,16 +78,26 @@ pub(super) fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
}
});
x = idx as f32;
if r.response.clicked() && ui.input(|i| i.modifiers.alt) {
x = p.default;
egui::Popup::close_all(ui.ctx());
}
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
let mut wide = f64::from(x);
let mut sl = crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))
.id_salt(p.id)
.show_value(false)
.default_value(f64::from(p.default));
if instrument && crate::sampler_window::whole_number(&ins.plugin, p.id) {
sl = sl.integer();
}
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
let r = sl.show(ui);
x = wide as f32;
r
};
// The grid cell's painted label is not an accesskit name; tie the
// combo box or slider to it so screen readers and label queries find it.
+10 -2
View File
@@ -791,7 +791,13 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
let key = format!("{prefix}{}", p.id);
let mut v = get(app, &key, p.default);
let before = v;
col.add(egui::Slider::new(&mut v, p.min..=p.max).text(p.name));
let mut wide = f64::from(v);
crate::widgets::FineSlider::new(&mut wide, f64::from(p.min)..=f64::from(p.max))
.text(p.name)
.logarithmic(p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0)
.default_value(f64::from(p.default))
.show(col);
v = wide as f32;
if (v - before).abs() > f32::EPSILON {
let mut m = serde_json::Map::new();
m.insert(key.clone(), json!(v));
@@ -806,7 +812,9 @@ pub fn clip_effects(app: &mut SoundApp, ctx: &egui::Context) {
});
let mut g = get(app, "gain", 0.0);
let before = g;
ui.add(egui::Slider::new(&mut g, -36.0..=24.0).text("Clip effects gain (dB)"));
let mut wide = f64::from(g);
crate::widgets::FineSlider::new(&mut wide, -36.0..=24.0).text("Clip effects gain (dB)").default_value(0.0).show(ui);
g = wide as f32;
if (g - before).abs() > f32::EPSILON {
let _ =
app.engine.execute_merged("clip.effects_set", &json!({"clips": [cid.0], "params": {"gain": g}}), &format!("cfx:{}:gain", cid.0));
+2
View File
@@ -5,6 +5,8 @@ use std::path::{Path, PathBuf};
#[derive(Default)]
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<PathBuf>>>,
/// Like `pick_open`, but the user may select several files (Import Audio). Empty when cancelled.
pub pick_open_many: Option<Box<dyn Fn(&str) -> Vec<PathBuf>>>,
/// The purpose and the file to start from.
pub pick_save: Option<Box<dyn Fn(&str, &Path) -> Option<PathBuf>>>,
/// The folder to start in.
+5
View File
@@ -143,6 +143,9 @@ pub struct Tokens {
pub selection: Color32,
pub playhead: Color32,
pub insertion: Color32,
/// The playback line while the transport runs without recording: a mid grey, set well apart
/// from `insertion` (the edit cursor stands beside it while playing) in both themes.
pub playback: Color32,
pub solo: Color32,
pub mute: Color32,
pub rec: Color32,
@@ -253,6 +256,7 @@ impl Tokens {
selection: Color32::from_rgba_premultiplied(40, 70, 110, 110),
playhead: Color32::from_rgb(232, 60, 50),
insertion: Color32::from_rgb(230, 230, 230),
playback: Color32::from_rgb(140, 140, 142),
solo: Color32::from_rgb(222, 196, 52),
mute: Color32::from_rgb(232, 148, 40),
rec: Color32::from_rgb(214, 52, 46),
@@ -338,6 +342,7 @@ impl Tokens {
selection: Color32::from_rgba_premultiplied(100, 145, 205, 90),
playhead: Color32::from_rgb(210, 48, 42),
insertion: Color32::from_rgb(35, 36, 40),
playback: Color32::from_rgb(110, 113, 119),
solo: Color32::from_rgb(178, 145, 20),
mute: Color32::from_rgb(194, 106, 22),
rec: Color32::from_rgb(190, 42, 38),
+169
View File
@@ -424,6 +424,175 @@ pub fn db_text(db: f32) -> String {
if db <= -143.9 { "-inf".into() } else { format!("{db:.1}") }
}
/// Where a [`fine_slider`] drag is anchored: the pointer's x and the value there, whether the key
/// was held at the anchor, whether the drag moves the value from the anchor and not to the
/// pointer, and the pass that last drew the slider during the drag.
#[derive(Clone, Copy)]
struct FineDrag {
x: f32,
value: f64,
fine: bool,
relative: bool,
pass: u64,
}
/// A horizontal slider that moves a tenth as far for the same drag while Ctrl (Command on a Mac)
/// is held, as a [`fader`] does, on the rail and on the value box. Pressing or releasing the key
/// during a drag on the rail or the value box leaves the value where it is, and the drag goes on
/// from there. The value box shows the decimal places that a tenth of a point's step needs, and a
/// drag on the rail that the key has touched rounds the value to them.
pub fn fine_slider(ui: &mut Ui, value: &mut f64, range: std::ops::RangeInclusive<f64>, text: &str) -> Response {
FineSlider::new(value, range).text(text).show(ui)
}
/// A [`fine_slider`] with the options of `egui::Slider` that plug-in parameters use.
pub struct FineSlider<'a> {
value: &'a mut f64,
range: std::ops::RangeInclusive<f64>,
text: &'a str,
id_salt: Option<&'a str>,
show_value: bool,
logarithmic: bool,
integer: bool,
default: Option<f64>,
}
impl<'a> FineSlider<'a> {
pub fn new(value: &'a mut f64, range: std::ops::RangeInclusive<f64>) -> Self {
Self { value, range, text: "", id_salt: None, show_value: true, logarithmic: false, integer: false, default: None }
}
/// The value that an Alt-click (Option-click on a Mac) on the slider or its value box sets,
/// as a [`fader`] returns to 0 dB. While Alt is held, a drag leaves the value where it is, and
/// an Alt-click on the value box does not open it for typing.
pub fn default_value(mut self, default: f64) -> Self {
self.default = Some(default);
self
}
/// The label after the slider. It also tells sliders in the same `Ui` apart unless
/// [`Self::id_salt`] is set.
pub fn text(mut self, text: &'a str) -> Self {
self.text = text;
self
}
/// Tells sliders in the same `Ui` apart when they have no label.
pub fn id_salt(mut self, salt: &'a str) -> Self {
self.id_salt = Some(salt);
self
}
/// Whether the value box is shown. Without it, a drag that the key has touched rounds the
/// value only on a whole-number slider.
pub fn show_value(mut self, show: bool) -> Self {
self.show_value = show;
self
}
/// A logarithmic rail, as `egui::Slider::logarithmic`, for a range above zero. A range that
/// reaches zero or below stays linear.
pub fn logarithmic(mut self, logarithmic: bool) -> Self {
self.logarithmic = logarithmic;
self
}
/// Whole numbers only, as `egui::Slider::integer`.
pub fn integer(mut self) -> Self {
self.integer = true;
self
}
pub fn show(self, ui: &mut Ui) -> Response {
let Self { value, range, text, id_salt, show_value, logarithmic, integer, default } = self;
let id = ui.scope_id().with(("fine_slider", id_salt.unwrap_or(text)));
let before = *value;
let (fine, x) = ui.input(|i| (i.modifiers.command || i.modifiers.ctrl, i.pointer.interact_pos().map(|p| p.x)));
let reset = default.is_some() && ui.input(|i| i.modifiers.alt);
// A drag lasts through the pass that releases the button, where egui still sets the value
// from the pointer. An anchor that the previous pass did not store was left by an earlier
// drag, and is ignored.
let pass = ui.ctx().cumulative_pass_nr();
let mut drag = ui.data(|d| d.get_temp::<FineDrag>(id)).filter(|d| d.pass + 1 == pass);
let (lo, hi) = (*range.start(), *range.end());
let log = logarithmic && lo > 0.0 && hi > lo;
// The handle's travel along the rail, as `egui::Slider` lays it out.
let thickness = ui.text_style_height(&egui::TextStyle::Body).max(ui.spacing().interact_size.y);
let travel = f64::from((ui.spacing().slider_width - 2.0 * thickness / 2.5).max(1.0));
// Where a value sits along the rail (0 to 1), and back, as `egui::Slider` maps them.
let span = if log { (hi / lo).ln() } else { hi - lo };
let along = |v: f64| if span > 0.0 { if log { (v / lo).ln() / span } else { (v - lo) / span } } else { 0.0 };
let at = |n: f64| {
let n = n.clamp(0.0, 1.0);
if log { lo * (n * span).exp() } else { lo + n * span }
};
// How far the value moves for one point of the rail at `v`.
let per_point = |v: f64| if log { v * span / travel } else { span / travel };
let scale = if fine { 0.1 } else { 1.0 };
let decimals = if integer { 0 } else { (-(per_point(lo) * 0.1).log10()).ceil().clamp(0.0, 6.0) as usize };
if let (Some(d), Some(x)) = (&mut drag, x) {
if d.fine != fine || reset {
*d = FineDrag { x, value: *value, fine, relative: true, pass };
} else if d.relative {
let moved = at(along(d.value) + f64::from(x - d.x) / travel * scale);
let moved = if show_value || integer { egui::emath::round_to_decimals(moved, decimals) } else { moved };
*value = moved.clamp(lo, hi);
}
}
let hold = reset || drag.is_some_and(|d| d.relative);
let speed = per_point(*value) * scale;
let mut slider = egui::Slider::from_get_set(range, |v| {
if let Some(v) = v
&& !hold
{
*value = v;
}
*value
})
.show_value(show_value)
.logarithmic(log)
.min_decimals(decimals)
.text(text);
if integer {
slider = slider.integer();
}
if fine {
slider = slider.drag_value_speed(speed);
}
let mut resp = ui.add(slider);
// A drag from the anchor set the value before the slider read it.
if *value != before {
resp.mark_changed();
}
// The rail senses drags only, so a click on it ends as a drag that the pointer did not
// move far enough to count as one.
let clicked = resp.clicked() || (resp.drag_stopped() && ui.input(|i| i.pointer.could_any_button_be_click()));
if let Some(d) = default
&& reset
&& clicked
{
let d = if integer { d.round() } else { d };
*value = d.clamp(lo, hi);
resp.surrender_focus();
resp.mark_changed();
}
let rail = Rect::from_min_size(resp.rect.min, vec2(ui.spacing().slider_width, resp.rect.height()));
let on_rail = resp.dragged() && ui.input(|i| i.pointer.press_origin()).is_some_and(|p| rail.contains(p));
let next = match (on_rail, drag, x) {
(true, Some(d), _) => Some(FineDrag { pass, ..d }),
(true, None, Some(x)) => Some(FineDrag { x, value: *value, fine, relative: fine, pass }),
_ => None,
};
ui.data_mut(|m| match next {
Some(d) => {
m.insert_temp(id, d);
}
None => m.remove::<FineDrag>(id),
});
resp
}
}
/// A speaker dot on the surround panner: label, square position (-1..1, y up = front), height layer.
pub struct PannerSpeaker {
pub label: &'static str,
+21
View File
@@ -39,6 +39,27 @@ fn bounce_from_dialog(h: &mut Harness<'_, SoundApp>, file: &Path) {
assert!(h.query_by_label("Bounce Mix").is_none());
}
#[test]
fn enter_on_a_focused_cancel_closes_the_dialog_and_starts_no_bounce() {
let dir = scratch("bounce-enter-cancel");
let file = dir.join("Mix.wav");
let mut h = harness();
let app = h.state_mut();
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
if let Some(Dialog::Bounce { path, .. }) = &mut app.dialogs.open {
*path = file.to_string_lossy().into_owned();
}
h.run_steps(2);
h.get_by_label("Cancel").focus();
h.run_steps(2);
h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
h.run_steps(3);
assert!(h.state().dialogs.open.is_none());
assert!(h.state().bounce.is_none(), "Cancel started a bounce");
assert!(!file.exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_bounce_renders_across_frames_with_progress_until_its_file_is_written() {
let dir = scratch("bounce-frames");
+17
View File
@@ -54,3 +54,20 @@ fn bounce_of_a_session_with_a_relative_path_starts_in_the_default_folder() {
other => panic!("{other:?}"),
}
}
/// A picked name that already says the format is written as picked: `Mix.aifc` is not turned
/// into `Mix.aif`, a file the save dialog never asked about.
#[test]
fn bounce_keeps_an_extension_that_names_the_format() {
use soundcraft_ui_egui::dialogs::bounce_path;
for (path, format, written) in [
("/Mixes/Mix.aifc", EncodeFormat::Aiff, "/Mixes/Mix.aifc"),
("/Mixes/Mix.AIFF", EncodeFormat::Aiff, "/Mixes/Mix.AIFF"),
("/Mixes/Mix.bwf", EncodeFormat::Wav, "/Mixes/Mix.bwf"),
("/Mixes/Mix.aifc", EncodeFormat::Flac, "/Mixes/Mix.flac"),
("/Mixes/Mix.wav", EncodeFormat::Aiff, "/Mixes/Mix.aif"),
("/Mixes/Mix", EncodeFormat::Wav, "/Mixes/Mix.wav"),
] {
assert_eq!(bounce_path(path, format), PathBuf::from(written), "{path} as {format:?}");
}
}
+34
View File
@@ -146,6 +146,40 @@ fn spot_drags_ask_where_before_moving() {
assert!((starts(&app, &ids)[0] - 2 * SEC).abs() < 4000);
}
#[test]
fn a_spot_dialog_that_replaces_an_open_dialog_ignores_the_enter_of_its_first_frame() {
let (mut app, ctx, t, ids) = app("spot", "grabber", &[(SEC, SEC)]);
assert!(app.dialogs.open_for_command(&app.engine, "setup.session"));
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
let (from, to) = (point(&app, t, SEC * 3 / 2), point(&app, t, SEC * 5 / 2));
frame(&ctx, &mut app, vec![Event::PointerMoved(from)]);
frame(&ctx, &mut app, vec![button(from, true)]);
let started = from + vec2(7.0, 0.0);
for k in 0..=5 {
frame(&ctx, &mut app, vec![Event::PointerMoved(started + (to - started) * (k as f32 / 5.0))]);
}
frame(&ctx, &mut app, vec![button(to, false), key_event(Key::Enter, true, Modifiers::NONE)]);
assert_eq!(app.dialogs.open_name(), Some("spot"), "the Enter of the Spot dialog's first frame leaves it open");
assert_eq!(starts(&app, &ids), vec![SEC]);
}
#[test]
fn a_spot_dialog_that_a_click_opens_over_an_open_dialog_ignores_the_enter_of_its_first_frame() {
for (tool, at) in [("grabber", SEC * 3 / 2), ("trim", SEC + SEC / 8)] {
let (mut app, ctx, t, ids) = app("spot", tool, &[(SEC, SEC)]);
assert!(app.dialogs.open_for_command(&app.engine, "setup.session"));
frame(&ctx, &mut app, vec![]);
frame(&ctx, &mut app, vec![]);
let at = point(&app, t, at);
frame(&ctx, &mut app, vec![Event::PointerMoved(at)]);
frame(&ctx, &mut app, vec![button(at, true)]);
frame(&ctx, &mut app, vec![button(at, false), key_event(Key::Enter, true, Modifiers::NONE)]);
assert_eq!(app.dialogs.open_name(), Some("spot"), "{tool}: the Enter of the Spot dialog's first frame leaves it open");
assert_eq!(starts(&app, &ids), vec![SEC], "{tool}");
}
}
#[test]
fn slip_trims_stop_at_the_next_clip() {
let (mut app, ctx, t, ids) = app("slip", "trim", &[(0, SEC), (2 * SEC, SEC)]);
@@ -0,0 +1,298 @@
//! Enter on a focused dialog button, checkbox, radio button or combo box acts on that widget and does not press the
//! dialog's default button.
use std::path::PathBuf;
use egui::accesskit::Role;
use egui::epaint::ClippedShape;
use egui::{Event, Key, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_audio_io::FileFormat;
use soundcraft_model::GroupId;
use soundcraft_ui_egui::dialogs::{Dialog, ask_to_save};
use soundcraft_ui_egui::{Services, SoundApp, menus};
fn harness(app: SoundApp) -> Harness<'static, SoundApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
/// Gives the button the keyboard focus, then presses Enter.
fn enter_on(h: &mut Harness<'_, SoundApp>, button: &str) {
enter_on_widget(h, Role::Button, button);
}
/// Gives the widget the keyboard focus, then presses Enter.
fn enter_on_widget(h: &mut Harness<'_, SoundApp>, role: Role, label: &str) {
h.get_by_role_and_label(role, label).focus();
h.run_steps(2);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(1);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
}
#[test]
fn enter_on_a_focused_cancel_closes_the_dialog_without_its_action() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let groups = app.engine.session().groups.len();
menus::invoke_menu(&mut app, "track.group", "Track > Group...");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("group"));
enter_on(&mut h, "Cancel");
assert_eq!(h.state().dialogs.open_name(), None);
assert_eq!(h.state().engine.session().groups.len(), groups);
}
#[test]
fn enter_on_a_focused_dont_save_closes_without_saving() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
menus::invoke_menu(&mut app, "session.close", "File > Close Session");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
enter_on(&mut h, "Don't Save");
assert_eq!(h.state().dialogs.open_name(), None, "no Save As prompt");
assert!(h.state().engine.session().tracks.is_empty());
}
#[test]
fn enter_on_a_focused_browse_browses_without_pressing_ok() {
let picked = "picked/Song.scraft";
let services = Services { pick_open: Some(Box::new(move |_, _| Some(PathBuf::from(picked)))), ..Services::default() };
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);
menus::invoke_menu(&mut app, "session.open", "File > Open Session...");
let mut h = harness(app);
enter_on(&mut h, "Browse…");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::PathPrompt { path, .. }) if path == picked));
assert!(h.state().engine.session().track_by_name("Kick").is_some(), "the demo session is still open");
}
#[test]
fn enter_on_a_focused_browse_in_bounce_mix_does_not_bounce() {
let picked = "picked/Mix.flac";
let services = Services { pick_save: Some(Box::new(move |_, _| Some(PathBuf::from(picked)))), ..Services::default() };
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, services);
menus::invoke_menu(&mut app, "file.bounce_mix", "File > Bounce Mix...");
let mut h = harness(app);
enter_on(&mut h, "Browse…");
assert!(h.state().bounce.is_none(), "Enter on Browse started a bounce");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::Bounce { path, format, .. }) if path == picked && *format == FileFormat::Flac));
}
#[test]
fn enter_on_a_focused_default_button_runs_its_action_once() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let tracks = app.engine.session().tracks.len();
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
let mut h = harness(app);
enter_on(&mut h, "Create");
assert_eq!(h.state().dialogs.open_name(), None);
assert_eq!(h.state().engine.session().tracks.len(), tracks + 1);
}
#[test]
fn enter_on_a_focused_checkbox_toggles_it_and_keeps_the_dialog_open() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "file.score_setup", "File > Score Setup...");
let mut h = harness(app);
let Some(Dialog::ScoreSetup { show_track_names: before, .. }) = h.state().dialogs.open.clone() else { panic!("Score Setup did not open") };
enter_on_widget(&mut h, Role::CheckBox, "Show track names");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::ScoreSetup { show_track_names, .. }) if *show_track_names != before));
}
#[test]
fn enter_on_a_focused_radio_button_picks_it_and_keeps_the_dialog_open() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("fades"));
enter_on_widget(&mut h, Role::RadioButton, "Linear");
assert!(matches!(&h.state().dialogs.open, Some(Dialog::Fades { shape }) if shape == "linear"));
}
#[test]
fn enter_with_nothing_focused_presses_the_default_button() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("fades"));
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
}
#[test]
fn a_dialog_opened_while_a_window_button_has_the_focus_takes_enter() {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let mut h = harness(app);
h.get_all_by_role(Role::Button).next().expect("the window has a button").focus();
h.run_steps(2);
assert!(h.ctx.memory(|m| m.focused().is_some()));
menus::invoke_menu(h.state_mut(), "edit.fades_create", "Edit > Fades > Create...");
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), Some("fades"));
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None, "Enter pressed Create");
}
#[test]
fn the_enter_that_opens_a_dialog_does_not_press_its_default_button() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
run_frame(&ctx, &mut app, vec![]);
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the dialog open");
run_frame(&ctx, &mut app, vec![enter_key(false)]);
assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create");
}
/// Runs one frame of `app` with `events` and returns what it painted.
fn run_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<Event>) -> Vec<ClippedShape> {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
out.shapes
}
fn enter_key(repeat: bool) -> Event {
Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat, modifiers: Modifiers::NONE }
}
fn enter_up() -> Event {
Event::Key { key: Key::Enter, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }
}
#[test]
fn the_enter_that_replaces_an_open_dialog_does_not_press_the_new_default_button() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
run_frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("session"));
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("fades"), "the opening Enter leaves the new dialog open");
run_frame(&ctx, &mut app, vec![enter_key(false)]);
assert_eq!(app.dialogs.open_name(), None, "the next Enter presses Create");
}
#[test]
fn a_rename_dialog_that_replaces_an_open_dialog_ignores_the_enter_of_its_first_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
run_frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("session"));
let id = app.engine.session().tracks[0].id;
app.dialogs.open_rename_track(id, "Renamed");
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("rename_track"), "the first frame's Enter leaves the new dialog open");
}
/// The first Kick clip of `app`.
fn kick_clip(app: &SoundApp) -> soundcraft_model::ClipId {
app.engine.session().track_by_name("Kick").unwrap().clips()[0].id
}
#[test]
fn clear_auto_rename_modify_group_and_save_changes_replacing_a_dialog_ignore_the_enter_of_their_first_frame() {
type Open = fn(&mut SoundApp);
let cases: [(&str, Open); 4] = [
("clear_clips", |app| {
let clip = kick_clip(app);
assert!(app.dialogs.open_clear(&app.engine, vec![clip], vec![]));
}),
("auto_rename", |app| {
let clip = kick_clip(app);
assert!(app.dialogs.open_auto_rename(&app.engine, vec![clip]));
}),
("modify_group", |app| {
let track = app.engine.session().tracks[0].id.0;
let group = app.run("track.group", json!({"tracks": [track], "name": "Drums"})).unwrap()["group"].as_u64().unwrap();
assert!(app.dialogs.open_modify_group(&app.engine, GroupId(group)));
}),
("save_changes", |app| {
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
assert!(ask_to_save(app, "session.close", &json!({})));
}),
];
for (name, open) in cases {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
run_frame(&ctx, &mut app, vec![]);
open(&mut app);
run_frame(&ctx, &mut app, vec![enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some(name), "the first frame's Enter leaves the new dialog open");
}
}
#[test]
fn a_tempo_change_dialog_that_replaces_an_open_dialog_ignores_the_enter_of_its_first_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
run_frame(&ctx, &mut app, vec![]);
let shapes = run_frame(&ctx, &mut app, vec![]);
assert_eq!(app.dialogs.open_name(), Some("session"));
let [left, ..] = app.edit_layout.timeline;
let y = shapes
.iter()
.find_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == "Tempo" && t.pos.x < left => Some(t.galley.rect.translate(t.pos.to_vec2()).center().y),
_ => None,
})
.expect("the Tempo ruler's label");
let at = pos2(left + 50.0, y);
let button = |pressed| Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for pressed in [true, false, true] {
run_frame(&ctx, &mut app, vec![Event::PointerMoved(at), button(pressed)]);
}
run_frame(&ctx, &mut app, vec![button(false), enter_key(false), enter_up()]);
assert_eq!(app.dialogs.open_name(), Some("tempo_change"), "the first frame's Enter leaves the new dialog open");
}
#[test]
fn a_held_enter_does_not_press_the_default_button_until_it_is_pressed_again() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
run_frame(&ctx, &mut app, vec![]);
menus::invoke_menu(&mut app, "edit.fades_create", "Edit > Fades > Create...");
run_frame(&ctx, &mut app, vec![enter_key(false)]);
run_frame(&ctx, &mut app, vec![enter_key(true)]);
run_frame(&ctx, &mut app, vec![enter_key(true)]);
assert_eq!(app.dialogs.open_name(), Some("fades"), "the held Enter's repeats leave the dialog open");
run_frame(&ctx, &mut app, vec![enter_up()]);
run_frame(&ctx, &mut app, vec![enter_key(false)]);
assert_eq!(app.dialogs.open_name(), None, "a new press of Enter presses Create");
}
#[test]
fn enter_on_a_focused_combo_box_opens_it_and_keeps_the_dialog_open() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
menus::invoke_menu(&mut app, "setup.session", "Setup > Session");
let mut h = harness(app);
assert_eq!(h.state().dialogs.open_name(), Some("session"));
assert!(h.query_by_label("47.952").is_none());
enter_on_widget(&mut h, Role::ComboBox, "Timecode Rate");
assert_eq!(h.state().dialogs.open_name(), Some("session"));
assert!(h.query_by_label("47.952").is_some(), "the Timecode Rate list is open");
}
+41
View File
@@ -0,0 +1,41 @@
//! Import Audio's Browse button lets the user select several files at once (#1089).
use egui::vec2;
use egui_kittest::{Harness, kittest::Queryable};
use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog};
use std::path::PathBuf;
fn browse(picked: Vec<PathBuf>) -> Harness<'static, SoundApp> {
let pick_open_many: Box<dyn Fn(&str) -> Vec<PathBuf>> = Box::new(move |purpose| {
assert_eq!(purpose, "file.import_audio");
picked.clone()
});
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services { pick_open_many: Some(pick_open_many), ..Default::default() });
assert!(app.dialogs.open_for_command(&app.engine, "file.import_audio"));
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(3);
h.get_by_label("Browse…").click();
h.run_steps(1);
h
}
#[test]
fn browse_with_several_files_imports_them_and_closes_the_dialog() {
let h = browse(vec!["/nope/a.wav".into(), "/nope/b.wav".into()]);
assert!(h.state().dialogs.open.is_none());
assert!(h.state().imports.busy() || !h.state().ui.status.is_empty(), "the files were queued");
}
#[test]
fn browse_with_one_file_fills_the_path_field() {
let h = browse(vec!["/nope/a.wav".into()]);
match &h.state().dialogs.open {
Some(Dialog::PathPrompt { path, .. }) => assert_eq!(path, "/nope/a.wav"),
other => panic!("{other:?}"),
}
}
+5
View File
@@ -18,9 +18,11 @@ mod docking_workflows;
mod edit_menu;
mod edit_modes;
mod edit_window_headers;
mod focused_dialog_buttons;
mod group_dialog;
mod hide_floating;
mod idle;
mod import_browse;
mod key_families;
mod long_menu_scroll;
mod marker_rename;
@@ -35,6 +37,8 @@ mod mix_separate;
mod new_midi_clip;
mod new_tracks;
mod open_on_insert;
mod plugin_default_reset;
mod plugin_fine_sliders;
mod plugin_window;
mod pro_tools_keys;
mod record_hint;
@@ -52,6 +56,7 @@ mod side_lists_text;
mod strip_silence_dialog;
mod strip_silence_rectangles;
mod strip_silence_rename;
mod strip_silence_sliders;
mod sub_counter;
mod toolbar_scroll;
mod toolbar_tempo;
@@ -0,0 +1,347 @@
use egui::accesskit::Role;
use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_dsp::ParamInfo;
use soundcraft_model::TrackId;
use soundcraft_ui_egui::{Services, SoundApp};
use std::collections::BTreeMap;
fn harness(app: SoundApp) -> Harness<'static, SoundApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.run_steps(1);
}
fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
}
/// Presses at `at` with Alt held, moves `dx` points right in six steps, and releases; `dx` of 0
/// is an Alt-click.
fn alt_press(h: &mut Harness<'_, SoundApp>, at: Pos2, dx: f32) {
hold(h, Modifiers::ALT);
move_to(h, at);
press(h, at, true, Modifiers::ALT);
for i in 1..=6 {
if dx != 0.0 {
move_to(h, at + vec2(dx * i as f32 / 6.0, 0.0));
}
}
press(h, at + vec2(dx, 0.0), false, Modifiers::ALT);
hold(h, Modifiers::NONE);
h.run_steps(2);
}
/// A point near the left end of `rail`, where a plain click would set a value far from the
/// parameter's default.
fn near_left(rail: Rect) -> Pos2 {
rail.left_center() + vec2(3.0, 0.0)
}
/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom.
fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec<Rect> {
let area = h.ctx.memory(|m| m.area_rect(window)).unwrap();
let mut rails: Vec<Rect> = h.query_all_by_role(Role::Slider).map(|n| n.rect()).filter(|r| area.contains_rect(*r)).collect();
rails.sort_by(|a, b| a.top().total_cmp(&b.top()));
rails
}
fn param(plugin: &str, id: &str) -> &'static ParamInfo {
soundcraft_dsp::plugin_info(plugin).unwrap().params.iter().find(|p| p.id == id).unwrap()
}
fn insert_param(h: &Harness<'_, SoundApp>, track: TrackId, slot: usize, p: &ParamInfo) -> f32 {
let tr = h.state().engine.session().track(track).unwrap();
let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { tr.instrument.as_ref() } else { tr.mixer.inserts[slot].as_ref() };
ins.unwrap().params.get(p.id).copied().unwrap_or(p.default)
}
/// The center of the value box on the same row as `rail`, to the rail's right.
fn value_box(h: &Harness<'_, SoundApp>, rail: Rect) -> Pos2 {
h.query_all_by_role(Role::SpinButton)
.map(|n| n.rect())
.find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0)
.unwrap()
.center()
}
/// A plain click at `at`, which sets a value from the pointer.
fn plain_click(h: &mut Harness<'_, SoundApp>, at: Pos2) {
move_to(h, at);
press(h, at, true, Modifiers::NONE);
press(h, at, false, Modifiers::NONE);
}
fn undo_steps(h: &Harness<'_, SoundApp>) -> usize {
h.state().engine.undo_history().len()
}
/// Kick's EQ 7-Band window, with Input at 6 dB and HPF Slope on its last choice.
fn kick_eq() -> (Harness<'static, SoundApp>, TrackId) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
let slope = param("eq_7band", "hpf_slope");
app.run("mix.insert_param", json!({"track": kick.0, "slot": 0, "param": "input_gain", "value": 6.0})).unwrap();
app.run("mix.insert_param", json!({"track": kick.0, "slot": 0, "param": "hpf_slope", "value": slope.max})).unwrap();
app.engine.end_merge();
app.ui.plugin_windows = vec![(kick, 0)];
(harness(app), kick)
}
#[test]
fn alt_click_returns_a_plugin_window_slider_to_its_default_in_one_undo_step() {
let (mut h, kick) = kick_eq();
let input = param("eq_7band", "input_gain");
assert_ne!(input.default, 6.0);
let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0];
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(insert_param(&h, kick, 0, input), input.default, "Alt-click did not return Input to its default");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(insert_param(&h, kick, 0, input), 6.0, "Undo did not bring Input back");
}
#[test]
fn alt_drag_leaves_a_plugin_window_slider_where_it_is() {
let (mut h, kick) = kick_eq();
let input = param("eq_7band", "input_gain");
let rail = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0];
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 40.0);
assert_eq!(insert_param(&h, kick, 0, input), 6.0, "an Alt-drag moved Input");
assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step");
}
#[test]
fn alt_click_returns_a_plugin_window_choice_to_its_default_without_opening_the_list() {
let (mut h, kick) = kick_eq();
let slope = param("eq_7band", "hpf_slope");
assert_ne!(slope.default, slope.max);
let row = h.get_by_label("HPF Slope").rect();
let combo = h.query_all_by_role(Role::ComboBox).map(|n| n.rect()).find(|r| (r.center().y - row.center().y).abs() < 4.0).unwrap();
let steps = undo_steps(&h);
alt_press(&mut h, combo.center(), 0.0);
assert_eq!(insert_param(&h, kick, 0, slope), slope.default, "Alt-click did not return HPF Slope to its default");
assert!(!egui::Popup::is_any_open(&h.ctx), "Alt-click opened the list");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(insert_param(&h, kick, 0, slope), slope.max, "Undo did not bring HPF Slope back");
}
#[test]
fn alt_pressed_during_a_drag_holds_the_value_and_the_drag_goes_on_from_there() {
let (mut h, kick) = kick_eq();
let input = param("eq_7band", "input_gain");
let from = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)))[0].center();
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::NONE);
let pressed = insert_param(&h, kick, 0, input);
move_to(&mut h, from + vec2(10.0, 0.0));
let before = insert_param(&h, kick, 0, input);
assert!(before > pressed, "a plain drag did not move Input");
hold(&mut h, Modifiers::ALT);
assert_eq!(insert_param(&h, kick, 0, input), before, "pressing Alt moved Input");
for i in 1..=6 {
move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
}
assert_eq!(insert_param(&h, kick, 0, input), before, "a drag with Alt held moved Input");
hold(&mut h, Modifiers::NONE);
assert_eq!(insert_param(&h, kick, 0, input), before, "releasing Alt moved Input to the pointer");
move_to(&mut h, from + vec2(50.0, 0.0));
let moved = insert_param(&h, kick, 0, input) - before;
press(&mut h, from + vec2(50.0, 0.0), false, Modifiers::NONE);
let ratio = moved / (before - pressed);
assert!((0.8..=1.2).contains(&ratio), "10 points after Alt moved Input {moved}, 10 points before it {}", before - pressed);
}
/// Kick's compressor window, with Attack away from its default.
fn kick_compressor_attack() -> (Harness<'static, SoundApp>, TrackId, f32) {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
let attack = param("compressor", "attack");
let away = (attack.default * 2.0).min(attack.max);
assert_ne!(away, attack.default);
assert!(attack.taper == soundcraft_dsp::Taper::Log, "Attack is no longer a log-taper parameter");
app.run("mix.insert_param", json!({"track": kick.0, "slot": 1, "param": "attack", "value": away})).unwrap();
app.engine.end_merge();
app.ui.plugin_windows = vec![(kick, 1)];
(harness(app), kick, away)
}
fn compressor_attack_rail(h: &Harness<'_, SoundApp>, kick: TrackId) -> Rect {
let params = soundcraft_dsp::plugin_info("compressor").unwrap().params;
let index = params.iter().filter(|p| p.choices.is_empty()).position(|p| p.id == "attack").unwrap();
window_sliders(h, Id::unique(("plugin", kick.0, 1usize)))[index]
}
#[test]
fn alt_click_returns_a_log_taper_slider_to_its_default() {
let (mut h, kick, _) = kick_compressor_attack();
let attack = param("compressor", "attack");
let rail = compressor_attack_rail(&h, kick);
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(insert_param(&h, kick, 1, attack), attack.default, "Alt-click did not return Attack to its default");
}
#[test]
fn alt_drag_leaves_a_log_taper_slider_where_it_is() {
let (mut h, kick, away) = kick_compressor_attack();
let attack = param("compressor", "attack");
let rail = compressor_attack_rail(&h, kick);
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 40.0);
assert_eq!(insert_param(&h, kick, 1, attack), away, "an Alt-drag moved Attack");
assert_eq!(undo_steps(&h), steps, "an Alt-drag added an undo step");
}
#[test]
fn alt_click_returns_a_whole_number_slider_to_its_default() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
app.run("mix.instrument_param", json!({"track": keys.0, "param": "root_note", "value": 72.0})).unwrap();
app.engine.end_merge();
app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];
let mut h = harness(app);
let root = param("sampler", "root_note");
assert_ne!(root.default, 72.0);
let rail = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)))[0];
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 0.0);
let v = insert_param(&h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);
assert_eq!(v, root.default, "Alt-click did not return Root Note to its default");
assert_eq!(v.fract(), 0.0, "Root Note is not a whole number after an Alt-click");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click is not one undo step");
}
#[test]
fn alt_click_returns_clip_effects_sliders_and_value_boxes_to_their_defaults() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"eq.input_gain": 6.0, "comp.threshold": -6.0}})).unwrap();
app.engine.end_merge();
app.ui.show_clip_effects = true;
let mut h = harness(app);
for (label, key, p, on_box) in
[("Input", "eq.input_gain", param("eq_7band", "input_gain"), false), ("Threshold", "comp.threshold", param("compressor", "threshold"), true)]
{
let key = format!("clip_fx.{}.{key}", clip.0);
let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied();
assert_ne!(read(&h), Some(f64::from(p.default)));
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let at = if on_box { value_box(&h, rail) } else { near_left(rail) };
// A plain click on the value box gives it the keyboard focus for typing.
let focus = h.ctx.memory(|m| m.focused());
let steps = undo_steps(&h);
alt_press(&mut h, at, 0.0);
assert_eq!(read(&h), Some(f64::from(p.default)), "Alt-click did not return {label} to its default");
assert_eq!(undo_steps(&h), steps + 1, "Alt-click on {label} is not one undo step");
assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened {label}'s value box for typing");
}
}
#[test]
fn fallback_audiosuite_sliders_reset_with_alt_click() {
for (process, label, id, default) in [
("normalize", "Peak dB", "target_db", -0.1f32),
("time_stretch", "Length ratio", "ratio", 1.0),
("pitch_shift", "Semitones", "semitones", 0.0),
] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some(process.into());
let mut h = harness(app);
let read = |h: &Harness<'_, SoundApp>| -> BTreeMap<String, f32> {
h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.to_string())))).unwrap_or_default()
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
move_to(&mut h, near_left(rail));
press(&mut h, near_left(rail), true, Modifiers::NONE);
press(&mut h, near_left(rail), false, Modifiers::NONE);
assert_ne!(read(&h)[id], default, "{process}: plain click did not move the value");
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(read(&h)[id], default, "{process}: Alt-click did not restore the default");
plain_click(&mut h, near_left(rail));
assert_ne!(read(&h)[id], default, "{process}: plain click did not move the value");
let focus = h.ctx.memory(|m| m.focused());
let at = value_box(&h, rail);
alt_press(&mut h, at, 0.0);
assert_eq!(read(&h)[id], default, "{process}: Alt-click on the value box did not restore the default");
assert_eq!(h.ctx.memory(|m| m.focused()), focus, "{process}: Alt-click opened the value box for typing");
}
}
#[test]
fn alt_click_returns_audiosuite_parameter_sliders_to_their_defaults() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some("compressor".into());
let mut h = harness(app);
let read = |h: &Harness<'_, SoundApp>| -> BTreeMap<String, f32> {
h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default()
};
let threshold = param("compressor", "threshold");
let rail = h.get_by_role_and_label(Role::Slider, "Threshold").rect();
move_to(&mut h, near_left(rail));
press(&mut h, near_left(rail), true, Modifiers::NONE);
press(&mut h, near_left(rail), false, Modifiers::NONE);
assert_ne!(read(&h)["threshold"], threshold.default, "a plain click did not move Threshold");
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(read(&h)["threshold"], threshold.default, "Alt-click did not return Threshold to its default");
plain_click(&mut h, near_left(rail));
assert_ne!(read(&h)["threshold"], threshold.default, "a plain click did not move Threshold");
let focus = h.ctx.memory(|m| m.focused());
let at = value_box(&h, rail);
alt_press(&mut h, at, 0.0);
assert_eq!(read(&h)["threshold"], threshold.default, "Alt-click on the value box did not return Threshold to its default");
assert_eq!(h.ctx.memory(|m| m.focused()), focus, "Alt-click opened Threshold's value box for typing");
}
#[test]
fn clip_effects_gain_resets_with_alt_click_and_undo_restores_the_value() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.run("clip.effects_set", json!({"clips": [clip.0], "params": {"gain": 6.0}})).unwrap();
app.engine.end_merge();
app.ui.show_clip_effects = true;
let mut h = harness(app);
let key = format!("clip_fx.{}.gain", clip.0);
let input = h.get_by_role_and_label(Role::Slider, "Input").rect();
move_to(&mut h, input.center());
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -2000.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(30);
let rail = h.get_by_role_and_label(Role::Slider, "Clip effects gain (dB)").rect();
let steps = undo_steps(&h);
alt_press(&mut h, near_left(rail), 0.0);
assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&0.0));
assert_eq!(undo_steps(&h), steps + 1);
h.state_mut().engine.execute("edit.undo", &json!({})).unwrap();
assert_eq!(h.state().engine.session().edit.values.get(&key), Some(&6.0));
}
@@ -0,0 +1,359 @@
use egui::accesskit::Role;
use egui::{Event, Id, Modifiers, PointerButton, Pos2, Rect, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_dsp::ParamInfo;
use soundcraft_model::TrackId;
use soundcraft_ui_egui::{Services, SoundApp};
use std::collections::BTreeMap;
fn harness(app: SoundApp) -> Harness<'static, SoundApp> {
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn hold(h: &mut Harness<'_, SoundApp>, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.run_steps(1);
}
fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn move_to(h: &mut Harness<'_, SoundApp>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
}
/// Presses the middle of `rail` and drags `dx` points right in six steps with `modifiers` held,
/// calling `read` after each step; returns how far `read` moved from where the press put it.
fn drag(h: &mut Harness<'_, SoundApp>, rail: Rect, dx: f32, modifiers: Modifiers, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> f64 {
hold(h, modifiers);
let from = rail.center();
move_to(h, from);
press(h, from, true, modifiers);
let start = read(h);
for i in 1..=6 {
move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0));
read(h);
}
let moved = read(h) - start;
press(h, from + vec2(dx, 0.0), false, modifiers);
assert_eq!(read(h) - start, moved, "releasing the button moved the value");
hold(h, Modifiers::NONE);
moved
}
/// A plain 30-point drag and a Ctrl-drag of the same length on `rail`; returns how far each
/// moved `read`.
fn plain_and_fine(h: &mut Harness<'_, SoundApp>, rail: Rect, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) -> (f64, f64) {
let plain = drag(h, rail, 30.0, Modifiers::NONE, read);
let fine = drag(h, rail, 30.0, Modifiers::COMMAND, read);
(plain, fine)
}
fn assert_tenth(what: &str, (plain, fine): (f64, f64), within: std::ops::RangeInclusive<f64>) {
assert!(plain > 0.0, "{what}: a plain drag did not move the value");
assert!(within.contains(&(fine / plain)), "{what}: Ctrl-drag moved {fine}, a plain drag {plain}");
}
/// The value box on the same row as `rail`, to the rail's right.
fn value_box(h: &Harness<'_, SoundApp>, rail: Rect) -> Rect {
h.query_all_by_role(Role::SpinButton).map(|n| n.rect()).find(|r| r.left() > rail.right() && (r.center().y - rail.center().y).abs() < 4.0).unwrap()
}
/// The rails of the unlabeled sliders inside the window with id `window`, top to bottom.
fn window_sliders(h: &Harness<'_, SoundApp>, window: Id) -> Vec<Rect> {
let area = h.ctx.memory(|m| m.area_rect(window)).unwrap();
let mut rails: Vec<Rect> = h.query_all_by_role(Role::Slider).map(|n| n.rect()).filter(|r| area.contains_rect(*r)).collect();
rails.sort_by(|a, b| a.top().total_cmp(&b.top()));
rails
}
/// The plug-in's parameters that its window shows as sliders, in order.
fn slider_params(plugin: &str) -> Vec<&'static ParamInfo> {
soundcraft_dsp::plugin_info(plugin).unwrap().params.iter().filter(|p| p.choices.is_empty()).collect()
}
fn insert_param(h: &Harness<'_, SoundApp>, track: TrackId, slot: usize, p: &ParamInfo) -> f64 {
let tr = h.state().engine.session().track(track).unwrap();
let ins = if slot == soundcraft_mix::INSTRUMENT_SLOT { tr.instrument.as_ref() } else { tr.mixer.inserts[slot].as_ref() };
f64::from(ins.unwrap().params.get(p.id).copied().unwrap_or(p.default))
}
#[test]
fn plugin_window_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));
let params = slider_params("eq_7band");
assert_eq!((params[0].id, params[2].id), ("input_gain", "hpf_freq"));
for i in [0, 2] {
let p = params[i];
// Along the rail, so that a log-taper parameter compares as a linear one does.
let read = move |h: &Harness<'_, SoundApp>| f64::from(p.to_normalized(insert_param(h, kick, 0, p) as f32));
let others = |h: &Harness<'_, SoundApp>| params.iter().filter(|o| o.id != p.id).map(|o| insert_param(h, kick, 0, o)).collect::<Vec<_>>();
let before = others(&h);
assert_tenth(p.name, plain_and_fine(&mut h, rails[i], &read), 0.08..=0.12);
assert_eq!(others(&h), before, "dragging {} moved another parameter", p.name);
}
}
#[test]
fn the_control_key_alone_also_gives_a_fine_drag_on_a_plugin_slider() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));
let p = slider_params("eq_7band")[0];
let read = move |h: &Harness<'_, SoundApp>| insert_param(h, kick, 0, p);
let plain = drag(&mut h, rails[0], 30.0, Modifiers::NONE, &read);
let fine = drag(&mut h, rails[0], 30.0, Modifiers::CTRL, &read);
assert_tenth(p.name, (plain, fine), 0.08..=0.12);
}
/// Presses `rail`'s middle, drags 10 points, presses Ctrl, drags 30 more, releases Ctrl and then
/// the button. Each change of the key and the release leave `read` where it was, and the 30
/// points move it less than `plain` moved for the same distance.
fn toggle_ctrl_during_a_drag(h: &mut Harness<'_, SoundApp>, what: &str, rail: Rect, plain: f64, read: &dyn Fn(&Harness<'_, SoundApp>) -> f64) {
let from = rail.center();
move_to(h, from);
press(h, from, true, Modifiers::NONE);
move_to(h, from + vec2(10.0, 0.0));
let before = read(h);
hold(h, Modifiers::COMMAND);
assert_eq!(read(h), before, "{what}: pressing Ctrl moved the value");
for i in 1..=6 {
move_to(h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
read(h);
}
let held = read(h);
assert!(held > before && held - before < plain / 2.0, "{what}: 30 points with Ctrl moved {}, a plain drag {plain}", held - before);
hold(h, Modifiers::NONE);
assert_eq!(read(h), held, "{what}: releasing Ctrl moved the value");
press(h, from + vec2(40.0, 0.0), false, Modifiers::NONE);
assert_eq!(read(h), held, "{what}: releasing the button moved the value");
}
#[test]
fn pressing_and_releasing_ctrl_during_a_drag_keeps_the_value_of_a_logarithmic_slider() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 0usize)));
let p = slider_params("eq_7band")[2];
assert_eq!(p.id, "hpf_freq");
let read = move |h: &Harness<'_, SoundApp>| f64::from(p.to_normalized(insert_param(h, kick, 0, p) as f32));
let plain = drag(&mut h, rails[2], 30.0, Modifiers::NONE, &read);
toggle_ctrl_during_a_drag(&mut h, p.name, rails[2], plain, &read);
}
#[test]
fn pressing_and_releasing_ctrl_during_a_drag_keeps_a_whole_number_slider_whole() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)));
let root = slider_params("sampler")[0];
assert_eq!(root.id, "root_note");
let read = move |h: &Harness<'_, SoundApp>| {
let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);
assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag");
v
};
let plain = drag(&mut h, rails[0], 30.0, Modifiers::NONE, &read);
toggle_ctrl_during_a_drag(&mut h, root.name, rails[0], plain, &read);
}
#[test]
fn a_small_ctrl_drag_at_the_low_end_of_a_log_slider_moves_the_value() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 1)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", kick.0, 1usize)));
let attack = slider_params("compressor")[2];
assert_eq!(attack.id, "attack");
let from = rails[2].left_center() + vec2(1.0, 0.0);
hold(&mut h, Modifiers::COMMAND);
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::COMMAND);
let start = insert_param(&h, kick, 1, attack);
assert_eq!(start, f64::from(attack.min), "the press did not put Attack at its lowest value");
move_to(&mut h, from + vec2(1.0, 0.0));
let moved = insert_param(&h, kick, 1, attack);
press(&mut h, from + vec2(1.0, 0.0), false, Modifiers::COMMAND);
assert!(moved > start, "a one-point Ctrl-drag left Attack at {moved}");
assert_eq!(insert_param(&h, kick, 1, attack), moved, "releasing the button moved the value");
}
#[test]
fn a_small_ctrl_drag_at_the_low_end_of_a_log_slider_with_a_value_box_moves_the_value() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
let attack = slider_params("compressor")[2];
assert_eq!(attack.id, "attack");
let key = format!("clip_fx.{}.comp.attack", clip.0);
let from = h.get_by_role_and_label(Role::Slider, attack.name).rect().left_center() + vec2(1.0, 0.0);
hold(&mut h, Modifiers::COMMAND);
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::COMMAND);
let read = |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap();
let start = read(&h);
assert!((start - f64::from(attack.min)).abs() < 1e-6, "the press put Attack at {start}");
move_to(&mut h, from + vec2(1.0, 0.0));
let moved = read(&h);
press(&mut h, from + vec2(1.0, 0.0), false, Modifiers::COMMAND);
assert!(moved > start, "a one-point Ctrl-drag left Attack at {moved}");
}
#[test]
fn whole_number_instrument_sliders_move_a_tenth_as_far_and_stay_whole() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.run("mix.instrument", json!({"track": keys.0, "plugin": "sampler"})).unwrap();
app.ui.plugin_windows = vec![(keys, soundcraft_mix::INSTRUMENT_SLOT)];
let mut h = harness(app);
let rails = window_sliders(&h, Id::unique(("plugin", keys.0, soundcraft_mix::INSTRUMENT_SLOT)));
let root = slider_params("sampler")[0];
assert_eq!(root.id, "root_note");
// Each read during the drags checks that the instrument was sent a whole number.
let read = move |h: &Harness<'_, SoundApp>| {
let v = insert_param(h, keys, soundcraft_mix::INSTRUMENT_SLOT, root);
assert_eq!(v.fract(), 0.0, "Root Note is {v} during a drag");
v
};
assert_tenth(root.name, plain_and_fine(&mut h, rails[0], &read), 0.06..=0.14);
}
#[test]
fn clip_effects_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0), ("Clip effects gain (dB)", "gain", 0.0)] {
if key == "gain" {
let input = h.get_by_role_and_label(Role::Slider, "Input").rect();
move_to(&mut h, input.center());
h.event(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta: vec2(0.0, -2000.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Unknown,
});
h.run_steps(30);
}
let key = format!("clip_fx.{}.{key}", clip.0);
let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default);
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);
}
}
#[test]
fn clip_effects_value_boxes_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
for (label, key, default) in [("Input", "eq.input_gain", 0.0), ("Threshold", "comp.threshold", -20.0)] {
let key = format!("clip_fx.{}.{key}", clip.0);
let read = move |h: &Harness<'_, SoundApp>| h.state().engine.session().edit.values.get(&key).copied().unwrap_or(default);
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let boxed = value_box(&h, rail);
assert_tenth(label, plain_and_fine(&mut h, boxed, &read), 0.07..=0.13);
}
}
#[test]
fn clip_effects_log_taper_sliders_use_a_logarithmic_rail() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let s = app.engine.session();
let clip = s.track_by_name("Kick").unwrap().clips().iter().find(|c| c.is_audio()).unwrap().id;
app.run("edit.select", json!({"clips": [clip.0]})).unwrap();
app.ui.show_clip_effects = true;
let mut h = harness(app);
let ratio = slider_params("compressor")[1];
assert_eq!(ratio.id, "ratio");
let rail = h.get_by_role_and_label(Role::Slider, ratio.name).rect();
move_to(&mut h, rail.center());
press(&mut h, rail.center(), true, Modifiers::NONE);
press(&mut h, rail.center(), false, Modifiers::NONE);
let key = format!("clip_fx.{}.comp.ratio", clip.0);
let v = h.state().engine.session().edit.values.get(&key).copied().unwrap();
// The middle of a 1 to 100 log rail is 10; a linear rail puts it near 50.
assert!((7.0..=14.0).contains(&v), "a click in the middle of the Ratio rail set {v}");
}
#[test]
fn fallback_audiosuite_sliders_move_a_tenth_as_far_with_ctrl_held() {
for (process, label, id) in
[("normalize", "Peak dB", "target_db"), ("time_stretch", "Length ratio", "ratio"), ("pitch_shift", "Semitones", "semitones")]
{
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some(process.into());
let mut h = harness(app);
let read = |h: &Harness<'_, SoundApp>| {
let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", process.to_string())))).unwrap_or_default();
f64::from(params[id])
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);
}
}
#[test]
fn audiosuite_parameter_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some("compressor".into());
let mut h = harness(app);
for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] {
let read = move |h: &Harness<'_, SoundApp>| {
let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default();
f64::from(params[id])
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
assert_tenth(label, plain_and_fine(&mut h, rail, &read), 0.08..=0.12);
}
}
#[test]
fn audiosuite_value_boxes_move_a_tenth_as_far_with_ctrl_held() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.audiosuite = Some("compressor".into());
let mut h = harness(app);
for (label, id) in [("Threshold", "threshold"), ("Knee", "knee")] {
let read = move |h: &Harness<'_, SoundApp>| {
let params: BTreeMap<String, f32> = h.ctx.data(|d| d.get_temp(Id::unique(("as_params", "compressor".to_string())))).unwrap_or_default();
f64::from(params[id])
};
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let boxed = value_box(&h, rail);
assert_tenth(label, plain_and_fine(&mut h, boxed, &read), 0.07..=0.13);
}
}
+16
View File
@@ -96,6 +96,22 @@ fn save_in_the_question_saves_before_closing() {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn enter_on_a_focused_cancel_neither_saves_nor_closes() {
let mut h = harness();
let tracks = h.state().engine.session().tracks.len();
mute_kick(&mut h);
close_session(&mut h);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
h.get_by_label("Cancel").focus();
steps(&mut h);
h.event(egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE });
steps(&mut h);
assert!(h.state().dialogs.open.is_none(), "no Save As dialog follows");
assert_eq!(h.state().engine.session().tracks.len(), tracks);
assert!(h.state().engine.is_dirty());
}
/// A session the browser read: an empty one, so opening it is easy to see.
fn session_bytes() -> Vec<u8> {
soundcraft_engine::Engine::default().session().to_json().unwrap().into_bytes()
@@ -80,6 +80,45 @@ fn enter_on_a_focused_separate_separates_and_does_not_strip() {
assert_eq!(covered(&separated), covered(&before), "Enter on Separate keeps all the audio");
}
#[test]
fn enter_on_a_focused_cancel_closes_the_dialog_and_strips_nothing() {
let (mut h, track, before) = strip_silence_dialog();
h.get_by_role_and_label(Role::Button, "Cancel").focus();
h.run_steps(2);
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None);
assert_eq!(clips(&h, track), before, "Cancel leaves the clips as they were");
}
#[test]
fn enter_right_after_switching_between_strip_silence_and_rename_presses_the_new_default_button() {
let (mut h, _, _) = strip_silence_dialog();
let enter = |h: &mut Harness<'static, SoundApp>| {
for pressed in [true, false] {
h.event(Event::Key { key: Key::Enter, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
}
};
h.get_by_role_and_label(Role::Button, "Rename…").focus();
h.run_steps(2);
enter(&mut h);
h.run_steps(1);
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence_rename"));
enter(&mut h);
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence"), "the next Enter presses OK in Rename Selected Clips");
h.get_by_role_and_label(Role::Button, "Rename…").click();
h.run_steps(2);
h.get_by_role_and_label(Role::Button, "OK").focus();
h.run_steps(2);
enter(&mut h);
h.run_steps(1);
assert_eq!(h.state().dialogs.open_name(), Some("strip_silence"));
enter(&mut h);
h.run_steps(2);
assert_eq!(h.state().dialogs.open_name(), None, "the next Enter presses Strip in Strip Silence");
}
#[test]
fn strip_silence_strip_removes_the_silence() {
let (mut h, track, before) = strip_silence_dialog();
@@ -0,0 +1,223 @@
use egui::accesskit::Role;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp, menus, widgets};
const SLIDERS: [&str; 4] = ["Threshold dB", "Min strip duration ms", "Clip start pad ms", "Clip end pad ms"];
/// Selects the whole Kick track and opens Edit > Strip Silence from the menu.
fn strip_silence_dialog() -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let track = app.engine.session().track_by_name("Kick").unwrap().id;
let end = app.engine.session().content_end();
app.run("edit.select", json!({"tracks": [track.0], "start": 0, "end": end})).unwrap();
menus::invoke_menu(&mut app, "edit.strip_silence", "Edit > Strip Silence");
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
/// The value of the slider labeled `label`, in the order the window shows them.
fn value(h: &Harness<'_, SoundApp>, label: &str) -> f64 {
let Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) = h.state().dialogs.open else { panic!("Strip Silence is not open") };
let i = SLIDERS.iter().position(|s| *s == label).unwrap();
[threshold, min_ms, pre_ms, post_ms][i]
}
fn hold<S>(h: &mut Harness<'_, S>, modifiers: Modifiers) {
h.event(Event::ModifiersChanged(modifiers));
h.run_steps(1);
}
fn press<S>(h: &mut Harness<'_, S>, pos: Pos2, pressed: bool, modifiers: Modifiers) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers });
h.run_steps(1);
}
fn move_to<S>(h: &mut Harness<'_, S>, pos: Pos2) {
h.event(Event::PointerMoved(pos));
h.run_steps(1);
}
/// Presses the middle of a slider's rail (`role` `Slider`) or value box (`SpinButton`) and drags
/// `dx` points right in six steps, with `modifiers` held; returns how far the value moved from
/// where the press put it.
fn drag_on(h: &mut Harness<'_, SoundApp>, role: Role, label: &str, dx: f32, modifiers: Modifiers) -> f64 {
hold(h, modifiers);
let from = h.get_by_role_and_label(role, label).rect().center();
move_to(h, from);
press(h, from, true, modifiers);
let start = value(h, label);
for i in 1..=6 {
move_to(h, from + vec2(dx * i as f32 / 6.0, 0.0));
}
let moved = value(h, label) - start;
press(h, from + vec2(dx, 0.0), false, modifiers);
assert_eq!(value(h, label) - start, moved, "{label}: releasing the button moved the value");
hold(h, Modifiers::NONE);
moved
}
fn drag(h: &mut Harness<'_, SoundApp>, label: &str, dx: f32, modifiers: Modifiers) -> f64 {
drag_on(h, Role::Slider, label, dx, modifiers)
}
#[test]
fn strip_silence_sliders_move_a_tenth_as_far_with_ctrl_held() {
let mut h = strip_silence_dialog();
for label in SLIDERS {
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let fine = drag(&mut h, label, 30.0, Modifiers::COMMAND);
assert!(plain > 0.0, "{label}: a plain drag did not move the value");
let ratio = fine / plain;
assert!((0.08..=0.12).contains(&ratio), "{label}: Ctrl-drag moved {fine}, a plain drag {plain}");
}
}
#[test]
fn ctrl_dragging_the_value_box_moves_a_tenth_as_far_too() {
let mut h = strip_silence_dialog();
for label in ["Threshold dB", "Clip start pad ms"] {
let plain = drag_on(&mut h, Role::SpinButton, label, 20.0, Modifiers::NONE);
let fine = drag_on(&mut h, Role::SpinButton, label, 20.0, Modifiers::COMMAND);
assert!(plain > 0.0, "{label}: a plain drag on the value box did not move the value");
let ratio = fine / plain;
assert!((0.07..=0.13).contains(&ratio), "{label}: Ctrl-drag on the value box moved {fine}, a plain drag {plain}");
}
}
#[test]
fn ctrl_without_command_moves_a_tenth_as_far() {
let mut h = strip_silence_dialog();
for (role, dx, within) in [(Role::Slider, 30.0, 0.08..=0.12), (Role::SpinButton, 20.0, 0.07..=0.13)] {
let label = "Threshold dB";
let plain = drag_on(&mut h, role, label, dx, Modifiers::NONE);
let fine = drag_on(&mut h, role, label, dx, Modifiers::CTRL);
assert!(plain > 0.0, "{role:?}: a plain drag did not move the value");
assert!(within.contains(&(fine / plain)), "{role:?}: Ctrl-drag moved {fine}, a plain drag {plain}");
}
}
#[test]
fn alt_click_and_alt_drag_do_what_a_plain_click_and_drag_do() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let rail = h.get_by_role_and_label(Role::Slider, label).rect();
let near = rail.left_center() + vec2(3.0, 0.0);
let click = |h: &mut Harness<'static, SoundApp>, at: Pos2, modifiers: Modifiers| {
hold(h, modifiers);
move_to(h, at);
press(h, at, true, modifiers);
press(h, at, false, modifiers);
hold(h, Modifiers::NONE);
};
click(&mut h, near, Modifiers::NONE);
let clicked = value(&h, label);
click(&mut h, rail.center(), Modifiers::NONE);
assert_ne!(value(&h, label), clicked, "a click in the middle of the rail did not move the value");
click(&mut h, near, Modifiers::ALT);
assert_eq!(value(&h, label), clicked, "an Alt-click did not do what a plain click does");
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let alt = drag(&mut h, label, 30.0, Modifiers::ALT);
assert!(plain > 0.0, "a plain drag did not move the value");
assert_eq!(alt, plain, "an Alt-drag did not do what a plain drag does");
}
#[test]
fn the_value_box_shows_the_tenths_a_ctrl_drag_reaches() {
let mut h = strip_silence_dialog();
drag(&mut h, "Threshold dB", 7.0, Modifiers::COMMAND);
let v = value(&h, "Threshold dB");
let shown = h.get_by_role_and_label(Role::SpinButton, "Threshold dB").value().unwrap_or_default();
assert_eq!(shown, format!("{v:.1}"));
}
#[test]
fn pressing_and_releasing_ctrl_during_a_drag_keeps_the_value() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let from = h.get_by_role_and_label(Role::Slider, label).rect().center();
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::NONE);
move_to(&mut h, from + vec2(10.0, 0.0));
let before = value(&h, label);
hold(&mut h, Modifiers::COMMAND);
assert_eq!(value(&h, label), before, "pressing Ctrl moved the value");
for i in 1..=6 {
move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
}
let fine = value(&h, label) - before;
assert!((0.08..=0.12).contains(&(fine / plain)), "Ctrl-drag moved {fine}, a plain drag {plain}");
let held = value(&h, label);
hold(&mut h, Modifiers::NONE);
assert_eq!(value(&h, label), held, "releasing Ctrl moved the value back to the pointer");
press(&mut h, from + vec2(40.0, 0.0), false, Modifiers::NONE);
assert_eq!(value(&h, label), held, "releasing the button moved the value back to the pointer");
}
#[test]
fn pressing_ctrl_during_a_drag_on_the_value_box_keeps_the_value() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let plain = drag_on(&mut h, Role::SpinButton, label, 30.0, Modifiers::NONE);
let held = drag_on(&mut h, Role::SpinButton, label, 30.0, Modifiers::COMMAND);
assert!(held < plain / 2.0, "a Ctrl-drag on the value box moved {held}, a plain drag {plain}");
let from = h.get_by_role_and_label(Role::SpinButton, label).rect().center();
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::NONE);
move_to(&mut h, from + vec2(10.0, 0.0));
let before = value(&h, label);
hold(&mut h, Modifiers::COMMAND);
assert_eq!(value(&h, label), before, "pressing Ctrl moved the value");
for i in 1..=6 {
move_to(&mut h, from + vec2(10.0 + 5.0 * i as f32, 0.0));
}
let fine = value(&h, label) - before;
// The rest of the drag moves as far as a drag with Ctrl held from the start.
assert!((0.8..=1.2).contains(&(fine / held)), "after Ctrl, the value box moved {fine}; a Ctrl-drag moves {held}");
let last = value(&h, label);
press(&mut h, from + vec2(40.0, 0.0), false, Modifiers::COMMAND);
assert_eq!(value(&h, label), last, "releasing the button moved the value");
hold(&mut h, Modifiers::NONE);
}
#[test]
fn the_control_key_alone_also_gives_a_fine_drag() {
let mut h = strip_silence_dialog();
let label = "Threshold dB";
let plain = drag(&mut h, label, 30.0, Modifiers::NONE);
let fine = drag(&mut h, label, 30.0, Modifiers::CTRL);
assert!((0.08..=0.12).contains(&(fine / plain)), "Control-drag moved {fine}, a plain drag {plain}");
}
#[test]
fn a_ctrl_drag_reports_every_change_in_the_response() {
let mut h = Harness::builder().with_size(vec2(400.0, 100.0)).build_ui_state(
|ui, (value, changed): &mut (f64, bool)| {
*changed = widgets::fine_slider(ui, value, 0.0..=96.0, "Level").changed();
},
(48.0, false),
);
h.run_steps(2);
let from = h.get_by_role_and_label(Role::Slider, "Level").rect().center();
hold(&mut h, Modifiers::COMMAND);
move_to(&mut h, from);
press(&mut h, from, true, Modifiers::COMMAND);
for i in 1..=6 {
let before = h.state().0;
move_to(&mut h, from + vec2(5.0 * i as f32, 0.0));
assert_ne!(h.state().0, before, "step {i} did not move the value");
assert!(h.state().1, "step {i} moved the value without reporting a change");
}
press(&mut h, from + vec2(30.0, 0.0), false, Modifiers::COMMAND);
}
+188 -19
View File
@@ -36,20 +36,36 @@ impl Os {
/// The standard VST3 search locations for this OS (existing or not), followed by every entry of
/// the `VST3_PATH` environment variable.
pub fn default_search_paths() -> Vec<PathBuf> {
let mut v = os_search_paths(
Os::current(),
std::env::var_os("HOME").map(PathBuf::from),
std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from),
std::env::var_os("LOCALAPPDATA").map(PathBuf::from),
default_search_paths_with(Os::current(), |name| std::env::var_os(name), std::env::current_exe().ok())
}
/// [`default_search_paths`] with the environment lookup and executable path injected, so the
/// Windows common-files fallback, `VST3_PATH` and the application-local folders are testable.
fn default_search_paths_with(os: Os, var: impl Fn(&str) -> Option<std::ffi::OsString>, executable: Option<PathBuf>) -> Vec<PathBuf> {
let mut v = search_paths(
os,
var("HOME").map(PathBuf::from),
// A 32-bit process on 64-bit Windows sees the x86 directory in COMMONPROGRAMFILES.
var("COMMONPROGRAMW6432").or_else(|| var("COMMONPROGRAMFILES")).map(PathBuf::from),
var("COMMONPROGRAMFILES(X86)").map(PathBuf::from),
var("LOCALAPPDATA").map(PathBuf::from),
executable,
);
if let Some(cp) = std::env::var_os("VST3_PATH") {
if let Some(cp) = var("VST3_PATH") {
v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty()));
}
v
}
/// The OS-specific standard locations, given the relevant environment values.
pub fn os_search_paths(os: Os, home: Option<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {
/// Builds the search list without reading process state, so every OS layout is testable anywhere.
fn search_paths(
os: Os,
home: Option<PathBuf>,
common_program_files: Option<PathBuf>,
common_program_files_x86: Option<PathBuf>,
local_app_data: Option<PathBuf>,
executable: Option<PathBuf>,
) -> Vec<PathBuf> {
let mut v = Vec::new();
match os {
Os::MacOs => {
@@ -57,21 +73,44 @@ pub fn os_search_paths(os: Os, home: Option<PathBuf>, common_program_files: Opti
v.push(h.join("Library/Audio/Plug-Ins/VST3"));
}
v.push(PathBuf::from("/Library/Audio/Plug-Ins/VST3"));
v.push(PathBuf::from("/Network/Library/Audio/Plug-Ins/VST3"));
// Standalone binaries have no app bundle; don't scan a directory above their executable.
let contents = executable.as_deref().and_then(Path::parent).filter(|dir| dir.ends_with("Contents/MacOS")).and_then(Path::parent);
if let Some(contents) = contents {
v.push(contents.join("VST3"));
}
}
Os::Windows => {
if let Some(c) = common_program_files {
v.push(c.join("VST3"));
}
if let Some(l) = local_app_data {
v.push(l.join("Programs").join("Common").join("VST3"));
}
for c in [common_program_files, common_program_files_x86].into_iter().flatten() {
let path = c.join("VST3");
// Both environment variables can name the same 32-bit common-files directory.
if !v.contains(&path) {
v.push(path);
}
}
if let Some(exe) = executable
&& let Some(app_dir) = exe.parent()
{
v.push(app_dir.join("VST3"));
}
}
Os::Unix => {
// Keep the user/global/application order from Steinberg's Linux plug-in locations.
if let Some(h) = home {
v.push(h.join(".vst3"));
}
v.push(PathBuf::from("/usr/lib64/vst3"));
v.push(PathBuf::from("/usr/lib/vst3"));
v.push(PathBuf::from("/usr/local/lib64/vst3"));
v.push(PathBuf::from("/usr/local/lib/vst3"));
if let Some(exe) = executable
&& let Some(app_dir) = exe.parent()
{
v.push(app_dir.join("vst3"));
}
}
}
v
@@ -237,6 +276,11 @@ mod tests {
d
}
/// Returns a closure that looks up environment variables from the given pairs, for testing.
fn env_from(pairs: Vec<(&'static str, std::ffi::OsString)>) -> impl Fn(&str) -> Option<std::ffi::OsString> {
move |name| pairs.iter().find(|(key, _)| *key == name).map(|(_, value)| value.clone())
}
#[test]
fn finds_files_and_bundles_recursively_but_not_inside_bundles() {
let d = tmp("find");
@@ -292,17 +336,142 @@ mod tests {
}
#[test]
fn os_paths_cover_the_standard_locations() {
let mac = os_search_paths(Os::MacOs, Some("/Users/u".into()), None, None);
assert_eq!(mac, vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3"), PathBuf::from("/Library/Audio/Plug-Ins/VST3")]);
let win = os_search_paths(Os::Windows, None, Some("C:/CPF".into()), Some("C:/LAD".into()));
assert_eq!(win[0], PathBuf::from("C:/CPF").join("VST3"));
assert_eq!(win.len(), 2);
let unix = os_search_paths(Os::Unix, Some("/home/u".into()), None, None);
assert_eq!(unix, vec![PathBuf::from("/home/u/.vst3"), PathBuf::from("/usr/lib/vst3"), PathBuf::from("/usr/local/lib/vst3")]);
fn default_paths_are_not_empty() {
assert!(!default_search_paths().is_empty());
}
#[test]
fn macos_paths_cover_documented_locations_in_order() {
let exe = PathBuf::from("/Applications/SoundCraft.app/Contents/MacOS/soundcraft");
let global_and_app = vec![
PathBuf::from("/Library/Audio/Plug-Ins/VST3"),
PathBuf::from("/Network/Library/Audio/Plug-Ins/VST3"),
PathBuf::from("/Applications/SoundCraft.app/Contents/VST3"),
];
let mut with_home = vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3")];
with_home.extend(global_and_app.iter().cloned());
assert_eq!(search_paths(Os::MacOs, Some("/Users/u".into()), None, None, None, Some(exe.clone())), with_home);
assert_eq!(search_paths(Os::MacOs, None, None, None, None, Some(exe)), global_and_app);
}
#[test]
fn macos_paths_without_a_bundle_keep_only_standard_directories() {
let globals = vec![PathBuf::from("/Library/Audio/Plug-Ins/VST3"), PathBuf::from("/Network/Library/Audio/Plug-Ins/VST3")];
assert_eq!(search_paths(Os::MacOs, None, None, None, None, None), globals);
for exe in ["/usr/local/bin/soundcraft", "/tmp/MacOS/soundcraft", "/tmp/Contents/bin/soundcraft"] {
assert_eq!(search_paths(Os::MacOs, None, None, None, None, Some(exe.into())), globals);
}
let mut with_home = vec![PathBuf::from("/Users/u/Library/Audio/Plug-Ins/VST3")];
with_home.extend(globals);
assert_eq!(search_paths(Os::MacOs, Some("/Users/u".into()), None, None, None, None), with_home);
}
#[test]
fn windows_paths_cover_documented_locations_in_order() {
let paths = search_paths(
Os::Windows,
None,
Some("D:/Program Files/Common Files".into()),
Some("D:/Program Files (x86)/Common Files".into()),
Some("C:/Users/u/AppData/Local".into()),
Some("D:/SoundCraft/soundcraft.exe".into()),
);
assert_eq!(
paths,
vec![
PathBuf::from("C:/Users/u/AppData/Local/Programs/Common/VST3"),
PathBuf::from("D:/Program Files/Common Files/VST3"),
PathBuf::from("D:/Program Files (x86)/Common Files/VST3"),
PathBuf::from("D:/SoundCraft/VST3"),
]
);
}
#[test]
fn windows_paths_handle_missing_values_and_shared_common_directory() {
assert!(search_paths(Os::Windows, None, None, None, None, None).is_empty());
assert_eq!(
search_paths(Os::Windows, None, None, None, None, Some("C:/SoundCraft/soundcraft.exe".into())),
vec![PathBuf::from("C:/SoundCraft/VST3")]
);
let common = PathBuf::from("C:/Program Files (x86)/Common Files");
assert_eq!(search_paths(Os::Windows, None, Some(common.clone()), Some(common.clone()), None, None), vec![common.join("VST3")]);
assert_eq!(search_paths(Os::Windows, None, None, Some(common.clone()), None, None), vec![common.join("VST3")]);
}
#[test]
fn default_paths_prefer_the_64_bit_windows_common_directory() {
let paths = default_search_paths_with(
Os::Windows,
env_from(vec![
("COMMONPROGRAMW6432", "C:/Program Files/Common Files".into()),
("COMMONPROGRAMFILES", "C:/ignored/Common Files".into()),
("COMMONPROGRAMFILES(X86)", "C:/Program Files (x86)/Common Files".into()),
("LOCALAPPDATA", "C:/Users/u/AppData/Local".into()),
]),
Some("C:/SoundCraft/soundcraft.exe".into()),
);
assert_eq!(
paths,
vec![
PathBuf::from("C:/Users/u/AppData/Local/Programs/Common/VST3"),
PathBuf::from("C:/Program Files/Common Files/VST3"),
PathBuf::from("C:/Program Files (x86)/Common Files/VST3"),
PathBuf::from("C:/SoundCraft/VST3"),
]
);
}
#[test]
fn default_paths_fall_back_to_common_program_files() {
let paths =
default_search_paths_with(Os::Windows, env_from(vec![("COMMONPROGRAMFILES", "C:/Program Files (x86)/Common Files".into())]), None);
assert_eq!(paths, vec![PathBuf::from("C:/Program Files (x86)/Common Files/VST3")]);
}
#[test]
fn default_paths_append_vst3_path_entries() {
let vst3_path = std::env::join_paths(["/opt/one", "/opt/two"]).unwrap();
let paths = default_search_paths_with(
Os::Unix,
env_from(vec![("HOME", "/home/u".into()), ("VST3_PATH", vst3_path)]),
Some("/opt/SoundCraft/soundcraft".into()),
);
assert_eq!(
paths,
vec![
PathBuf::from("/home/u/.vst3"),
PathBuf::from("/usr/lib64/vst3"),
PathBuf::from("/usr/lib/vst3"),
PathBuf::from("/usr/local/lib64/vst3"),
PathBuf::from("/usr/local/lib/vst3"),
PathBuf::from("/opt/SoundCraft/vst3"),
PathBuf::from("/opt/one"),
PathBuf::from("/opt/two"),
]
);
}
#[test]
fn unix_paths_cover_documented_locations_in_order() {
let exe = PathBuf::from("/opt/SoundCraft/soundcraft");
let globals = vec![
PathBuf::from("/usr/lib64/vst3"),
PathBuf::from("/usr/lib/vst3"),
PathBuf::from("/usr/local/lib64/vst3"),
PathBuf::from("/usr/local/lib/vst3"),
];
let mut global_and_app = globals.clone();
global_and_app.push(PathBuf::from("/opt/SoundCraft/vst3"));
let mut with_home = vec![PathBuf::from("/home/u/.vst3")];
with_home.extend(global_and_app.iter().cloned());
assert_eq!(search_paths(Os::Unix, Some("/home/u".into()), None, None, None, Some(exe.clone())), with_home);
assert_eq!(search_paths(Os::Unix, None, None, None, None, Some(exe)), global_and_app);
let mut home_and_globals = vec![PathBuf::from("/home/u/.vst3")];
home_and_globals.extend(globals);
assert_eq!(search_paths(Os::Unix, Some("/home/u".into()), None, None, None, None), home_and_globals);
}
#[test]
fn sub_categories_map_to_categories() {
let c = |s: &str| category_from_sub_categories(&split_sub_categories(s));
+1 -1
View File
@@ -10,7 +10,7 @@ SoundCraft supplies each state's colors, border, radius and icon painter; select
dispatch remain in the toolbar and track controls. The single-shape frame option preserves the
existing fill/border blending when controls are disabled. Specialized transport Play/Stop/Record styling,
nonlinear meters, faders, pan gestures, fonts and the live theme resolver remain local. Shared adapters read the current palette. The UI
requires Rust 1.95 or later without raising the core workspace's Rust 1.90 declaration.
requires Rust 1.95 or later, as does the workspace: the craft-libs crates the model and engine use declare it.
```
L0 time midi video (standalone: no workspace deps)
+1 -1
View File
@@ -33,7 +33,7 @@ Newline-delimited JSON over TCP on `127.0.0.1`. One request per line, one reply
{"id": 1, "ok": true, "result": {"tracks": [12, 13]}}
```
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`.
Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no track named `Nope`"}`. A line that is not a JSON object with a string `method` gets one error reply, and the connection is closed.
## Methods
+7 -3
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-27 · **Change:** minor (Cancel Zoom Toggle; Tracks and Groups and Clips in dock columns; Alt-click returns a plugin parameter to its default) · **Target:** Avid Pro Tools Ultimate 2026.4.1
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -51,7 +51,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| I/O Setup | 🟡 | Busses and paths; no hardware channel matrix (G-01) |
| Preferences | 🟡 | Many settings stored, fewer honoured |
| Window configurations, Tile/Cascade | 🟡 | Configurations ✅; Tile/Cascade in PR #151 |
| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6) |
| Plugin windows | 🟡 | Generic parameter sliders; Pro Tools plugins have bespoke GUIs (#138 asks for skeuomorphic design); multi-page plugin views (#6); Alt/Option-click returns a parameter to its default |
| A plugin opens when it's inserted | ✅ | #486; a hosted plugin's own editor, else its plugin window |
| Command palette (Search) | ✅ | Over every command |
@@ -63,15 +63,19 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Interface scaling | 🟡 | Slider in PR #147 |
| Meter types and ballistics (peak, RMS, VU, K-scales, gain reduction) | 🟡 | Peak/RMS; meter type choice and GR meters partial |
| Clip indicators, track colours, waveform views (peak/power/rectified/outlines) | ✅ | |
| Fader resolution, fine adjust, numeric entry | 🟡 | |
| Fader resolution, fine adjust, numeric entry | 🟡 | Ctrl/Cmd-drag moves the faders, the Strip Silence sliders and their value boxes, plugin parameter sliders, the Clip Effects sliders and the AudioSuite sliders a tenth as far |
| macOS system menu bar | ✅ | #10 |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-27 | minor | Clip Effects gain supports Alt/Option-click reset to 0 dB and Ctrl/Cmd fine adjustment. Readiness estimate unchanged |
| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged |
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
| 2026-10-24 | minor | Plugin parameters: Alt/Option-click returns one to its default. Readiness estimate unchanged |
| 2026-10-24 | minor | Plugin parameter sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged |
| 2026-10-24 | minor | Strip Silence sliders: Ctrl/Cmd-drag moves them a tenth as far. Readiness estimate unchanged |
| 2026-10-20 | minor | Preferences uses one fixed non-scrolling size with compact stacked choices, removes the duplicate meter-type list, and command-backs visual-effects reduction and undo depth alongside the UI theme |
| 2026-10-19 | minor | Preferences opens from Setup with nine searchable pages, compact grouped controls, staged apply/cancel, platform tab shortcuts and disabled placeholders for unavailable settings |
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
+2
View File
@@ -112,6 +112,8 @@ $Portable = Join-Path $TargetDir "windows-package\soundcraft-$Version-windows-$A
Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $Portable | Out-Null
Copy-Item (Join-Path $Stage '*.exe') $Portable
# The ZIP is portable by default; keep the marker out of the MSI staging directory.
Set-Content -LiteralPath (Join-Path $Portable 'portable.txt') -Encoding Ascii -Value 'SoundCraft portable mode: settings and logs are stored in SoundCraftData beside the executable.'
foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE', 'ATTRIBUTION.md') {
$p = Join-Path $Root $f
if (Test-Path $p) { Copy-Item $p $Portable }