Compare commits

..
75 Commits
Author SHA1 Message Date
Brandon Thomas 5075da2f3e Merge remote-tracking branch 'origin/main' into land-993
# Conflicts:
#	crates/engine/src/score.rs
2026-10-29 21:59:42 -04:00
Brandon Thomas 52083e07f1 Merge pull request #1159 from Silvertree2010/fix/h3519-insert-time-and-cut-time-leave-k
Move key signatures with Insert Time and Cut Time
2026-10-29 21:55:21 -04:00
Brandon Thomas b46584cc30 Merge remote-tracking branch 'origin/main' into fix1159 2026-10-29 21:54:18 -04:00
Brandon Thomas 52b5cea05f Merge pull request #1134 from Silvertree2010/fix/h2909-cut-time-and-insert-time-leave-a
Shift selection memory location ends on Cut Time and Insert Time
2026-10-29 21:54:11 -04:00
Brandon Thomas 228fd37ab7 Merge remote-tracking branch 'origin/main' into fix1134
# Conflicts:
#	crates/engine/tests/it/edit_modes.rs
2026-10-29 21:53:13 -04:00
Brandon Thomas 60d18f225d Merge pull request #1095 from dotCooCoo/fix/strip-silence-scan-stops
Strip Silence: closing the window while Mix shows stops the rectangle scan
2026-10-29 21:52:38 -04:00
Brandon Thomas ba0c6e381f Merge pull request #1106 from dotCooCoo/fix/wasm-clippy-drop-non-drop
Engine: wasm32 clippy reports no drop_non_drop in autosave and backups
2026-10-29 21:52:35 -04:00
Brandon Thomas bcb872f1a3 Merge pull request #1103 from dotCooCoo/fix/updates-ui-wasm-dead-code
Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm
2026-10-29 21:52:31 -04:00
Brandon Thomas b119e3adce Merge pull request #1077 from sanderdewijs/fix/playback-engine-command
setup.playback_engine / setup.hardware: apply device and buffer in the app, don't claim it headless
2026-10-29 21:52:27 -04:00
Brandon Thomas 0d8dde8294 Merge pull request #1181 from reoniazx/fix/dirty-state-comparison
Avoid cloning sessions when checking unsaved changes
2026-10-29 21:52:24 -04:00
Brandon Thomas 94fb7db790 Merge pull request #1179 from luigiPinna/fix/video-sample-cap-912
Route sequential H.264 sample reads through the 256 MiB cap
2026-10-29 21:52:20 -04:00
Brandon Thomas 3f5377c877 Merge pull request #1178 from luigiPinna/fix/vst3-bus-limits-904
Reject VST3 bus layouts beyond host limits instead of truncating
2026-10-29 21:52:16 -04:00
Brandon Thomas 3b87c6dcc2 Merge pull request #1026 from CloudCorpRecords/fix/folder-delete-shared-bus
track.delete: keep a folder's input bus when surviving tracks route through it
2026-10-29 21:52:12 -04:00
Brandon Thomas d2244d0765 Merge pull request #1172 from TheGalitube/fix/h1672-bars-beats-conversion-panics-on
Clamp the minimum beat before conversion
2026-10-29 21:52:09 -04:00
Brandon Thomas e2809001cf Merge pull request #1171 from TheGalitube/fix/h1407-velocity-ramps-miscalculate-note
Calculate velocity ramps across the full tick range
2026-10-29 21:52:05 -04:00
Brandon Thomas 92c925a1dc Merge pull request #1149 from ayozetr/test-878-matroska-sizes
Audio import: regression test for oversized Matroska element sizes (fixes #878)
2026-10-29 21:52:01 -04:00
Brandon Thomas 140e63fe09 Merge pull request #1148 from ayozetr/fix-895-stretch-render-budget
Bound the source span of stretch renders (fixes #895)
2026-10-29 21:51:58 -04:00
Brandon Thomas c3cf5fe236 Merge pull request #1145 from DAnaeto/fix/cli-keep-going-exit-status
Fix exit status after kept-going script errors
2026-10-29 21:51:55 -04:00
Brandon Thomas 1ce93ab2ea Merge pull request #1169 from Silvertree2010/fix/h3883-setup-current-timecode-overflows
Saturate and clamp session start in setup.current_timecode
2026-10-29 21:51:51 -04:00
Brandon Thomas dd1ac56430 Merge pull request #1182 from Silvertree2010/fix/h3996-h-264-slice-header-panics-with-i
Return Error::Invalid for overflowing H.264 slice header values
2026-10-29 21:51:47 -04:00
Brandon Thomas fda41ecd12 Merge pull request #1161 from Silvertree2010/fix/h3574-new-memory-location-during-playb
Fix stale end on a Memory Location added during playback
2026-10-29 21:51:43 -04:00
Brandon Thomas 6a10dfa19d Merge pull request #1160 from Silvertree2010/fix/h3562-mcp-server-sends-no-reply-to-an
Reply with Invalid Request to empty batches and non-object messages
2026-10-29 21:51:39 -04:00
Brandon Thomas 672f5ab767 Merge pull request #1153 from Silvertree2010/test/reference-algorithms
Check the rewritten DSP paths against the algorithms they replaced
2026-10-29 21:51:35 -04:00
Brandon Thomas 11ab6a999c Merge pull request #1141 from Silvertree2010/fix/1113-audio-io-info-reports-more-frame
Report the decoded length for AAC in MP4 in audio-io probe
2026-10-29 21:51:31 -04:00
Brandon Thomas b84d6c95d5 Merge pull request #1138 from Silvertree2010/fix/h3004-moving-several-midi-notes-left-p
Keep MIDI note spacing when moving notes left past tick 0
2026-10-29 21:51:28 -04:00
Brandon Thomas 1d99907d85 Merge pull request #1137 from Silvertree2010/fix/h3028-pasting-a-crossfaded-clip-pair-d
Keep crossfade overlaps when pasting clips
2026-10-29 21:51:24 -04:00
Brandon Thomas 57b3ae5357 Merge pull request #1123 from Silvertree2010/fix/h2893-elastic-warp-to-a-shorter-length
Clamp clip fades after elastic warp and remove warp
2026-10-29 21:51:21 -04:00
Brandon Thomas 2e6c519bef Merge pull request #1110 from Silvertree2010/fix/h2771-bwf-bext-fields-are-cut-in-the-m
Cut BWF bext text fields at UTF-8 character boundaries
2026-10-29 21:51:18 -04:00
Brandon Thomas adc7c13a37 Merge pull request #1062 from Silvertree2010/fix/h2436-moving-several-clips-left-past-t
Keep clip spacing when moving a group left past the session start
2026-10-29 21:51:14 -04:00
Brandon Thomas f5d7ca5d87 Merge pull request #1061 from Silvertree2010/fix/h2409-musicxml-svg-score-export-writes
Strip XML-forbidden characters from MusicXML/SVG score export
2026-10-29 21:51:10 -04:00
Brandon Thomas a6df32ffab Merge pull request #1025 from Silvertree2010/fix/h2054-session-text-export-counts-midi
Count only audio-track clips in session text export
2026-10-29 21:51:07 -04:00
Brandon Thomas 7b654fd1a3 Merge pull request #1024 from Silvertree2010/fix/h2052-min-secs-position-strings-are-mi
Reject out-of-range timecode fields so Min:Secs strings fall through
2026-10-29 21:51:03 -04:00
NetterSilverandClaude Haiku 5.5 f16e312fbc Return Error::Invalid for overflowing H.264 slice header values
Found while testing crates/video/src/h264/slice.rs:202-204 (SliceHeader::parse)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 14:54:30 +01:00
reoniazx 986fb951df Avoid cloning sessions for dirty-state comparisons 2026-10-29 20:19:47 +08:00
Luigi Pinna 5dbebaf617 Contributors: add luigiPinna to people.toml 2026-10-29 10:46:43 +01:00
Sander de WijsandClaude Opus 5.5 5c8654b2d7 Split the playback engine device test under the cognitive complexity limit
Upstream now denies clippy::cognitive_complexity (#1129). The test is
split into refusals, applying, and parity with ui.audio_device; the
assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:46:26 +01:00
Sander de WijsandClaude Opus 5.5 d97e5e7f16 Validate playback device command responses
Reject non-string device values before changing session or audio configuration, include bit depth in desktop responses, and report applied only for an open non-silent device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:39:42 +01:00
Sander de WijsandClaude Opus 5.5 a63b23adf7 Apply the playback engine's device and buffer in the desktop app
setup.playback_engine and setup.hardware stored device and buffer_size on the session and reported 1024 when nothing was set. Playback never read those values, so the open device stayed at its old buffer. In the app the commands now reopen the device through ui.audio_device and return the name, buffer and sample rate actually in use, while delay compensation still goes through the engine command. Headless keeps the stored request, returns applied: false, and leaves buffer_size null until one is set. A buffer is any integer from 16 to 8192 and is not rounded to a power of two; values outside that range error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:39:42 +01:00
Max MittelstädtandGPT-6-Luna 0ede26f5bc Clamp the minimum beat before conversion
Found while testing crates/time/src/tempo.rs:304

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-29 09:05:48 +00:00
Luigi Pinna 09444326d5 Reject VST3 bus layouts beyond host limits instead of truncating
Fixes #904
2026-10-29 09:42:56 +01:00
Luigi Pinna b609172db6 Route sequential H.264 sample reads through the 256 MiB cap
decode_h264 read each sample with Mp4File::read_sample directly, so a
sample table entry above 256 MiB was allocated and read in full when the
source was long enough. It now goes through Movie::read like the
keyframe and intra-frame paths, and an oversized sample is skipped like
any other unreadable access unit.

Fixes #912
2026-10-29 09:39:46 +01:00
Max MittelstädtandGPT-6-Luna 65e5443975 Calculate velocity ramps across the full tick range
Found while testing crates/midi/src/ops.rs:181

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-29 08:31:50 +00:00
NetterSilverandClaude Haiku 5.5 1c284e3703 Saturate and clamp session start in setup.current_timecode
Found while testing crates/engine/src/cmd/setup_more.rs:309-310 (current_timecode)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 09:08:00 +01:00
Dominic Anaeto 179f0c8c5d Merge main and preserve script failure status after CLI refactor 2026-10-28 22:55:08 -04:00
Dylan M. Taylor 8ca6939981 Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds (#1167)
* Document the aarch64 archive name in FreeBSD packaging

package.sh already honors CARGO_BUILD_TARGET: cargo builds for the target, the binaries are read
from target/<triple>/release, and the arch comes from the target rather than uname -m. Only the
header still described x86_64 alone, so update it.

* Include the full header in package.sh --help
2026-10-28 15:36:07 -04:00
Dylan M. Taylor 60b2c07a70 Grant network access so update checks can reach GitHub (#1163) 2026-10-28 13:16:03 -04:00
NetterSilverandClaude Haiku 5.5 e600e6bf64 Fix stale end on a Memory Location added during playback
Found while testing crates/engine/src/cmd/event.rs:305 (add_marker)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:06:40 +01:00
NetterSilverandClaude Haiku 5.5 90932afed0 Reply with Invalid Request to empty batches and non-object messages
Found while testing crates/automation/src/mcp.rs:201-205 (Server::handle) and :258-261 (Server::serve)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:16:19 +00:00
NetterSilverandClaude Haiku 5.5 50d0028e07 Move key signatures with Insert Time and Cut Time
Found while testing crates/engine/src/cmd/event.rs:147-196 (insert_time, cut_time); key_signatures in crates/model/src/session.rs:344

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 12:43:00 +00:00
dotCooCoo 1a20190149 Merge commit 'refs/check/mw-soundcraft-main' into fix/updates-ui-wasm-dead-code
# Conflicts:
#	crates/ui-egui/src/lib.rs
2026-10-28 02:22:29 -07:00
dotCooCoo c14dc61473 Merge commit 'refs/check/mw-soundcraft-main' into fix/strip-silence-scan-stops
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-28 01:42:36 -07:00
NetterSilverandClaude Opus 5.5 ceb3404c31 Check the rewritten DSP paths against the algorithms they replaced
True peak, the Maximizer window minimum, flush and GainRamp are compared
bit for bit with their previous implementations computed in the test, so
the checks hold whatever the platform libm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 23:58:58 +01:00
Ayoze Torres ed0aed5ccb Matroska regression test: assert each case reaches the read it targets
The codec-data file now has a valid Info and track up to its oversized
CodecPrivate, and must fail with the end-of-file error from that read;
the block file must probe successfully, so decoding reaches the cluster.
An earlier rejection by anything else would now fail the test instead
of passing it without exercising the oversized read.
2026-10-27 21:34:25 +00:00
Ayoze Torres 1aab8e449d Audio import: regression test for oversized Matroska element sizes (#878)
With symphonia 0.5, the Matroska reader allocated whatever size an
element claimed before reading it, so a 30-byte file whose EBML header
claims 2^48 bytes aborted the process on import, drag and drop or
session open. Since the bump to symphonia 0.6 such reads grow with the
data actually read (safe_read_into_boxed_slice), so the abort is gone
without a SoundCraft-side check.

Lock that in: the reported file, a track whose CodecPrivate claims 2^46
bytes and a SimpleBlock that claims 2^47 bytes must give an error (or,
for the block, a best-effort read of what is there) under every hint,
never an abort. The test passes with memory capped at 1 GB.
2026-10-27 20:52:22 +00:00
Ayoze Torres c7a7cfa09b Bound the source span of stretch renders (#895)
render_stretched bounded only the output, at an hour. The source span it
reads in is the clip length over its stretch, which the 0.1..10 ratio
check lets reach ten hours, and it is held for every channel of the
source. A session's clip length and stretch are not trusted, so a
one-frame stereo file with a one-hour clip at stretch 0.1 made Remove
Pitch Shift allocate about 14 GB and abort, losing unsaved work.
Elastic Properties and TCE to Timeline go through the same code.

Both the source span and the output are now bounded by render_limit for
the source's channel count, like other offline renders, and the command
is refused before anything is allocated.
2026-10-27 20:48:57 +00:00
Dominic Anaeto 79c45a154d fix(cli): report kept-going script errors 2026-10-27 13:26:04 -04:00
NetterSilverandClaude Opus 5.5 9adcc1f389 Report the decoded length for AAC in MP4 in audio-io probe
Fixes #1113

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:29:22 +00:00
NetterSilverandClaude Haiku 5.5 795f1cb6ef Keep MIDI note spacing when moving notes left past tick 0
Found while testing crates/engine/src/cmd/midi.rs:128-131 (notes_move)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 11:34:36 +00:00
NetterSilverandClaude Opus 5.5 e631966cf7 Keep crossfade overlaps when pasting clips
Found while testing crates/engine/src/edit.rs:235-248 (paste, via place_clip at :252-258)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 10:21:59 +00:00
NetterSilverandClaude Haiku 5.5 45498c8268 Shift selection memory location ends on Cut Time and Insert Time
Found while testing crates/engine/src/cmd/event.rs:162-167 (insert_time) and :188-194 (cut_time)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 02:34:20 +00:00
NetterSilverandClaude Haiku 5.5 0d0d0abc27 Clamp clip fades after elastic warp and remove warp
Found while testing crates/engine/src/cmd/clip.rs:217-231 (elastic), also :249 (remove_warp)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 21:44:02 +00:00
NetterSilverandClaude Haiku 5.5 9dc26fa1ca Cut BWF bext text fields at UTF-8 character boundaries
Found while testing crates/audio-io/src/wav.rs:343-348 (put_fixed_str, used by bext_chunk)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 06:50:47 +01:00
Rene Turcios 7a9b3e38e1 Merge main into fix/folder-delete-shared-bus
Bring the branch up to date with upstream main (64 commits). The only
overlap was crates/engine/src/cmd/track.rs, resolved by keeping the
upstream refactor that extracts the surviving-parent walk into
surviving_parent, and keeping this branch's change on top of it: the
removed-bus computation now computes the replacement from that helper
and still drops the bus only when bus_used_beyond_folder says no
surviving track routes through it, so a shared folder bus and every
route to it survive the folder delete.

Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com>
2026-10-25 21:16:40 -07:00
dotCooCoo 9a99c82d2c Engine: wasm32 clippy reports no drop_non_drop in autosave and backups
On wasm32-unknown-unknown, std::fs::File has no Drop, so clippy with
-D warnings reports the three explicit drops of a File or Option<File>
in soundcraft-engine as drop_non_drop errors: Autosave::discard,
autosave::leftovers and io::copy_new.

Autosave::discard now releases the lock by setting the field to None,
which closes the file on every target. The drops in leftovers and
copy_new carry #[cfg(not(target_arch = "wasm32"))], as the DirBuilder
mode call in Autosave::create carries #[cfg(unix)]. The desktop builds
close the same files at the same points as before.
2026-10-25 21:08:33 -07:00
dotCooCoo 0c7212854a Web: Check for Updates opens its window, with no dead_code warnings in xtask wasm
SoundApp::logic and SoundApp::ui called updates_ui::tick and
updates_ui::show only off wasm32, so `cargo xtask wasm` reported both
functions as never used. On the web, SoundCraft > Check for Updates…
sets the window's message ("Update check is not supported in the web
browser.") but nothing drew the window.

Both functions already have wasm32 branches, so the calls now run on
every target, as effectcraft's lib.rs calls them.
2026-10-25 17:36:40 -07:00
dotCooCoo ee55acbc93 Strip Silence: closing the window while Mix shows stops the rectangle scan
Only the Edit window dropped the Strip Silence rectangles and their scan
when Strip Silence closed. With the Mix window showing, a scan for old
values ran to the end and strip_scan_running() stayed true.
dialogs::show, which runs every frame, now drops them whenever neither
Strip Silence nor its Rename Selected Clips dialog is open.

edit.strip_silence_preview, strip_silence_scan and strip_settings move
from cmd/edit.rs to a new cmd/strip_silence.rs with its own specs(), as
AGENTS.md asks for new commands. edit.strip_silence reads its settings
through the same strip_settings.
2026-10-25 14:13:49 -07:00
Samuel Bacaro e294aa48c0 score: keep the bar duration when the item cap drops notes
A 64/1 bar can hold 256 sixteenths, more than MAX_ITEMS_PER_MEASURE (128). The
cap used to truncate the item list and drop the remaining duration, so MusicXML
wrote an underfull measure and SVG divided positions by the shortened total,
moving the kept notes across the bar.

The discarded suffix is now replaced with representable rests. The cap shrinks
the retained prefix further when the rests would not fit, and a final truncate
still bounds a hostile meter whose whole-bar rests exceed the cap.

Regression test: item_cap_keeps_the_measure_duration (fails on the old
truncate-only code, passes with rests).
2026-10-25 09:55:14 -03:00
Samuel Bacaro 021caab499 score: make the MusicXML oversize test reach the measure check
The 1 KiB budget failed on the part-list check, so nothing exercised the MusicXML
per-measure budget. Measure the header (parts with no measures) and the full
document, pick a budget between them, and assert that the header fits while the
completed document is refused, so the test proves the refusal comes from the
measures. The 1 KiB run stays as the header-refusal case, and the SVG case is
unchanged.
2026-10-25 09:40:30 -03:00
Samuel Bacaro fa77e03e16 score: apply every byte-budget and per-part cap in the same place
Addresses the review threads on #993:

- MAX_NOTES_PER_PART: the layout loop stops collecting past 100k notes
  per part, so the raw note vector cannot grow unboundedly regardless
  of clip count.
- fill: a pitch the chord cap rejects no longer extends the onset end
  (e.0), so dropped pitches do not inflate chord duration; a retained
  pitch that reappears with a longer duration still does.
- musicxml now checks the budget before the per-measure loop (covers an
  oversized title/composer, escaped, even with zero measures), after the
  part-list, and at the end, so no growth path is unchecked.
- svg now checks every measure (rest-only or missing measures cannot
  grow the output untouched past the budget), every staff, every system
  and the final footer, so staff/clef/bracket growth is capped too.
- over_budget takes the budget as a parameter and the budgeted entry
  points stay separate so tests pass a tiny budget instead of building
  256 MiB. The oversize test shrank from ~256 MiB to ~64 KiB and the
  new header test checks the title/composer escape path directly.

Regression tests.
2026-10-25 09:23:40 -03:00
Samuel Bacaro ccd3fe7ca2 Bound Print Score and MusicXML output size
MAX_MEASURES limited the number of measures but not the items per
measure, pitches per chord, or total output bytes, so a hostile session
could produce an unbounded document (a 10M-note session became a ~20 GB
SVG and aborted).

- Limit items per measure and pitches per chord in layout/fill.
- Give svg() and musicxml() a 256 MiB byte budget; over it they return
  an error instead of growing without bound.
- write_score surfaces that error to the caller.

Fixes #921
2026-10-25 09:23:40 -03:00
NetterSilverandClaude Haiku 5.5 9f049ac7d0 Keep clip spacing when moving a group left past the session start
Found while testing crates/engine/src/edit.rs:486-489 (move_clips), reached via edit.move_clips in crates/engine/src/cmd/edit.rs:935

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:51:21 +00:00
NetterSilverandClaude Haiku 5.5 7509b64c5c Strip XML-forbidden characters from MusicXML/SVG score export
Found while testing crates/engine/src/score.rs:216-218 (xml_escape), used by musicxml() and svg()

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 06:39:26 +01:00
Rene Turcios 8385ed94c6 track.delete: keep a folder's input bus when surviving tracks route through it
owned_folder_bus treats any bus a folder takes as input as the folder's
own, but track.input or a session file can point a folder at any bus.
Deleting such a folder removed the bus and repaired every route to it:
inputs set to none, outputs rerouted, sends cleared. Point a folder at
the demo session's shared Verb bus and deleting it wiped every reverb
send in the session.

Remove the bus only when nothing that survives the delete routes
through it: an input, a send target, or an output from outside the
folder's own subtree, whose outputs the delete already reroutes to the
parent folder's bus.

Fixes #914

Signed-off-by: Rene Turcios <95189389+CloudCorpRecords@users.noreply.github.com>
2026-10-24 15:05:38 +00:00
NetterSilverandClaude Haiku 5.5 8f23afe462 Count only audio-track clips in session text export
Found while testing crates/engine/src/inspect.rs:116-120 (session_text)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 17:00:54 +02:00
NetterSilverandClaude Haiku 5.5 6d86a52310 Reject out-of-range timecode fields so Min:Secs strings fall through
Found while testing crates/time/src/timecode.rs:189-211 (Timecode::parse), reached via crates/engine/src/cmd/mod.rs:275-287 (position_param_raw)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 16:57:29 +02:00
64 changed files with 1975 additions and 152 deletions
+2 -1
View File
@@ -154,7 +154,8 @@ pub(crate) struct RunArgs {
pub(crate) struct ScriptArgs {
/// The script to run
pub(crate) file: PathBuf,
/// Report a failing line and go on with the next
/// Continue after command errors and finish requested output, then exit nonzero if any command failed.
/// Malformed JSON stops immediately
#[arg(long)]
pub(crate) keep_going: bool,
#[command(flatten)]
+2
View File
@@ -20,6 +20,8 @@ pub(crate) enum CliError {
ScriptJson { path: PathBuf, line: usize, source: serde_json::Error },
#[error("{}:{line}: {id}: {source}", path.display())]
ScriptLine { path: PathBuf, line: usize, id: String, source: EngineError },
#[error("script finished with command errors")]
ScriptFailed,
#[error("{}: {source}", path.display())]
File { path: PathBuf, source: std::io::Error },
#[error("{}: {source}", path.display())]
+4 -1
View File
@@ -75,6 +75,7 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
let text = std::fs::read_to_string(path).map_err(|source| CliError::File { path: path.clone(), source })?;
let mut e = load_engine(&args.engine, err)?;
let keep = args.keep_going;
let mut failed = false;
for (n, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
@@ -90,10 +91,12 @@ pub(crate) fn script(args: &ScriptArgs, out: &mut dyn Write, err: &mut dyn Write
return Err(failure);
}
outln!(err, "{failure}");
failed = true;
}
}
}
finish(&mut e, &args.engine, out)
finish(&mut e, &args.engine, out)?;
if failed { Err(CliError::ScriptFailed) } else { Ok(()) }
}
#[cfg(test)]
+27 -10
View File
@@ -238,13 +238,13 @@ fn script_line_errors_stop_unless_keep_going() {
let dir = sandbox("script-errors");
std::fs::write(dir.join("bad.txt"), "nosuch.cmd {}\ntrack.new {}\n").unwrap();
std::fs::write(dir.join("badjson.txt"), "track.new {x\n").unwrap();
// A stopping line error carries the `error:` prefix; a --keep-going one is a bare line.
// Kept-going line errors stay bare; a final error summary supplies the failing status.
let stop = invoke(&dir, &["script", "bad.txt"]);
assert_eq!((stop.code, stop.stdout.as_str()), (1, ""));
assert_eq!(stop.stderr, "error: bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
let keep = invoke(&dir, &["script", "bad.txt", "--keep-going"]);
assert_eq!((keep.code, keep.stdout.as_str()), (0, "track.new: {\"tracks\":[1]}\n"));
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\n");
assert_eq!((keep.code, keep.stdout.as_str()), (1, "track.new: {\"tracks\":[1]}\n"));
assert_eq!(keep.stderr, "bad.txt:1: nosuch.cmd: unknown command `nosuch.cmd`\nerror: script finished with command errors\n");
assert_fails(&invoke(&dir, &["script", "badjson.txt"]), "badjson.txt:1: bad JSON: key must be a string at line 1 column 2");
}
@@ -515,10 +515,9 @@ fn mcp_roots_with_connect_are_a_usage_error() {
);
}
/// A name that isn't valid Unicode works wherever the tool itself reads or writes the file
/// (`convert`); a session or bounce the engine opens or writes by text name is refused (exit 1),
/// naming the file. Run in-process: the names need no shell.
#[cfg(unix)]
/// Native file paths round-trip on filesystems that accept non-Unicode names. macOS rejects
/// these names with EILSEQ before the tool can write them. Run in-process: no shell is needed.
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn non_unicode_file_names() {
use std::ffi::OsString;
@@ -543,8 +542,26 @@ fn non_unicode_file_names() {
std::fs::rename(dir.join("s.scraft"), latin1("scraft")).unwrap();
let (code, err) = run(vec!["info".into(), latin1("scraft").into()]);
assert_eq!(code, 0, "{err}");
let (code, err) = run(vec!["run".into(), "--demo".into(), "--bounce".into(), latin1("wav").into()]);
assert_eq!(code, 1);
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Engine output paths enter JSON, so invalid Unicode must be refused even on a filesystem
/// that cannot create such a file (including macOS).
#[cfg(unix)]
#[test]
fn non_unicode_session_outputs_are_rejected() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = sandbox("nonunicode-outputs");
for (flag, name) in [("--bounce", b"caf\xe9.wav".as_slice()), ("--save", b"caf\xe9.scraft".as_slice())] {
let path = dir.join(OsString::from_vec(name.to_vec()));
let (mut out, mut err) = (Vec::new(), Vec::new());
let code = soundcraft_cli::run(vec!["run".into(), "--demo".into(), flag.into(), path.into_os_string()], &mut out, &mut err);
let err = String::from_utf8_lossy(&err);
assert_eq!(code, 1, "{err}");
assert!(err.contains("caf\u{FFFD}.") && err.contains("not valid Unicode"), "{err}");
assert!(out.is_empty());
}
let _ = std::fs::remove_dir_all(&dir);
}
+1
View File
@@ -9,3 +9,4 @@ mod closed_stdout;
mod man_and_completions;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod run;
mod script_status;
@@ -0,0 +1,90 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
static NEXT: AtomicU64 = AtomicU64::new(0);
let sequence = NEXT.fetch_add(1, Ordering::Relaxed);
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
let path = std::env::temp_dir().join(format!("soundcraft-script-status-{}-{stamp}-{sequence}", std::process::id()));
std::fs::create_dir(&path).unwrap();
Self(path)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn invoke(dir: &Path, args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).current_dir(dir).env("SOUNDCRAFT_NO_PREFS", "1").args(args).output().unwrap()
}
#[test]
fn keep_going_runs_later_commands_and_finishes_but_reports_failure() {
let dir = Scratch::new();
std::fs::write(
dir.0.join("audit.txt"),
"audit.deliberately_invalid {}\ntrack.new {\"name\":\"After failure\",\"count\":1,\"format\":\"stereo\"}\n",
)
.unwrap();
let stop = invoke(&dir.0, &["script", "audit.txt", "--save", "stopped.scraft"]);
assert_eq!(stop.status.code(), Some(1));
assert!(String::from_utf8_lossy(&stop.stderr).contains("audit.deliberately_invalid"));
assert!(!String::from_utf8_lossy(&stop.stdout).contains("track.new:"));
assert!(!dir.0.join("stopped.scraft").exists());
let keep = invoke(
&dir.0,
&["script", "audit.txt", "--keep-going", "--bounce", "after.wav", "--start", "0", "--end", "0.01", "--save", "after.scraft", "--inspect"],
);
assert_eq!(keep.status.code(), Some(1), "{}", String::from_utf8_lossy(&keep.stderr));
assert!(String::from_utf8_lossy(&keep.stderr).contains("audit.deliberately_invalid"));
let stdout = String::from_utf8_lossy(&keep.stdout);
assert!(stdout.contains("track.new:") && stdout.contains("After failure"), "{stdout}");
assert!(dir.0.join("after.wav").is_file());
let info = invoke(&dir.0, &["info", "after.scraft"]);
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
assert!(String::from_utf8_lossy(&info.stdout).contains("After failure"));
}
#[test]
fn successful_script_and_parsing_or_finish_failures_keep_their_status() {
let dir = Scratch::new();
std::fs::write(dir.0.join("good.txt"), "track.new {\"name\":\"Good\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
let success = invoke(&dir.0, &["script", "good.txt", "--keep-going"]);
assert!(success.status.success(), "{}", String::from_utf8_lossy(&success.stderr));
std::fs::write(dir.0.join("bad.txt"), "track.new {bad\ntrack.new {\"name\":\"Too late\"}\n").unwrap();
let malformed = invoke(&dir.0, &["script", "bad.txt", "--keep-going", "--save", "malformed.scraft"]);
assert_eq!(malformed.status.code(), Some(1));
assert!(String::from_utf8_lossy(&malformed.stderr).contains("bad JSON"));
assert!(!String::from_utf8_lossy(&malformed.stdout).contains("track.new:"));
assert!(!dir.0.join("malformed.scraft").exists());
std::fs::write(dir.0.join("parent"), "not a directory").unwrap();
for (option, path) in [("--save", "parent/out.scraft"), ("--bounce", "parent/out.wav")] {
let fail = invoke(&dir.0, &["script", "good.txt", "--keep-going", option, path]);
assert_eq!(fail.status.code(), Some(1), "{option}: {}", String::from_utf8_lossy(&fail.stderr));
assert!(String::from_utf8_lossy(&fail.stderr).contains("parent"));
}
std::fs::write(dir.0.join("engine.txt"), "session.save {}\ntrack.new {\"name\":\"After engine failure\",\"count\":1}\n").unwrap();
let fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "engine.scraft"]);
assert_eq!(fail.status.code(), Some(1));
assert!(String::from_utf8_lossy(&fail.stderr).contains("session.save"));
assert!(String::from_utf8_lossy(&fail.stdout).contains("track.new:"));
assert!(dir.0.join("engine.scraft").is_file());
let both_fail = invoke(&dir.0, &["script", "engine.txt", "--keep-going", "--save", "parent/out.scraft"]);
assert_eq!(both_fail.status.code(), Some(1));
let errors = String::from_utf8_lossy(&both_fail.stderr);
assert!(errors.contains("session.save") && errors.contains("parent"), "{errors}");
assert!(!errors.contains("script finished with command errors"), "{errors}");
}
+4
View File
@@ -61,3 +61,7 @@ display_name = "Matt Smith-Burlage"
[people.ProdByBuddha]
sync_github_name = true
[people.luigiPinna]
real_name = "Luigi Pinna"
sync_github_name = true
+35 -2
View File
@@ -580,13 +580,46 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
Ok((info, AudioBuffer { sample_rate, channels: planar }))
}
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
const AAC_FRAMES_PER_PACKET: u64 = 1024;
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
/// sample whose duration differs from a frame makes the two disagree.
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
reader.format_info().short_name == "isomp4"
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
}
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
let mut frames: u64 = 0;
loop {
match reader.next_packet() {
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
Ok(Some(_)) => {}
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
demux_failure(frames, e)?;
break;
}
}
}
Ok(frames)
}
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let reader = match open(bytes, detected)? {
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
let (_, info) = info_of(reader.as_ref(), detected)?;
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
}
if info.frames > 0 && info.channels > 0 {
return Ok(info);
}
+4 -1
View File
@@ -342,7 +342,10 @@ pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
let bytes = s.as_bytes();
let n = bytes.len().min(len);
let mut n = bytes.len().min(len);
while !s.is_char_boundary(n) {
n -= 1;
}
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
out.resize(out.len() + (len - n), 0);
}
+1
View File
@@ -4,6 +4,7 @@ mod decode_read_list;
mod file_format;
mod float_headroom;
mod malformed;
mod mp4_length;
mod roundtrip;
mod stream;
mod wav_empty_data;
+64
View File
@@ -208,6 +208,70 @@ fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
}
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
[id, &size.to_be_bytes(), body].concat()
}
/// The EBML header of a Matroska file.
fn ebml_header() -> Vec<u8> {
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
}
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
/// for the header, for codec data and for block frames.
#[test]
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
let huge = 1u64 << 47;
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
let reported = [
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
];
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
// A valid 16-bit PCM track entry's children, followed by `extra`.
let pcm = |extra: &[u8]| {
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
[
ebml(&[0xD7], 1, &[1]),
ebml(&[0x73, 0xC5], 1, &[1]),
ebml(&[0x83], 1, &[2]),
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
ebml(&[0xE1], audio.len() as u64, &audio),
extra.to_vec(),
]
.concat()
};
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
// segment is valid up to that read, which runs out of file.
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
let track = pcm(&[]);
let track = ebml(&[0xAE], track.len() as u64, &track);
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
for hint in [None, Some("mkv"), Some("wav")] {
assert!(probe(&reported, hint).is_err(), "{hint:?}");
assert!(decode(&reported, hint).is_err(), "{hint:?}");
// Refused at the oversized read itself, not by anything before it.
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
// The file and its track open, so decoding reaches the block; the block reader stops at the
// end of the file, a best-effort read of what is there.
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
}
}
#[test]
fn well_formed_caf_still_reads() {
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
+61
View File
@@ -0,0 +1,61 @@
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
use soundcraft_audio_io::{decode, probe};
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
atom(kind, &body)
}
/// An MPEG-4 descriptor with a one-byte length.
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
[&[tag, body.len() as u8][..], body].concat()
}
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
let dsi = descriptor(5, &[0x11, 0x88]);
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
let total = (packets - 1) * 1024 + last_dur;
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
let stbl = [
stsd,
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
full(b"stsc", &[1, 1, packets, 1]),
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
full(b"stco", &[1, offset]),
];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
}
#[test]
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
for last_dur in [1145, 546] {
let file = aac_mp4(10, last_dur);
let (_, buf) = decode(&file, Some("m4a")).unwrap();
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
}
}
+10
View File
@@ -316,3 +316,13 @@ fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}
#[test]
fn bwf_truncation_keeps_utf8_boundaries() {
let src = sine(48_000, 1, 10);
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
let out = probe(&bytes, None).unwrap().bwf.unwrap();
assert_eq!(out.originator, "a".repeat(31));
assert_eq!(out.description, "d".repeat(255));
}
+42
View File
@@ -199,6 +199,9 @@ impl<B: Backend> Server<B> {
/// Handle one JSON-RPC message; returns the reply line (None for notifications).
pub fn handle(&mut self, msg: &Value) -> Option<Value> {
if !msg.is_object() {
return Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request"}}));
}
let id = msg.get("id").cloned();
let method = msg.get("method").and_then(Value::as_str).unwrap_or("");
let params = msg.get("params").cloned().unwrap_or(json!({}));
@@ -255,6 +258,9 @@ impl<B: Backend> Server<B> {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Ok(Value::Array(batch)) if batch.is_empty() => {
Some(json!({"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "invalid request: empty batch"}}))
}
Ok(Value::Array(batch)) => {
let replies: Vec<Value> = batch.iter().filter_map(|m| self.handle(m)).collect();
if replies.is_empty() { None } else { Some(Value::Array(replies)) }
@@ -270,3 +276,39 @@ impl<B: Backend> Server<B> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Headless;
use soundcraft_engine::Engine;
fn reply(line: &str) -> String {
let mut server = Server::new(Headless::new(Engine::default()));
let mut out = Vec::new();
server.serve(line.as_bytes(), &mut out).unwrap();
String::from_utf8(out).unwrap()
}
#[test]
fn invalid_requests_get_an_invalid_request_error() {
for input in ["[]", "42", "[1]", "\"x\""] {
let text = reply(input);
assert_eq!(text.lines().count(), 1, "input {input}: reply was {text:?}");
let v: Value = serde_json::from_str(text.trim()).unwrap();
let e = match &v {
Value::Array(a) => a[0].clone(),
other => other.clone(),
};
assert_eq!(e["error"]["code"], -32600, "input {input}");
assert_eq!(e["id"], Value::Null, "input {input}");
}
}
#[test]
fn notifications_still_get_no_reply() {
assert_eq!(reply(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), "");
let v: Value = serde_json::from_str(reply(r#"{"jsonrpc":"2.0","id":1,"method":"ping"}"#).trim()).unwrap();
assert_eq!(v["id"], 1);
}
}
+60
View File
@@ -395,3 +395,63 @@ impl LoudnessMeter {
gain_to_db(self.true_peak)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn noise(len: usize, seed: u64, amp: f32) -> Vec<f32> {
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
(0..len)
.map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
((x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0) * amp
})
.collect()
}
/// True peak as first written: the history shifted every sample, each phase summed on its own.
fn true_peak_reference(coefs: &[[f32; TP_PHASES]; TP_TAPS], x: &[f32]) -> f32 {
let mut h = [0.0f32; TP_TAPS];
let mut tp = 0.0f32;
for v in x.iter().map(|v| if v.is_finite() { *v } else { 0.0 }) {
h.copy_within(0..TP_TAPS - 1, 1);
h[0] = v;
let mut peak = v.abs();
for p in 0..TP_PHASES {
let y: f32 = coefs.iter().zip(h.iter()).map(|(c, b)| c[p] * b).sum();
peak = peak.max(y.abs());
}
if peak > tp {
tp = peak;
}
}
tp
}
#[test]
fn true_peak_matches_the_shifted_history() {
let sr = 48_000.0;
let len = 20_000;
let mut l: Vec<f32> =
(0..len).map(|i| 0.6 * (std::f32::consts::TAU * 0.25 * i as f32 + 0.7).sin()).zip(noise(len, 1, 0.3)).map(|(a, b)| a + b).collect();
let r = noise(len, 2, 0.9);
l[123] = f32::NAN;
l[4_567] = f32::INFINITY;
for block in [1, 7, 480, len] {
let mut m = LoudnessMeter::new(sr, 2);
let want = true_peak_reference(&m.tp_coefs, &l).max(true_peak_reference(&m.tp_coefs, &r));
for at in (0..len).step_by(block) {
let n = block.min(len - at);
m.process(&[l[at..at + n].to_vec(), r[at..at + n].to_vec()], n);
}
assert_eq!(m.true_peak.to_bits(), want.to_bits(), "block {block}");
m.reset();
let quiet = noise(500, 3, 0.1);
m.process(&[quiet.clone(), quiet.clone()], 500);
assert_eq!(m.true_peak.to_bits(), true_peak_reference(&m.tp_coefs, &quiet).to_bits(), "after reset, block {block}");
}
}
}
+41
View File
@@ -671,3 +671,44 @@ impl Plugin for Maximizer {
self.gr_meter
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The window minimum as first written: the last `la` requests in a ring, scanned whole.
#[test]
fn window_min_matches_a_scan_of_the_last_requests() {
let mut x = 0x2545_f491_4f6c_dd1du64;
let mut next = move || {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
x
};
for la in [1usize, 2, 3, 7, 64, 480] {
let mut m = Maximizer::new();
m.win = vec![(0, 1.0); la];
let mut ring = vec![1.0f32; la];
let mut at = 0;
for i in 0..6_000 {
if i == 3_000 {
m.reset();
ring.iter_mut().for_each(|v| *v = 1.0);
at = 0;
}
let r = next();
let req = match r % 16 {
0 => f32::NAN,
1 => 1.0,
2 => 0.25,
_ => (r >> 40) as f32 / (1u64 << 24) as f32 * 1.5,
};
ring[at] = req;
at = (at + 1) % la;
let want = ring.iter().copied().fold(1.0f32, f32::min);
assert_eq!(m.window_min(req, la).to_bits(), want.to_bits(), "la {la}, sample {i}");
}
}
}
}
+14
View File
@@ -310,3 +310,17 @@ pub(crate) fn sort_events<T: Copy>(ev: &mut [T], key: impl Fn(&T) -> usize) {
pub(crate) fn note_hz(note: f32) -> f32 {
440.0 * 2f32.powf((note - 69.0) / 12.0)
}
#[cfg(test)]
mod tests {
#[test]
fn flush_matches_the_finite_and_magnitude_test() {
let edge = 1.0e-25f32.to_bits();
let special = [0, 0x8000_0000, 1, edge - 1, edge, edge + 1, 0x7f7f_ffff, 0x7f80_0000, 0xff80_0000, 0x7fc0_0000, 0xffc0_0001];
for b in (0..=u32::MAX).step_by(997).chain(special).chain(special.map(|b| b | 0x8000_0000)) {
let x = f32::from_bits(b);
let want = if x.is_finite() && x.abs() > 1.0e-25 { x } else { 0.0 };
assert_eq!(super::flush(x).to_bits(), want.to_bits(), "{b:#x}");
}
}
}
+2 -1
View File
@@ -255,7 +255,7 @@ impl Autosave {
/// Delete the folder: the session closed with nothing left to recover.
fn discard(mut self) {
drop(self.lock.take());
self.lock = None;
let _ = std::fs::remove_dir_all(&self.dir);
}
}
@@ -286,6 +286,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
let origin_saved = origin.as_ref().and_then(|o| std::fs::metadata(o).and_then(|m| m.modified()).ok());
let saved = std::fs::metadata(dir.join(SESSION)).and_then(|m| m.modified());
let recording = dir.join(RECORDING).is_file();
#[cfg(not(target_arch = "wasm32"))]
drop(lock);
match saved {
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
+3 -1
View File
@@ -28,6 +28,7 @@ mod plugin_state;
mod query;
pub(crate) mod sampler;
mod setup_more;
mod strip_silence;
mod surround;
mod tab;
mod track;
@@ -63,7 +64,7 @@ mod panic_probe {
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
pub use edit::strip_silence_scan;
pub use strip_silence::strip_silence_scan;
pub type Run = fn(&mut Engine, &Value) -> Result<Value>;
/// `Err(reason)` = disabled.
@@ -160,6 +161,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
v.extend(strip_silence::specs());
#[cfg(test)]
v.extend(panic_probe::specs());
v
+23 -1
View File
@@ -228,6 +228,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
nc.content = soundcraft_model::ClipContent::Audio { source: src, offset: 0 };
nc.length = out_len;
nc.stretch = 1.0;
nc.clamp_fades();
super::more_util::replace_clip(s, t, *id, nc);
rendered += 1;
}
@@ -246,7 +247,7 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some((src, off, len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
c.length = len.max(1);
c.fade_out.len = c.fade_out.len.min(c.length);
c.clamp_fades();
n += 1;
}
}
@@ -496,6 +497,27 @@ mod tests {
}
}
#[test]
fn warping_and_unwarping_keep_fades_within_the_clip() {
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
if let Some(c) = e.session_mut().find_clip_mut(cid) {
c.fade_in.len = 20_000;
c.fade_out.len = 20_000;
}
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 0.5})).unwrap();
let c = e.session().find_clip(cid).unwrap().1.clone();
assert!(c.fade_in.len <= c.length && c.fade_out.len <= c.length, "{} {} {}", c.length, c.fade_in.len, c.fade_out.len);
let (mut e, _, cid) = elastic_clip(20_000, 10_000, 20_000);
e.execute("clip.elastic_properties", &json!({"clips": [cid.0], "ratio": 2.0})).unwrap();
if let Some(c) = e.session_mut().find_clip_mut(cid) {
c.fade_in.len = 40_000;
}
e.execute("clip.remove_warp", &json!({"clips": [cid.0]})).unwrap();
let c = e.session().find_clip(cid).unwrap().1.clone();
assert!(c.fade_in.len <= c.length, "{} {}", c.length, c.fade_in.len);
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();
+3 -42
View File
@@ -1,7 +1,7 @@
//! Edit menu commands.
use super::{
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, has_clipboard, has_range, has_selection, has_tracks,
i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
};
use soundcraft_time::Samples;
@@ -125,15 +125,6 @@ pub fn specs() -> Vec<CommandSpec> {
has_range,
strip_silence
),
cmd!(
query "edit.strip_silence_preview",
"Strip Silence Rectangles",
[],
None,
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
has_range,
strip_silence_preview
),
cmd!(
"edit.fades_create",
"Create...",
@@ -821,38 +812,8 @@ fn copy_to_new_playlist(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"tracks": tracks.len()}))
}
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
/// command in errors.
fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
let tracks = tracks_required(e, cmd, p)?;
let r = range_param(e, cmd, p)?;
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
}
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
/// can be started.
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
}
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
let found = strip_silence_scan(e, p)?.run()?;
let tracks: Vec<Value> =
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
Ok(json!({"tracks": tracks}))
}
fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
let (tracks, r, thr, min_len, (pre, post)) = strip_settings(e, "edit.strip_silence", p)?;
let (tracks, r, thr, min_len, (pre, post)) = super::strip_silence::strip_settings(e, "edit.strip_silence", p)?;
let mode = match p.get("mode") {
None => crate::io::StripMode::Strip,
Some(Value::String(m)) => crate::io::StripMode::from_id(m).ok_or_else(|| bad("edit.strip_silence", format!("unknown mode `{m}`")))?,
+34 -1
View File
@@ -163,6 +163,13 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
if m.start >= r.start {
m.start += r.len();
m.end += r.len();
} else if m.end > r.start {
m.end += r.len();
}
}
for (at, _) in &mut s.key_signatures {
if *at >= r.start {
*at = at.saturating_add(r.len());
}
}
Ok(json!({"inserted": r.len()}))
@@ -190,6 +197,16 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
if m.start >= r.end {
m.start -= r.len();
m.end -= r.len();
} else if m.end >= r.end {
m.end -= r.len();
} else if m.end > r.start {
m.end = r.start;
}
}
s.key_signatures.retain(|(at, _)| !r.contains(*at));
for (at, _) in &mut s.key_signatures {
if *at >= r.end {
*at -= r.len();
}
}
Ok(json!({"removed": r.len()}))
@@ -302,7 +319,10 @@ fn add_marker(e: &mut Engine, p: &Value) -> Result<Value> {
(None, Some(a), Some(b)) => (a, b),
(None, Some(p), None) | (None, None, Some(p)) => (p, p),
_ if kind == MarkerKind::Selection => (sel.start, sel.end),
_ => (if e.transport.playing { e.transport.position } else { sel.start }, sel.start),
_ => {
let point = if e.transport.playing { e.transport.position } else { sel.start };
(point, point)
}
};
let s = e.session_mut();
let n = s.markers.len() + 1;
@@ -473,6 +493,19 @@ mod tests {
assert_eq!(find(&c), Some((1000, 2000)));
}
/// A plain marker added during playback is a point at the playhead, whatever the edit selection is.
#[test]
fn markers_add_while_playing_is_a_point_at_the_playhead() {
let mut e = demo_engine();
e.session_mut().edit.selection.start = 48_000;
e.session_mut().edit.selection.end = 48_000;
e.transport.playing = true;
e.transport.position = 4800;
let r = e.execute("markers.add", &json!({"name": "M"})).unwrap();
let m = e.session().markers.iter().find(|m| Some(m.id.0) == r["id"].as_u64()).map(|m| (m.start, m.end));
assert_eq!(m, Some((4800, 4800)));
}
/// `markers.edit` documents `color`: it sets the memory location's RGB colour, undoably; `null` clears it.
#[test]
fn edit_marker_sets_documented_color() {
+5 -1
View File
@@ -335,7 +335,11 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
return Err(bad(id, "no MIDI or instrument tracks to notate"));
}
let setup = crate::score::ScoreSetup::from_session(e.session());
let (text, ext) = if svg { (crate::score::svg(&parts, &setup), "svg") } else { (crate::score::musicxml(&parts, &setup), "musicxml") };
let (text, ext) = if svg {
(crate::score::svg(&parts, &setup).map_err(|m| bad(id, m))?, "svg")
} else {
(crate::score::musicxml(&parts, &setup).map_err(|m| bad(id, m))?, "musicxml")
};
let path = match str_param(p, "path") {
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
+36
View File
@@ -124,6 +124,12 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
let dt = i64_or(p, "ticks", 0);
let ds = i64_or(p, "semitones", 0);
let seq = seq_mut(e, id, "midi.notes_move")?;
// Clamp the shared delta so the earliest selected note stops at tick 0 and the selection keeps its spacing.
let earliest = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| n.start).min();
let dt = match earliest {
Some(m) => dt.max(0i64.saturating_sub(m).min(0)),
None => dt,
};
for (i, n) in seq.notes.iter_mut().enumerate() {
if idx.contains(&i) {
n.start = n.start.saturating_add(dt).max(0);
@@ -168,6 +174,36 @@ mod tests {
assert!(e.execute("midi.note_edit", &json!({"clip": clip, "index": 99})).is_err());
}
#[test]
fn notes_move_left_past_zero_keeps_spacing() {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let r = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap();
let clip = r["clip"].as_u64().unwrap();
for s in [100, 200, 300] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
}
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": -150})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
assert_eq!(starts, [0, 100, 300]);
}
#[test]
fn notes_move_without_left_overflow_does_not_shift_selection() {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
for s in [0, 100] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": s})).unwrap();
}
seq_mut(&mut e, ClipId(clip), "test").unwrap().notes[0].start = -50;
e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0, 1], "ticks": 0})).unwrap();
let n = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
let starts: Vec<i64> = n["notes"].as_array().unwrap().iter().map(|x| x["start"].as_i64().unwrap()).collect();
assert_eq!(starts, [0, 100]);
}
#[test]
fn notes_move_huge_params_do_not_overflow() {
let mut e = Engine::default();
+42
View File
@@ -155,6 +155,14 @@ pub fn render_stretched(s: &mut Session, cmd: &str, c: &Clip, out_len: Samples)
if !(0.1..=10.0).contains(&ratio) {
return Err(bad(cmd, format!("stretch ratio {ratio:.3} is outside 0.1..10")));
}
// The source span read in can be 10× the output, and both are held for every channel: bound
// each like any other offline render (a session's clip length and stretch are not trusted).
let channels = audio.buffer.channels.len().max(1);
let limit = crate::io::render_limit(channels);
if src_len > limit as f64 || usize::try_from(out_len).map_or(true, |n| n > limit) {
let secs = limit as f64 / s.sample_rate.as_f64();
return Err(bad(cmd, format!("the clip is too long to stretch: one render holds at most {secs:.0} s of {channels}-channel audio")));
}
let a = usize::try_from(offset.max(0)).unwrap_or(0);
let n = src_len as usize;
let ch: Vec<Vec<f32>> = audio
@@ -213,4 +221,38 @@ mod tests {
s.sample_rate = soundcraft_time::SampleRate::new(96_000).unwrap();
assert_eq!(max_render(&s), soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as Samples);
}
/// A stereo session with a one-frame source and one clip on it.
fn stretched_clip(length: Samples, stretch: f64) -> (Session, Clip) {
use soundcraft_model::{ChannelFormat, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);
let cid = s.new_clip_id();
let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);
clip.stretch = stretch;
crate::edit::place_clip(&mut s, t, clip.clone());
(s, clip)
}
/// #895: a session's clip length and stretch made the source span read in 10× the one-hour
/// output limit, for every channel (about 14 GB here), and the allocation aborted the app.
#[test]
fn a_stretch_over_the_render_limit_is_refused_before_allocating() {
let hour = Session::default().sample_rate.samples(3600.0);
let (mut s, clip) = stretched_clip(hour, 0.1);
let err = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap_err();
assert!(err.to_string().contains("too long to stretch"), "{err}");
let mut e = crate::Engine::new(s);
assert!(e.execute("clip.remove_pitch_shift", &serde_json::json!({"clips": [clip.id.0]})).is_err());
let (_, kept) = e.session().find_clip(clip.id).unwrap();
assert_eq!((kept.stretch, kept.length), (0.1, hour), "the clip is left as it was");
// An ordinary stretch still renders, at the length asked for.
let (mut s, clip) = stretched_clip(1_000, 0.5);
let src = render_stretched(&mut s, "clip.remove_pitch_shift", &clip, clip.length).unwrap().unwrap();
assert!(s.pool.get(src).unwrap().buffer.channels.iter().all(|c| c.len() == 1_000));
}
}
+132 -16
View File
@@ -42,13 +42,24 @@ pub fn specs() -> Vec<CommandSpec> {
always,
prepare_dpe
),
cmd!(noundo "setup.hardware", "Hardware...", ["Setup"], None, "{device?, buffer_size?: 16..8192} — audio interface settings (sample rate is the session's)", always, |e, p| engine_settings(e, p)),
cmd!(noundo "setup.playback_engine", "Playback Engine...", ["Setup"], None, "{device?, buffer_size?: 16..8192, delay_compensation?: bool}", always, |e, p| {
if let Some(v) = p.get("delay_compensation").and_then(Value::as_bool) {
e.session_mut().edit.delay_compensation = v;
}
engine_settings(e, p)
}),
cmd!(
noundo "setup.hardware",
"Hardware...",
["Setup"],
None,
"{device?: output name, buffer_size?: 16..=8192 or null} — output device and buffer. Applied only in the desktop app (Playback Engine window / ui.audio_device), which returns the open device. Headless stores the request and returns applied: false; buffer_size is null when unset. Any integer in range, not rounded to the window's power-of-two sizes. Sample rate is the session's",
always,
|e, p| engine_settings(e, p, "setup.hardware")
),
cmd!(
noundo "setup.playback_engine",
"Playback Engine...",
["Setup"],
None,
"{device?: output name, buffer_size?: 16..=8192 or null, delay_compensation?: bool} — device and buffer as setup.hardware (applied only in the desktop app, or via ui.audio_device); delay_compensation is applied here too. Headless returns applied: false and does not invent a buffer",
always,
|e, p| engine_settings(e, p, "setup.playback_engine")
),
cmd!(noundo "setup.disk_allocation", "Disk Allocation...", ["Setup"], None, "{path?, track?} — the record folder for the session, or for one track", always, disk_allocation),
cmd!(noundo "setup.peripherals", "Peripherals...", ["Setup"], None, "{settings?: {name: number|bool|string}} — control surface / sync device settings", always, peripherals),
cmd!(
@@ -156,18 +167,69 @@ fn prepare_dpe(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"prepared": n}))
}
fn engine_settings(e: &mut Engine, p: &Value) -> Result<Value> {
/// A `buffer_size` argument: absent, cleared (`null`), or an integer in 16..=8192.
enum BufferParam {
Skip,
Clear,
Set(u64),
}
/// Same range as `ui.audio_device`. Out of range and non-numbers error; there is no clamp and no
/// power-of-two rounding (the Playback Engine window lists those sizes, the command keeps the number).
fn buffer_param(id: &str, p: &Value) -> Result<BufferParam> {
match p.get("buffer_size") {
None => Ok(BufferParam::Skip),
Some(Value::Null) => Ok(BufferParam::Clear),
Some(v) => {
let n = v.as_u64().filter(|n| (16..=8192).contains(n)).ok_or_else(|| bad(id, "`buffer_size` must be a number in 16..=8192 or null"))?;
Ok(BufferParam::Set(n))
}
}
}
/// `device` as `ui.audio_device`'s `output`: a name, or `null`/empty to clear. Absent leaves it.
fn device_param<'a>(id: &str, p: &'a Value) -> Result<Option<Option<&'a str>>> {
match p.get("device") {
None => Ok(None),
Some(Value::Null) => Ok(Some(None)),
Some(Value::String(name)) => Ok(Some((!name.is_empty()).then_some(name.as_str()))),
Some(_) => Err(bad(id, "`device` must be a string or null")),
}
}
/// Headless `setup.hardware` / `setup.playback_engine`. Playback never reads `engine.device` or
/// `engine.buffer_size` (only this command does); they are kept so a later call can report the
/// request. The result says `applied: false` and leaves `buffer_size` null until one was stored.
fn engine_settings(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
let buffer = buffer_param(id, p)?;
let device = device_param(id, p)?;
if id == "setup.playback_engine"
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
{
e.session_mut().edit.delay_compensation = on;
}
let s = e.session_mut();
if let Some(b) = p.get("buffer_size").and_then(Value::as_u64) {
let b = b.clamp(16, 8192).next_power_of_two().min(8192);
s.edit.values.insert("engine.buffer_size".into(), b as f64);
match buffer {
BufferParam::Skip => {}
BufferParam::Clear => {
s.edit.values.remove("engine.buffer_size");
}
BufferParam::Set(n) => {
s.edit.values.insert("engine.buffer_size".into(), n as f64);
}
}
if let Some(d) = str_param(p, "device") {
set_text(&mut s.edit, "engine.device", Some(d));
if let Some(name) = device {
set_text(&mut s.edit, "engine.device", name);
}
// Exact integers only: a hand-edited fraction is not reported as a buffer size.
let buffer_size = s.edit.values.get("engine.buffer_size").copied().and_then(|v| {
let n = v as u64;
(v.is_finite() && (n as f64) == v).then_some(n)
});
Ok(json!({
"applied": false,
"device": text(&s.edit, "engine.device"),
"buffer_size": s.edit.value("engine.buffer_size", 1024.0),
"buffer_size": buffer_size,
"sample_rate": s.sample_rate.hz(),
"bit_depth": s.bit_depth,
"delay_compensation": s.edit.delay_compensation,
@@ -307,7 +369,7 @@ fn current_timecode(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let at = s.edit.selection.start.max(0);
let abs = tc.to_samples(s.frame_rate, s.sample_rate);
s.timecode_start = abs - at;
s.timecode_start = abs.saturating_sub(at).clamp(-soundcraft_time::MAX_POSITION, soundcraft_time::MAX_POSITION);
Ok(json!({"timecode_start": s.timecode_start, "at": at}))
}
@@ -567,6 +629,20 @@ mod tests {
assert_eq!(shown, "01:00:00:00");
}
#[test]
fn current_timecode_out_of_range_is_clamped() {
use soundcraft_time::MAX_POSITION;
for tc in ["-9223372036854775807:00:00:00", "9999999:00:00:00"] {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 48_000})).unwrap();
e.execute("setup.current_timecode", &json!({"timecode": tc})).unwrap();
let start = e.session().timecode_start;
assert!((-MAX_POSITION..=MAX_POSITION).contains(&start), "{tc}: start {start}");
let reloaded = Session::from_json(&e.session().to_json().unwrap()).unwrap();
assert_eq!(reloaded.timecode_start, start, "{tc}");
}
}
#[test]
fn click_countoff_settings() {
let mut e = Engine::default();
@@ -587,7 +663,8 @@ mod tests {
fn engine_and_midi_settings() {
let mut e = Engine::default();
let r = e.execute("setup.playback_engine", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
assert_eq!(r["buffer_size"], 512.0);
assert_eq!(r["applied"], false);
assert_eq!(r["buffer_size"], 300);
assert_eq!(r["device"], "Interface");
let r = e.execute("setup.midi_input_filter", &json!({"filter": ["sysex", "bogus"]})).unwrap();
assert_eq!(r["filtered"], json!(["sysex"]));
@@ -597,6 +674,45 @@ mod tests {
assert_eq!(r["samples"], 3200.0);
}
#[test]
fn playback_engine_does_not_pretend_the_device_changed() {
let mut e = Engine::default();
let r = e.execute("setup.playback_engine", &json!({})).unwrap();
assert_eq!(r["applied"], false);
assert!(r["buffer_size"].is_null(), "no invented default buffer: {r}");
assert!(r["device"].is_null());
assert_eq!(r["delay_compensation"], true, "the session default, not a device claim");
let r = e.execute("setup.hardware", &json!({"buffer_size": 300, "device": "Interface"})).unwrap();
assert_eq!(r["applied"], false);
assert_eq!(r["buffer_size"], 300, "kept, not rounded to a power of two");
assert_eq!(r["device"], "Interface");
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
let r = e.execute("setup.playback_engine", &json!({"delay_compensation": false})).unwrap();
assert_eq!(r["applied"], false);
assert_eq!(r["delay_compensation"], false);
assert_eq!(r["buffer_size"], 300, "delay compensation does not clear a stored buffer");
assert!(!e.session().edit.delay_compensation);
let err = e.execute("setup.playback_engine", &json!({"buffer_size": 8, "delay_compensation": true, "device": "Other"})).unwrap_err();
assert!(err.to_string().contains("16..=8192"), "{err}");
assert!(!e.session().edit.delay_compensation, "an invalid buffer changes nothing");
assert_eq!(e.session().edit.value("engine.buffer_size", -1.0), 300.0);
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
assert!(e.execute("setup.hardware", &json!({"buffer_size": "big"})).is_err());
let err = e.execute("setup.playback_engine", &json!({"device": 42, "delay_compensation": true})).unwrap_err();
assert!(err.to_string().contains("`device` must be a string or null"), "{err}");
assert!(!e.session().edit.delay_compensation, "an invalid device changes nothing");
assert_eq!(text(&e.session().edit, "engine.device").as_deref(), Some("Interface"));
let r = e.execute("setup.hardware", &json!({"buffer_size": null, "device": ""})).unwrap();
assert_eq!(r["applied"], false);
assert!(r["buffer_size"].is_null());
assert!(r["device"].is_null());
}
#[test]
fn import_session_data_brings_tracks_and_audio() {
let dir = std::env::temp_dir().join(format!("sc-import-{}", std::process::id()));
+49
View File
@@ -0,0 +1,49 @@
//! Strip Silence Rectangles: the ranges Strip Silence keeps, which the Strip Silence window draws.
use super::{CommandSpec, f32_or, f64_or, has_range, range_param, tracks_required};
use soundcraft_model::TrackId;
use soundcraft_time::Samples;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
query "edit.strip_silence_preview",
"Strip Silence Rectangles",
[],
None,
"{threshold_db?: -48, min_length_ms?: 50, pad_before_ms?: 5, pad_after_ms?: 20, tracks?, start?, end?} (the ranges edit.strip_silence finds with the same settings, padded, on each track: what Strip keeps and the Strip Silence window draws as rectangles; changes nothing)",
has_range,
strip_silence_preview
)]
}
/// Strip Silence's tracks, range, threshold, minimum strip duration and clip start and end pads.
pub(super) type StripSettings = (Vec<TrackId>, soundcraft_time::Range, f32, Samples, (Samples, Samples));
/// The [`StripSettings`] in Strip Silence's params, with their defaults and limits. `cmd` names the
/// command in errors.
pub(super) fn strip_settings(e: &Engine, cmd: &str, p: &Value) -> Result<StripSettings> {
let tracks = tracks_required(e, cmd, p)?;
let r = range_param(e, cmd, p)?;
let thr = f32_or(p, "threshold_db", -48.0).clamp(-96.0, 0.0);
let sr = e.session().sample_rate;
let ms = |k: &str, d: f64| sr.samples(f64_or(p, k, d).clamp(0.0, 10_000.0) / 1000.0);
Ok((tracks, r, thr, ms("min_length_ms", 50.0), (ms("pad_before_ms", 5.0), ms("pad_after_ms", 20.0))))
}
/// The scan `edit.strip_silence_preview` runs with params `p`, on the session as it is now. The Edit
/// window runs it while the Strip Silence window or its Rename Selected Clips dialog is open: on a
/// worker thread outside the web build, and on the frame thread in the web build or when no thread
/// can be started.
pub fn strip_silence_scan(e: &Engine, p: &Value) -> Result<crate::io::StripScan> {
let (tracks, range, threshold_db, min_len, pad) = strip_settings(e, "edit.strip_silence_preview", p)?;
Ok(crate::io::StripScan { session: e.session_arc(), tracks, range, threshold_db, min_len, pad, stop: Default::default() })
}
fn strip_silence_preview(e: &mut Engine, p: &Value) -> Result<Value> {
let found = strip_silence_scan(e, p)?.run()?;
let tracks: Vec<Value> =
found.iter().map(|(t, ranges)| json!({"track": t.0, "ranges": ranges.iter().map(|x| [x.start, x.end]).collect::<Vec<_>>()})).collect();
Ok(json!({"tracks": tracks}))
}
+39 -1
View File
@@ -590,6 +590,20 @@ fn owned_folder_bus(s: &Session, folder: TrackId) -> Option<soundcraft_model::Bu
}
}
/// Whether a bus is still routed through by a track that survives deleting
/// `deleted`, ignoring the output slots of `folder`'s own members, which the
/// delete reroutes to the parent folder's bus. A folder owns its input bus
/// only when this is false: `track.input` or a session file can point a
/// folder at a bus that other tracks already use (#914).
fn bus_used_beyond_folder(s: &Session, bus: soundcraft_model::BusId, deleted: &[TrackId], folder: TrackId) -> bool {
let route = Route::Bus(bus);
s.tracks.iter().filter(|track| !deleted.contains(&track.id)).any(|track| {
track.mixer.input == route
|| track.mixer.sends.iter().flatten().any(|send| send.target == route)
|| (track.mixer.output == route && !in_folder(s, track.id, folder))
})
}
/// Keep only the selected roots so selecting a folder and one of its children preserves the subtree.
fn root_tracks(s: &Session, tracks: Vec<TrackId>) -> Vec<TrackId> {
tracks.iter().copied().filter(|track| !tracks.iter().any(|root| root != track && in_folder(s, *track, *root))).collect()
@@ -708,7 +722,11 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
.filter_map(|folder| {
let bus = owned_folder_bus(s, *folder)?;
let parent = surviving_parent(s, &tracks, *folder);
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
let replacement = parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main);
// A bus that surviving tracks route through is shared, not the
// folder's own: keep it, and every route to it, when the folder
// goes (#914).
(!bus_used_beyond_folder(s, bus, &tracks, *folder)).then_some((bus, replacement))
})
.collect();
// Promote children of deleted folders and repair only routes that used the deleted
@@ -1129,6 +1147,26 @@ mod tests {
assert!(e.session().bus(outer_bus).is_some(), "the surviving parent folder's bus must remain");
}
#[test]
fn deleting_a_folder_keeps_a_bus_other_tracks_still_use() {
let mut e = crate::demo::demo_engine();
// #914: the demo session's Verb bus is shared: an aux takes it as its input
// and several tracks send to it. Point a new folder's input at it, the way
// `track.input` or a session file can, then delete the folder.
let verb = e.session().bus_by_name("Verb").unwrap().id;
let folder = e.execute("track.new", &json!({"kind": "folder", "name": "Shared"})).unwrap();
let folder = TrackId(folder["tracks"][0].as_u64().unwrap());
e.execute("track.input", &json!({"tracks": [folder.0], "input": "Verb"})).unwrap();
assert_eq!(e.session().track(folder).unwrap().mixer.input, Route::Bus(verb));
e.execute("track.delete", &json!({"tracks": [folder.0]})).unwrap();
assert!(e.session().track(folder).is_none(), "the folder itself is deleted");
assert!(e.session().bus(verb).is_some(), "a bus other tracks route through must survive");
let aux = e.session().track_by_name("Verb").unwrap();
assert_eq!(aux.mixer.input, Route::Bus(verb), "the aux input must keep the bus");
let snare = e.session().track_by_name("Snare").unwrap();
assert!(snare.mixer.sends.iter().flatten().any(|send| send.target == Route::Bus(verb)), "sends to the shared bus must survive");
}
#[test]
fn heights_take_points_and_step_through_the_presets() {
let mut e = crate::demo::demo_engine();
+265
View File
@@ -0,0 +1,265 @@
//! Borrowed document equality for the saved-state indicator.
use soundcraft_model::{EditState, Session};
/// Equal apart from the selection, playhead and zoom, without copying document data.
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
}
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
// No `..`: adding a document field must also decide how it affects saved-state equality.
let Session {
format_version,
name,
sample_rate,
bit_depth,
frame_rate,
timecode_start,
tempo,
key_signatures,
tracks,
sources,
videos,
busses,
outputs,
markers,
groups,
auto_names,
edit,
next_id,
comments,
midi_originals,
pool,
folder,
} = s;
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
(
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
)
}
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
let EditState {
tool,
edit_mode,
selection: _,
selected_tracks: _,
selected_clips: _,
selected_sources,
playhead: _,
grid,
nudge,
grid_lines,
main_counter,
sub_counter,
zoom: _,
zoom_toggle,
rulers,
link_timeline_edit,
link_track_edit,
insertion_follows_playback,
loop_playback,
pre_post_roll,
pre_roll,
post_roll,
tab_to_transient,
automation_follows_edit,
markers_follow_edit,
layered_editing,
mirrored_midi,
click,
countoff,
countoff_bars,
midi_merge,
record_mode,
loop_record,
quickpunch,
scrolling,
solo_mode,
pre_fader_metering,
delay_compensation,
keyboard_focus,
timeline_selection: _,
flags,
values,
} = edit;
// Borrow the saved navigation on both sides instead of overwriting a document clone.
// Keeping these values in the comparison also preserves PartialEq for non-finite zooms.
(
(
tool,
edit_mode,
&navigation.selection,
&navigation.selected_tracks,
&navigation.selected_clips,
selected_sources,
&navigation.playhead,
grid,
nudge,
grid_lines,
main_counter,
sub_counter,
),
(
&navigation.zoom,
zoom_toggle,
rulers,
link_timeline_edit,
link_track_edit,
insertion_follows_playback,
loop_playback,
pre_post_roll,
pre_roll,
post_roll,
tab_to_transient,
automation_follows_edit,
),
(
markers_follow_edit,
layered_editing,
mirrored_midi,
click,
countoff,
countoff_bars,
midi_merge,
record_mode,
loop_record,
quickpunch,
scrolling,
solo_mode,
),
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
)
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackId, TrackKind, ZoomToggle};
use soundcraft_time::{Range, TimeFormat};
use std::sync::Arc;
// The previous comparison is an oracle for preserving the saved-state policy.
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
assert_eq!(same_document(saved, doc), expected, "{field}");
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
assert_eq!(same_document(saved, doc), previous_same_document(saved, doc), "{field}, previous policy");
}
#[test]
fn navigation_fields_are_ignored() {
let saved = Session::default();
let mut doc = saved.clone();
doc.edit.selection = Range::new(100, 200);
doc.edit.timeline_selection = Range::new(300, 400);
doc.edit.selected_tracks = vec![TrackId(50)];
doc.edit.selected_clips = vec![ClipId(60)];
doc.edit.playhead = 500;
doc.edit.zoom.samples_per_px = 64.0;
doc.edit.zoom.scroll = 600;
doc.edit.zoom.waveform_zoom = 2.0;
doc.edit.zoom.presets = [1.0; 5];
check(&saved, &doc, true, "navigation");
}
#[test]
fn persisted_edit_settings_still_change_document_equality() {
let saved = Session::default();
let edits: &[(&str, fn(&mut EditState))] = &[
("tool", |e| e.tool = soundcraft_model::Tool::Grabber),
("edit_mode", |e| e.edit_mode = soundcraft_model::EditMode::Grid),
("selected_sources", |e| e.selected_sources.push(SourceId(1))),
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
("grid_lines", |e| e.grid_lines = !e.grid_lines),
("main_counter", |e| e.main_counter = TimeFormat::Samples),
("sub_counter", |e| e.sub_counter = None),
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
("rulers", |e| e.rulers.clear()),
("link_timeline_edit", |e| e.link_timeline_edit = !e.link_timeline_edit),
("link_track_edit", |e| e.link_track_edit = !e.link_track_edit),
("insertion_follows_playback", |e| e.insertion_follows_playback = !e.insertion_follows_playback),
("loop_playback", |e| e.loop_playback = !e.loop_playback),
("pre_post_roll", |e| e.pre_post_roll = !e.pre_post_roll),
("pre_roll", |e| e.pre_roll += 1),
("post_roll", |e| e.post_roll += 1),
("tab_to_transient", |e| e.tab_to_transient = !e.tab_to_transient),
("automation_follows_edit", |e| e.automation_follows_edit = !e.automation_follows_edit),
("markers_follow_edit", |e| e.markers_follow_edit = !e.markers_follow_edit),
("layered_editing", |e| e.layered_editing = !e.layered_editing),
("mirrored_midi", |e| e.mirrored_midi = !e.mirrored_midi),
("click", |e| e.click = !e.click),
("countoff", |e| e.countoff = !e.countoff),
("countoff_bars", |e| e.countoff_bars += 1),
("midi_merge", |e| e.midi_merge = !e.midi_merge),
("record_mode", |e| e.record_mode = "destructive".into()),
("loop_record", |e| e.loop_record = !e.loop_record),
("quickpunch", |e| e.quickpunch = !e.quickpunch),
("scrolling", |e| e.scrolling = "continuous".into()),
("solo_mode", |e| e.solo_mode = "afl".into()),
("pre_fader_metering", |e| e.pre_fader_metering = !e.pre_fader_metering),
("delay_compensation", |e| e.delay_compensation = !e.delay_compensation),
("keyboard_focus", |e| e.keyboard_focus = "midi".into()),
("flags", |e| {
e.flags.insert("test.flag".into());
}),
("values", |e| {
e.values.insert("test.value".into(), 0.5);
}),
];
for &(field, edit) in edits {
let mut doc = saved.clone();
edit(&mut doc.edit);
check(&saved, &doc, false, field);
}
}
#[test]
fn track_and_media_changes_still_change_document_equality() {
let mut saved = Session::default();
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100);
saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
let audio =
Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
saved.pool.insert(SourceId(900), audio);
check(&saved, &saved.clone(), true, "shared decoded audio");
let mut doc = saved.clone();
doc.track_mut(track).unwrap().playlist_mut().unwrap().clips[0].gain_db = -6.0;
check(&saved, &doc, false, "clip gain");
let mut doc = saved.clone();
doc.pool.remove(SourceId(900));
check(&saved, &doc, false, "decoded audio removed");
let mut doc = saved.clone();
doc.folder = Some(std::path::PathBuf::from("session"));
check(&saved, &doc, false, "session folder");
let mut doc = saved.clone();
doc.edit.values.insert("test.nan".into(), f64::NAN);
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
}
#[test]
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
let mut saved = Session::default();
let doc = saved.clone();
saved.edit.zoom.waveform_zoom = f32::NAN;
assert!(!same_document(&saved, &doc));
assert!(same_document(&doc, &saved));
assert_eq!(same_document(&saved, &doc), previous_same_document(&saved, &doc));
assert_eq!(same_document(&doc, &saved), previous_same_document(&doc, &saved));
}
}
+7 -2
View File
@@ -238,13 +238,16 @@ pub fn paste(s: &mut Session, track: TrackId, at: Samples, clips: &[Clip], lengt
make_room(s, track, at, length)?;
}
let mut ids = Vec::new();
let mut placed = Vec::new();
for c in clips {
let mut nc = c.clone();
nc.id = s.new_clip_id();
nc.start += at;
ids.push(nc.id);
place_clip(s, track, nc);
placed.push((track, nc));
}
// Clear every destination first, so pasted clips that overlap (crossfades) keep each other.
put_clips(s, placed);
Ok(ids)
}
@@ -523,8 +526,10 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
}
}
let n = moving.len();
// Limit the shift once, so the group stops at zero together and keeps its spacing.
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta).max(0);
c.start = c.start.saturating_add(delta);
(lands(dest, tid), c)
});
put_clips(s, placed.collect());
+21
View File
@@ -343,3 +343,24 @@ fn delete_fades_leaves_unrelated_overlaps_alone() {
let c = s.track(t).unwrap().clips();
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
}
#[test]
fn paste_keeps_crossfade_overlap() {
let (mut s, t) = session_with_clip();
let (a, b) = (s.new_clip_id(), s.new_clip_id());
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.clear();
let mut ca = Clip::audio(a, "a", SourceId(500), 0, 1000, 2000);
ca.fade_out = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
let mut cb = Clip::audio(b, "b", SourceId(500), 0, 2500, 2500);
cb.fade_in = Fade { len: 500, shape: FadeShape::Linear, curvature: 0.0 };
pl.clips.push(ca);
pl.clips.push(cb);
pl.sort();
}
let copied = copy_range(&s, t, Range::new(0, 6000));
paste(&mut s, t, 20_000, &copied, 6000, false).unwrap();
let pasted: Vec<_> =
s.track(t).unwrap().clips().iter().filter(|c| c.start >= 20_000).map(|c| (c.start, c.length, c.fade_out.len, c.fade_in.len)).collect();
assert_eq!(pasted, vec![(21_000, 2000, 500, 0), (22_500, 2500, 0, 500)]);
}
+14 -1
View File
@@ -115,7 +115,7 @@ pub fn session_text(e: &Engine) -> String {
out.push_str(&format!("SESSION NAME:\t{}\nSAMPLE RATE:\t{}\nBIT DEPTH:\t{:?}\nTIMECODE FORMAT:\t{}\n# OF AUDIO TRACKS:\t{}\n# OF AUDIO CLIPS:\t{}\n# OF AUDIO FILES:\t{}\n\n",
s.name, s.sample_rate.hz(), s.bit_depth, s.frame_rate.label(),
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).count(),
s.tracks.iter().map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
s.tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum::<usize>(), s.sources.len()));
out.push_str("F I L E S I N S E S S I O N\nFilename\tLocation\n");
for src in &s.sources {
out.push_str(&format!("{}\t{}\n", src.name, src.path));
@@ -156,6 +156,19 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn session_text_counts_only_audio_track_clips() {
let mut e = Engine::default();
let t = e.execute("track.new", &json!({"kind": "midi"})).unwrap()["tracks"][0].clone();
e.execute("midi.new_clip", &json!({"track": t, "start": 0, "length": 48000})).unwrap();
let text = session_text(&e);
assert!(text.contains("# OF AUDIO CLIPS:\t0\n"), "{text}");
let demo = crate::demo::demo_engine();
let n: usize = demo.session().tracks.iter().filter(|t| t.kind == soundcraft_model::TrackKind::Audio).map(|t| t.clips().len()).sum();
assert!(session_text(&demo).contains(&format!("# OF AUDIO CLIPS:\t{n}\n")));
}
#[test]
fn sources_report_whether_audio_is_loaded() {
let mut e = crate::demo::demo_engine();
+1
View File
@@ -598,6 +598,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
let mut dst = std::fs::OpenOptions::new().write(true).create_new(true).open(to)?;
if let Err(err) = std::io::copy(&mut src, &mut dst) {
#[cfg(not(target_arch = "wasm32"))]
drop(dst);
let _ = std::fs::remove_file(to);
return Err(err);
+30 -13
View File
@@ -11,6 +11,7 @@ pub mod catalog_more;
pub mod clip_group_file;
pub mod cmd;
pub mod demo;
mod dirty;
pub mod edit;
pub mod file_access;
pub mod inspect;
@@ -23,6 +24,8 @@ use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
use dirty::same_document;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
pub use soundcraft_model as model;
@@ -511,19 +514,6 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@@ -607,6 +597,33 @@ mod dirty_tests {
e.undo();
assert!(!e.is_dirty());
}
#[test]
fn dirty_state_follows_save_undo_and_redo_after_navigation() {
let mut e = Engine::default();
let track = e.session_mut().add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Mono, None);
e.mark_clean();
e.execute("mix.volume", &json!({"track": track.0, "db": -6.0})).unwrap();
assert!(e.is_dirty());
// A repeated check uses the revision cache.
assert!(e.is_dirty());
e.mark_clean();
assert!(!e.is_dirty());
e.execute("edit.select", &json!({"tracks": [track.0], "start": 100, "end": 200})).unwrap();
e.execute("transport.locate", &json!({"at": 300})).unwrap();
e.execute("view.zoom_in", &json!({})).unwrap();
assert!(!e.is_dirty());
assert!(e.undo());
assert!(e.is_dirty(), "undoing away from the saved fader value is dirty");
assert!(e.redo());
assert!(!e.is_dirty(), "redoing to the saved fader value is clean despite navigation changes");
e.execute("mix.volume", &json!({"track": track.0, "db": -12.0})).unwrap();
assert!(e.is_dirty());
assert!(e.undo());
assert!(!e.is_dirty());
assert!(e.redo());
assert!(e.is_dirty());
}
}
#[cfg(test)]
+202 -12
View File
@@ -13,9 +13,26 @@ use std::fmt::Write as _;
const UNIT: i64 = TICKS_PER_QUARTER / 4;
/// Upper bound on engraved measures, so a hostile session cannot make a huge document.
const MAX_MEASURES: usize = 2000;
/// Upper bound on output size, so a hostile session cannot make a huge document.
const MAX_OUTPUT_BYTES: usize = 256 * 1024 * 1024; // 256 MiB
/// Upper bound on items per measure, so a hostile session cannot make a huge document.
const MAX_ITEMS_PER_MEASURE: usize = 128;
/// Upper bound on pitches per chord, so a hostile session cannot make a huge document.
const MAX_PITCHES_PER_CHORD: usize = 16;
/// Upper bound on notes collected per part, before `fill` builds onsets and spans, so a hostile
/// session cannot make `layout` accumulate an unbounded note vector.
const MAX_NOTES_PER_PART: usize = 100_000;
/// Note values a single notehead can show, in sixteenths (longest first).
const VALUES: [u32; 8] = [16, 12, 8, 6, 4, 3, 2, 1];
/// The output has grown past the byte budget: refuse to grow it further.
fn over_budget(buf: &str, budget: usize) -> Result<(), String> {
if buf.len() > budget {
return Err(format!("score too large: over {} bytes", budget));
}
Ok(())
}
/// Score Setup (File › Score Setup…). Stored in the session's edit flags/values.
#[derive(Debug, Clone, PartialEq)]
pub struct ScoreSetup {
@@ -93,6 +110,9 @@ pub fn layout(s: &Session, tracks: &[TrackId]) -> Vec<Part> {
let base = s.tempo.samples_to_ticks(c.start, sr);
let end = s.tempo.samples_to_ticks(c.start.saturating_add(c.length), sr);
for n in &sequence.notes {
if notes.len() >= MAX_NOTES_PER_PART {
break;
}
let st = base.saturating_add(n.start);
if st < base || st >= end || n.length <= 0 {
continue;
@@ -151,13 +171,21 @@ fn split_units(mut units: i64) -> Vec<u32> {
}
fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure> {
// Onsets → chords, each held until its longest note ends or the next onset.
// Onsets → chords, each held until its longest *retained* note ends or the next onset. A pitch
// the cap rejects must not extend the onset end.
let mut onsets: std::collections::BTreeMap<i64, (i64, Vec<u8>)> = std::collections::BTreeMap::new();
for (a, b, p) in notes {
let e = onsets.entry(*a).or_insert((*b, Vec::new()));
e.0 = e.0.max(*b);
if !e.1.contains(p) {
e.1.push(*p);
let (ends, pitches) = onsets.entry(*a).or_insert((*b, Vec::new()));
let new_pitch = !pitches.contains(p);
if new_pitch && pitches.len() >= MAX_PITCHES_PER_CHORD {
// Chord full and this pitch is absent: it is dropped and must not extend the onset end.
continue;
}
// A retained pitch (already in the chord, or one the cap accepts) may extend the onset
// end, even when it reappears with a longer duration.
*ends = (*ends).max(*b);
if new_pitch {
pitches.push(*p);
}
}
let starts: Vec<i64> = onsets.keys().copied().collect();
@@ -196,10 +224,33 @@ fn fill(bars: &[(i64, i64, u32, u32)], notes: &[(i64, i64, u8)]) -> Vec<Measure>
}
emit(from, to, ps, *a < *ms, *b > me, &mut items);
cursor = to;
// Cap items per measure to avoid pathological memory use.
if items.len() > MAX_ITEMS_PER_MEASURE {
break;
}
}
if cursor < me {
emit(cursor, me, &[], false, false, &mut items);
}
// Cap the item count without shortening the measure: whatever suffix the cap discards is
// replaced with representable rests, so the bar's duration (and the SVG layout denominator)
// stays right. Shrinking the item list further makes room for those rests.
if items.len() > MAX_ITEMS_PER_MEASURE {
let bar_len = me.saturating_sub(*ms);
let mut keep = MAX_ITEMS_PER_MEASURE;
loop {
let kept: i64 = items.iter().take(keep).map(|i| i64::from(i.units)).sum();
let rests = split_units(bar_len.saturating_sub(kept));
if keep.saturating_add(rests.len()) <= MAX_ITEMS_PER_MEASURE || keep == 0 {
items.truncate(keep);
items.extend(rests.into_iter().map(|units| Item { pitches: Vec::new(), units, tie_start: false, tie_stop: false }));
break;
}
keep -= 1;
}
// Safety net for a hostile meter so wide that even whole-bar rests exceed the cap.
items.truncate(MAX_ITEMS_PER_MEASURE);
}
measures.push(Measure { numerator: *num, denominator: *den, items });
}
measures
@@ -214,7 +265,13 @@ fn spell(pitch: u8) -> (char, i32, i32) {
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;")
s.chars()
.filter(|c| matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}'))
.collect::<String>()
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
fn note_type(units: u32) -> (&'static str, bool) {
@@ -231,11 +288,20 @@ fn note_type(units: u32) -> (&'static str, bool) {
}
/// MusicXML 4.0 (partwise) for `parts`.
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
musicxml_budgeted(parts, setup, MAX_OUTPUT_BYTES)
}
/// [`musicxml`] with an explicit byte budget (kept separate so tests can pass a tiny budget
/// instead of building a 256 MiB score).
fn musicxml_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
let mut x = String::new();
x.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>\n");
x.push_str("<!DOCTYPE score-partwise PUBLIC \"-//Recordare//DTD MusicXML 4.0 Partwise//EN\" \"http://www.musicxml.org/dtds/partwise.dtd\">\n");
x.push_str("<score-partwise version=\"4.0\">\n");
// The header (and an oversized title/composer, escaped) can itself exceed the budget even with
// no measures, so check before the per-measure loop too.
over_budget(&x, budget)?;
let _ = writeln!(x, " <work><work-title>{}</work-title></work>", xml_escape(&setup.title));
x.push_str(" <identification>\n");
if !setup.composer.is_empty() {
@@ -246,6 +312,7 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(x, " <score-part id=\"P{}\"><part-name>{}</part-name></score-part>", i + 1, xml_escape(&p.name));
}
x.push_str(" </part-list>\n");
over_budget(&x, budget)?;
for (i, p) in parts.iter().enumerate() {
let _ = writeln!(x, " <part id=\"P{}\">", i + 1);
let mut meter = (0, 0);
@@ -312,11 +379,13 @@ pub fn musicxml(parts: &[Part], setup: &ScoreSetup) -> String {
}
}
x.push_str(" </measure>\n");
over_budget(&x, budget)?;
}
x.push_str(" </part>\n");
}
x.push_str("</score-partwise>\n");
x
over_budget(&x, budget)?;
Ok(x)
}
/// Staff position of `pitch` in half-spaces above the bottom line (treble E4 / bass G2).
@@ -328,7 +397,13 @@ fn staff_step(pitch: u8, bass: bool) -> i32 {
}
/// An engraved score as a printable SVG page (A4 portrait width, as tall as needed).
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
pub fn svg(parts: &[Part], setup: &ScoreSetup) -> Result<String, String> {
svg_budgeted(parts, setup, MAX_OUTPUT_BYTES)
}
/// [`svg`] with an explicit byte budget (kept separate so tests can pass a tiny budget instead of
/// building a 256 MiB score).
fn svg_budgeted(parts: &[Part], setup: &ScoreSetup, budget: usize) -> Result<String, String> {
const W: f64 = 794.0;
const MARGIN: f64 = 48.0;
const SP: f64 = 7.0; // staff space
@@ -350,7 +425,9 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(o, "<text x=\"{}\" y=\"56\" font-size=\"24\" text-anchor=\"middle\">{}</text>", W / 2.0, xml_escape(&setup.title));
if !setup.composer.is_empty() {
let _ = writeln!(o, "<text x=\"{}\" y=\"84\" font-size=\"13\" text-anchor=\"end\">{}</text>", W - MARGIN, xml_escape(&setup.composer));
over_budget(&o, budget)?;
}
over_budget(&o, budget)?;
let bar_w = (staff_x1 - staff_x0) / per as f64;
for sys in 0..systems {
let y_sys = top + sys as f64 * sys_h;
@@ -462,7 +539,13 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
let _ = writeln!(o, "<path d=\"M{:.1} {y:.1} q 8 5 16 0\" fill=\"none\" stroke=\"black\" stroke-width=\"0.9\"/>", x + 3.0);
}
}
// Check each measure, so a run of rest-only or missing measures that only draws
// bar lines cannot grow the output past the budget untouched.
over_budget(&o, budget)?;
}
// The staff markup (lines, clef, track names) itself grows with the part count even
// without any items, so check once per staff too.
over_budget(&o, budget)?;
}
// System bracket.
let y_last = y_sys + (parts.len().max(1) - 1) as f64 * STAFF_GAP + 4.0 * SP;
@@ -472,9 +555,11 @@ pub fn svg(parts: &[Part], setup: &ScoreSetup) -> String {
staff_x0 - 30.0,
staff_x0 - 30.0
);
over_budget(&o, budget)?;
}
o.push_str("</svg>\n");
o
over_budget(&o, budget)?;
Ok(o)
}
fn rest(o: &mut String, x: f64, y0: f64, sp: f64, units: u32) {
@@ -552,10 +637,115 @@ mod tests {
let parts = layout(e.session(), &[]);
assert!(!parts.is_empty(), "demo has MIDI tracks");
let setup = ScoreSetup::from_session(e.session());
let x = musicxml(&parts, &setup);
let x = musicxml(&parts, &setup).expect("demo musicxml fits");
assert!(x.contains("<score-partwise") && x.contains("<pitch>") && x.trim_end().ends_with("</score-partwise>"));
assert_eq!(x.matches("<measure ").count(), x.matches("</measure>").count());
let s = svg(&parts, &setup);
let s = svg(&parts, &setup).expect("demo svg fits");
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
}
#[test]
fn output_is_bounded_by_byte_budget() {
// A part with many notes must not produce an unbounded document.
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
let mut notes = Vec::new();
for i in 0..2000 {
notes.push((i * 2, i * 2 + 1, 60 + (i % 24) as u8));
}
let measures = fill(&bars, &notes);
let part = Part { name: "dense".into(), bass: false, measures };
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 4, show_track_names: false };
let s = svg(&[part], &setup);
assert!(s.is_ok(), "dense but bounded score fits");
let s = s.unwrap();
assert!(s.len() <= MAX_OUTPUT_BYTES);
}
#[test]
fn oversize_score_is_refused_not_aborted() {
// Many dense parts drive the output past the byte budget: the call must return an error,
// not grow a multi-GB string or abort. Use the budgeted entry points with a tiny budget so
// the test exercises the over-budget path without allocating 256 MiB of score, and covers
// both the per-measure checks and the header/staff-only growth.
let bars: Vec<_> = (0..100).map(|i| (i * 16, 16, 4, 4)).collect();
let mut notes = Vec::new();
for i in 0..2000 {
let onset = i * 2;
notes.push((onset, onset + 1, 60 + (i % 24) as u8));
}
let measures = fill(&bars, &notes);
let part = Part { name: "dense".into(), bass: false, measures };
let setup = ScoreSetup { title: "t".into(), composer: String::new(), bars_per_system: 1, show_track_names: true };
// Enough copies of the dense part to exceed a tiny budget.
let parts: Vec<Part> = (0..64).map(|i| Part { name: format!("part{i}"), ..part.clone() }).collect();
let tiny = 1024;
let err = svg_budgeted(&parts, &setup, tiny).unwrap_err();
assert!(err.contains("score too large"), "{err}");
// MusicXML needs a budget that clears the header/part-list but trips in the measures, so
// the per-measure check is what refuses the document (this test used to fail on the
// part-list check alone).
let boundless = usize::MAX;
// The header plus part-list exactly as `parse` writes it, but with no measures: that is
// the size a budget must clear before the per-measure check is reached.
let header_parts: Vec<Part> = parts.iter().map(|p| Part { name: p.name.clone(), bass: p.bass, measures: Vec::new() }).collect();
let header = musicxml_budgeted(&header_parts, &setup, boundless).map_or(0, |x| x.len());
let full = musicxml_budgeted(&parts, &setup, boundless).map_or(0, |x| x.len());
assert!(header > 0, "the bare header fits any budget");
assert!(full > header, "the measures add output ({full} vs {header})");
// A budget above the bare header but below the completed document must be refused by the
// per-measure check.
let between = header.saturating_add((full.saturating_sub(header)) / 2);
assert!(between > header && between < full, "budget sits between header and document");
// The header alone fits that budget, so the refusal below comes from the measures.
assert!(musicxml_budgeted(&header_parts, &setup, between).is_ok(), "header fits the mid budget");
let err = musicxml_budgeted(&parts, &setup, between).unwrap_err();
assert!(err.contains("score too large"), "{err}");
// And a header that does not fit is refused before any measure is built.
let err = musicxml_budgeted(&parts, &setup, tiny).unwrap_err();
assert!(err.contains("score too large"), "{err}");
}
#[test]
fn item_cap_keeps_the_measure_duration() {
// A 64/1 bar (256 sixteenths) with one note per sixteenth needs 256 items: over the cap.
// What the cap discards must come back as rests, so the bar still fills completely and the
// SVG layout denominator stays right.
let bars = vec![(0i64, 256i64, 64u32, 4u32)];
let notes: Vec<(i64, i64, u8)> = (0..256).map(|i| (i, i + 1, 60)).collect();
let measures = fill(&bars, &notes);
assert_eq!(measures.len(), 1);
let m = &measures[0];
assert!(m.items.len() <= MAX_ITEMS_PER_MEASURE, "capped at {MAX_ITEMS_PER_MEASURE}");
let total: u32 = m.items.iter().map(|i| i.units).sum();
assert_eq!(total, 256, "the bar still lasts 256 sixteenths");
assert!(m.items.iter().any(|i| i.pitches.is_empty()), "the discarded suffix became rests");
}
#[test]
fn oversized_title_is_refused_by_the_header_check() {
// An oversized part name/title must be caught by the header check even with no measures:
// the escaped copy alone exceeds the budget. (A 54 MiB title blows past 64 KiB of escaped
// text without touching the per-measure loop.)
let setup = ScoreSetup { title: "&".repeat(64 * 1024), composer: "c".repeat(64 * 1024), bars_per_system: 4, show_track_names: false };
let err = musicxml_budgeted(&[], &setup, 1024).unwrap_err();
assert!(err.contains("score too large"), "{err}");
let err = svg_budgeted(&[], &setup, 1024).unwrap_err();
assert!(err.contains("score too large"), "{err}");
}
#[test]
fn exports_drop_characters_xml_forbids() {
let e = crate::demo::demo_engine();
let mut parts = layout(e.session(), &[]);
parts[0].name = "Lead\u{1}1\u{FFFE}".to_string();
let mut setup = ScoreSetup::from_session(e.session());
setup.title = "T\u{2}itle".to_string();
setup.composer = "C\u{3}omp".to_string();
for out in [musicxml(&parts, &setup).expect("musicxml fits"), svg(&parts, &setup).expect("svg fits")] {
let bad: Vec<char> =
out.chars().filter(|c| (*c < ' ' && !matches!(c, '\n' | '\t' | '\r')) || matches!(c, '\u{FFFE}' | '\u{FFFF}')).collect();
assert!(bad.is_empty(), "forbidden characters: {bad:?}");
}
}
}
+27
View File
@@ -413,3 +413,30 @@ fn shuffle_lock_and_snap_rules_hold_when_a_call_names_tracks() {
run(&mut e, "edit.mode", json!({"mode": "grid"}));
assert!(e.execute("edit.snap_to_grid", &json!({"tracks": [t.0]})).is_err());
}
fn selection_marker(e: &mut Engine, start: i64, end: i64) -> usize {
run(e, "markers.add", json!({"kind": "selection", "start": start, "end": end}));
e.session().markers.len() - 1
}
#[test]
fn cut_and_insert_time_move_the_end_of_a_spanning_selection_memory_location() {
let (mut e, _, _) = engine("slip");
let a = selection_marker(&mut e, 1000, 5000);
let b = selection_marker(&mut e, 1000, 2500);
run(&mut e, "event.cut_time", json!({"start": 2000, "end": 3000}));
let m = &e.session().markers;
assert_eq!((m[a].start, m[a].end), (1000, 4000));
assert_eq!((m[b].start, m[b].end), (1000, 2000));
run(&mut e, "event.insert_time", json!({"start": 2000, "length": 1000}));
let m = &e.session().markers;
assert_eq!((m[a].start, m[a].end), (1000, 5000));
assert_eq!((m[b].start, m[b].end), (1000, 2000));
}
#[test]
fn moving_a_group_left_past_zero_keeps_its_spacing() {
let (mut e, t, ids) = long("slip", &[(500, 100, 0), (2000, 100, 0)]);
run(&mut e, "edit.move_clips", json!({"clips": [ids[0].0, ids[1].0], "by": -1000}));
assert_eq!(clips(&e, t), [(0, 100), (1500, 100)]);
}
+2
View File
@@ -11,10 +11,12 @@ mod hostile_params;
mod hostile_sessions;
mod long_renders;
mod midi_offset;
mod min_secs_with_timecode_counter;
mod mix_required_params;
mod random_edits;
mod recording;
mod session_transport;
mod split_into_mono;
mod time_key_signatures;
mod track_name_collisions;
mod trim_to_selection;
@@ -0,0 +1,11 @@
use serde_json::json;
use soundcraft_engine::Engine;
#[test]
fn min_secs_string_is_not_misread_as_timecode_when_the_counter_is_timecode() {
let mut e = Engine::default();
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
e.execute("edit.select", &json!({"start": "0:12.500", "end": "0:20.000"})).unwrap();
let sel = e.session().edit.selection;
assert_eq!((sel.start, sel.end), (600_000, 960_000));
}
@@ -0,0 +1,41 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, Session, TrackKind};
fn engine_with_keys() -> Engine {
let mut session = Session::default();
session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut engine = Engine::new(session);
for (at, key) in [(10, "C major"), (48000, "D major"), (100000, "E major")] {
engine.execute("event.add_key_change", &json!({"key": key, "at": at})).unwrap();
}
engine
}
fn keys(engine: &Engine) -> Vec<(u64, String)> {
engine.session().key_signatures.iter().map(|(at, k)| (*at as u64, k.clone())).collect()
}
fn k(at: u64, key: &str) -> (u64, String) {
(at, key.to_string())
}
#[test]
fn insert_time_moves_key_signatures() {
let mut engine = engine_with_keys();
let original = keys(&engine);
engine.execute("event.insert_time", &json!({"start": 48000, "length": 1000})).unwrap();
assert_eq!(keys(&engine), vec![k(10, "C major"), k(49000, "D major"), k(101000, "E major")]);
engine.undo();
assert_eq!(keys(&engine), original);
}
#[test]
fn cut_time_removes_and_moves_key_signatures() {
let mut engine = engine_with_keys();
let original = keys(&engine);
engine.execute("event.cut_time", &json!({"start": 20, "end": 60000})).unwrap();
assert_eq!(keys(&engine), vec![k(10, "C major"), k(40020, "E major")]);
engine.undo();
assert_eq!(keys(&engine), original);
}
+3 -2
View File
@@ -178,9 +178,10 @@ pub fn change_velocity_with_seed(notes: &mut [Note], op: VelocityOp, seed: u64)
VelocityOp::Ramp { from, to } => {
let first = notes.iter().map(|n| n.start).min().unwrap_or(0);
let last = notes.iter().map(|n| n.start).max().unwrap_or(0);
let span = last.saturating_sub(first) as f64;
let span = (i128::from(last) - i128::from(first)) as f64;
for n in notes.iter_mut() {
let t = if span > 0.0 { n.start.saturating_sub(first) as f64 / span } else { 0.0 };
let offset = (i128::from(n.start) - i128::from(first)) as f64;
let t = if span > 0.0 { offset / span } else { 0.0 };
let v = f64::from(from) + (f64::from(to) - f64::from(from)) * t;
n.velocity = clamp_vel(round_i64(v));
}
+7
View File
@@ -106,6 +106,13 @@ fn transpose_clamps() {
assert!(notes.iter().all(|n| n.pitch == 127));
}
#[test]
fn ramp_full_tick_range() {
let mut notes = vec![n(60, i64::MIN, 1), n(60, 0, 1), n(60, i64::MAX, 1)];
change_velocity(&mut notes, VelocityOp::Ramp { from: 1, to: 127 });
assert_eq!(notes.iter().map(|n| n.velocity).collect::<Vec<_>>(), vec![1, 64, 127]);
}
#[test]
fn velocity_ops() {
let base = vec![n(60, 0, 1), n(60, 100, 1), n(60, 200, 1)];
+12
View File
@@ -1187,3 +1187,15 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
}
}
}
#[test]
fn gain_ramp_matches_converting_both_ends_every_sample() {
for (db0, db1, n) in [(-6.0f32, 3.0f32, 512usize), (0.0, 0.0, 1), (-144.0, 0.0, 300), (2.5, -60.0, 0), (f32::NAN, -3.0, 64), (-200.0, 12.0, 7)] {
let ramp = crate::automation::GainRamp::new(db0, db1, n);
let (g0, g1) = (db_to_gain(db0), db_to_gain(db1));
for i in 0..=n {
let want = if n == 0 { g0 } else { g0 + (g1 - g0) * (i as f32 / n as f32) };
assert_eq!(ramp.at(i).to_bits(), want.to_bits(), "{db0} -> {db1} dB, sample {i} of {n}");
}
}
}
+7 -1
View File
@@ -115,7 +115,13 @@ pub fn parse_position(
let v = sr.samples(m * 60.0 + s);
Ok(if neg { -v } else { v })
}
TimeFormat::Timecode => Ok(Timecode::parse(t)?.to_samples(rate, sr).saturating_sub(tc_start)),
TimeFormat::Timecode => {
let tc = Timecode::parse(t)?;
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
return Err(err());
}
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
}
TimeFormat::FeetFrames => {
let (ft, fr) = t.split_once('+').ok_or_else(err)?;
let ft: i64 = ft.trim().parse().map_err(|_| err())?;
+7 -1
View File
@@ -301,7 +301,7 @@ impl TempoMap {
pub fn tick_at_bar_beat(&self, bb: BarBeat) -> i64 {
let start = self.bar_start_tick(bb.bar);
let m = self.meter_at_tick(start);
start.saturating_add((bb.beat - 1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
start.saturating_add(bb.beat.saturating_sub(1).max(0).saturating_mul(m.ticks_per_beat())).saturating_add(bb.tick)
}
pub fn bar_beat_at(&self, s: Samples, sr: SampleRate) -> BarBeat {
@@ -344,6 +344,12 @@ mod tests {
assert_eq!(m.bar_start_tick(0), -TICKS_PER_QUARTER * 4);
}
#[test]
fn minimum_beat_is_clamped_without_overflow() {
let m = TempoMap::default();
assert_eq!(m.tick_at_bar_beat(BarBeat { bar: 1, beat: i64::MIN, tick: 0 }), 0);
}
#[test]
fn bar_beat_at_min_tick_does_not_overflow() {
let m = TempoMap::default();
-2
View File
@@ -176,7 +176,6 @@ impl SoundApp {
if ctx.current_pass_index() == 0 {
bounce::step(self, ctx);
import::step(self, ctx);
#[cfg(not(target_arch = "wasm32"))]
updates_ui::tick(self, ctx);
}
if !self.pointer_down {
@@ -340,7 +339,6 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
#[cfg(not(target_arch = "wasm32"))]
updates_ui::show(self, &ctx);
// The sampler's drop rects are current now, so route any captured drop in this pass.
self.route_dropped_files();
+4
View File
@@ -455,6 +455,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
audiosuite_window(app, ctx);
crate::bounce::show(app, ctx);
crate::import::show(app, ctx);
// Whichever window shows, closing Strip Silence drops its rectangles and stops their scan.
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 mut keep = true;
let mut action: Option<(String, Value)> = None;
+1 -4
View File
@@ -42,10 +42,7 @@ pub fn strip_silence_rectangles(app: &mut SoundApp, ctx: &egui::Context) -> Stri
let values = match &app.dialogs.open {
Some(Dialog::StripSilence { threshold, min_ms, pre_ms, post_ms }) => [*threshold, *min_ms, *pre_ms, *post_ms],
Some(Dialog::StripSilenceRename { strip, .. }) => *strip,
_ => {
app.dialogs.strip_rectangles = StripRectangles::default();
return Arc::default();
}
_ => return Arc::default(),
};
let key: StripKey = (app.engine.revision, values.map(f64::to_bits));
let r = &mut app.dialogs.strip_rectangles;
+5
View File
@@ -518,6 +518,11 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
if id == "ui.audio_device" {
return Some(crate::windows::audio_device_command(app, p));
}
// Menu clicks never get here: Setup › Playback Engine / Hardware open the window
// (`MENU_WINDOWS`). A programmatic call applies the open device instead.
if matches!(id, "setup.playback_engine" | "setup.hardware") {
return Some(crate::windows::setup_audio_command(app, id, p));
}
let toggle = |v: &mut bool| {
*v = p.get("value").and_then(Value::as_bool).unwrap_or(!*v);
json!({"value": *v})
+49
View File
@@ -517,6 +517,8 @@ pub fn playback_engine(app: &mut SoundApp, ctx: &egui::Context) {
number_combo(ui, "pe-rate", "Sample rate", &mut draft.sample_rate, out.map_or(&[][..], |d| &d.sample_rates), "Hz");
ui.end_row();
ui.label("Buffer size");
// The combo lists standard power-of-two sizes. `setup.playback_engine` does not round
// onto this list: it keeps any integer 16..=8192, the same as `ui.audio_device`.
let buffers = out.map(DeviceInfo::buffer_sizes).unwrap_or_else(|| soundcraft_playback::devices::STANDARD_BUFFERS.to_vec());
number_combo(ui, "pe-buffer", "Buffer size", &mut draft.buffer_size, &buffers, "samples");
ui.end_row();
@@ -623,6 +625,53 @@ pub fn audio_device_command(app: &mut SoundApp, p: &serde_json::Value) -> Result
Ok(out)
}
/// Programmatic `setup.playback_engine` / `setup.hardware`. `device` is the output name and
/// `buffer_size` is any integer 16..=8192 or null (not rounded to the window's power-of-two list);
/// both go through [`audio_device_command`]. Delay compensation still goes through the engine
/// command, and only after the device change has been accepted. The result is the open device:
/// its name, the buffer actually in use (`device_buffer`, null for the device default), its sample
/// rate, and delay compensation. `applied` is true only when an audio device is open.
pub fn setup_audio_command(app: &mut SoundApp, id: &str, p: &serde_json::Value) -> Result<serde_json::Value, String> {
use serde_json::Value;
let mut audio = serde_json::Map::new();
if let Some(device) = p.get("device") {
if !device.is_null() && !device.is_string() {
return Err("`device` must be a string or null".into());
}
audio.insert("output".to_string(), device.clone());
}
if let Some(buffer) = p.get("buffer_size") {
audio.insert("buffer_size".to_string(), buffer.clone());
}
if !audio.is_empty() {
audio_device_command(app, &Value::Object(audio))?;
}
if id == "setup.playback_engine"
&& let Some(on) = p.get("delay_compensation").and_then(Value::as_bool)
{
app.engine.execute("setup.playback_engine", &json!({"delay_compensation": on})).map_err(|e| e.to_string())?;
}
let applied = app.player.as_ref().is_some_and(|player| !player.silent);
let (device, buffer_size, sample_rate) = match app.player.as_ref() {
Some(player) => {
let buffer = match player.device_buffer {
Some(n) => json!(n),
None => Value::Null,
};
(json!(player.device_name), buffer, json!(player.device_rate))
}
None => (Value::Null, Value::Null, json!(app.engine.session().sample_rate.hz())),
};
Ok(json!({
"applied": applied,
"device": device,
"buffer_size": buffer_size,
"sample_rate": sample_rate,
"bit_depth": app.engine.session().bit_depth,
"delay_compensation": app.engine.session().edit.delay_compensation,
}))
}
/// Setup › I/O: busses and output paths.
pub fn io_setup(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_io_setup;
+76
View File
@@ -25,3 +25,79 @@ fn the_audio_device_choice_is_kept_only_when_it_is_applied() {
assert_eq!(r["config"]["buffer_size"], 256);
assert!(r["device"].is_object(), "{r}");
}
fn new_app() -> SoundApp {
SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default())
}
#[test]
fn playback_engine_refuses_an_invalid_or_untimely_device_choice_without_side_effects() {
let mut app = new_app();
let before = app.ui.audio.clone();
let bit_depth = app.engine.session().bit_depth;
assert!(!app.ui.show_playback_engine);
let r = app.run("setup.hardware", json!({})).unwrap();
assert_eq!(r["applied"], false, "without a player there is no open device");
assert_eq!(r["bit_depth"], json!(bit_depth));
assert!(app.engine.session().edit.delay_compensation, "delay compensation starts on");
let err = app.run("setup.playback_engine", json!({"buffer_size": 8, "delay_compensation": false})).unwrap_err();
assert!(err.contains("16..=8192"), "{err}");
assert_eq!(app.ui.audio, before, "an invalid buffer changes nothing");
assert!(app.engine.session().edit.delay_compensation, "an invalid buffer does not apply delay compensation");
app.engine.transport.recording = true;
let err = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap_err();
assert!(err.contains("stop recording"), "{err}");
assert_eq!(app.ui.audio, before);
assert!(app.engine.session().edit.delay_compensation, "a refused device change does not apply delay compensation");
app.engine.transport.recording = false;
let err = app.run("setup.playback_engine", json!({"device": 42, "delay_compensation": false})).unwrap_err();
assert!(err.contains("`device` must be a string or null"), "{err}");
assert_eq!(app.ui.audio, before, "an invalid device changes nothing");
assert!(app.engine.session().edit.delay_compensation, "an invalid device does not apply delay compensation");
}
#[test]
fn playback_engine_applies_the_device_choice_and_reports_the_open_device() {
let mut app = new_app();
let bit_depth = app.engine.session().bit_depth;
let r = app.run("setup.playback_engine", json!({"buffer_size": 256, "delay_compensation": false})).unwrap();
assert_eq!(app.ui.audio.buffer_size, Some(256));
assert!(!app.engine.session().edit.delay_compensation);
assert!(!app.ui.show_playback_engine, "a programmatic call does not open the window");
assert_eq!(r["delay_compensation"], false);
let player = app.player.as_ref().unwrap();
assert_eq!(r["applied"], !player.silent);
assert_eq!(r["device"], json!(player.device_name));
assert_eq!(r["buffer_size"], json!(player.device_buffer));
assert_eq!(r["sample_rate"], player.device_rate);
assert_eq!(r["bit_depth"], json!(bit_depth));
}
#[test]
fn playback_engine_applies_the_same_device_choice_as_the_audio_command() {
let mut app = new_app();
app.run("setup.playback_engine", json!({"delay_compensation": false})).unwrap();
// 300 is in range and is not rounded onto the window's power-of-two list.
let mut via_window_command = new_app();
via_window_command.run("ui.audio_device", json!({"buffer_size": 300, "output": "Elsewhere"})).unwrap();
app.run("setup.hardware", json!({"buffer_size": 300, "device": "Elsewhere"})).unwrap();
assert_eq!(app.ui.audio.buffer_size, Some(300));
assert_eq!(app.ui.audio.buffer_size, via_window_command.ui.audio.buffer_size);
assert_eq!(app.ui.audio.output, via_window_command.ui.audio.output);
assert!(!app.ui.show_playback_engine);
app.run("setup.playback_engine", json!({"buffer_size": null, "device": null})).unwrap();
assert_eq!(app.ui.audio.buffer_size, None);
assert_eq!(app.ui.audio.output, None);
assert!(!app.engine.session().edit.delay_compensation, "clearing the device leaves delay compensation");
let cfg = app.ui.audio.clone();
soundcraft_ui_egui::menus::invoke_menu(&mut app, "setup.playback_engine", "Setup > Playback Engine...");
assert!(app.ui.show_playback_engine, "the menu still opens the Playback Engine window");
assert_eq!(app.ui.audio, cfg, "opening the window does not change the device");
}
@@ -25,8 +25,6 @@ const KNOWN_UNREACHED: &[&str] = &[
"mix.solo_safe",
"options.post_roll",
"options.pre_roll",
"setup.hardware", // menu opens the Playback Engine window, which doesn't run it
"setup.playback_engine", // same
"setup.reset_edit_state",
"track.load_preset",
"track.pin",
@@ -93,6 +91,13 @@ fn every_command_has_a_ui_path() {
assert!(stale.is_empty(), "now reachable, remove from KNOWN_UNREACHED: {stale:?}");
}
#[test]
fn playback_engine_menu_opens_the_window_instead_of_running_the_command() {
let extra = menus::ui_aliases();
assert_eq!(catalog::implemented_by("Setup > Playback Engine...", &extra).as_deref(), Some("window.playback_engine"));
assert_eq!(catalog::implemented_by("Setup > Hardware...", &extra).as_deref(), Some("window.playback_engine"));
}
#[test]
fn new_midi_clip_is_in_the_clip_menu() {
let extra = menus::ui_aliases();
@@ -109,3 +109,18 @@ fn the_rectangles_stay_while_rename_is_open_and_go_when_the_window_closes() {
assert_eq!(rectangles(&h), 0);
assert_eq!(clips(&h, track), before);
}
#[test]
fn closing_the_window_while_the_mix_window_shows_stops_the_scan() {
let (mut h, _) = strip_silence_dialog();
if let Some(Dialog::StripSilence { threshold, .. }) = &mut h.state_mut().dialogs.open {
*threshold = -30.0;
}
h.run_steps(1);
assert!(h.state().dialogs.strip_scan_running(), "a scan for the new threshold started");
h.state_mut().run("window.mix", json!({})).unwrap();
h.run_steps(2);
h.state_mut().dialogs.open = None;
h.run_steps(2);
assert!(!h.state().dialogs.strip_scan_running(), "the scan still runs after the window closed");
}
+94 -4
View File
@@ -1,7 +1,7 @@
//! NAL unit header and slice header parsing (7.3.1, 7.3.3).
use crate::bitstream::BitReader;
use crate::h264::error::{Result, ensure};
use crate::h264::error::{Error, Result, ensure};
use crate::h264::params::{Pps, Sps};
/// NAL unit types used by the decoder.
@@ -199,9 +199,9 @@ impl SliceHeader {
let mut num_ref_idx_active = [pps.num_ref_idx_l0_default_active, pps.num_ref_idx_l1_default_active];
if !slice_type.is_intra() {
if r.read_flag()? {
num_ref_idx_active[0] = r.read_ue()? + 1;
num_ref_idx_active[0] = plus_one(r.read_ue()?)?;
if slice_type.is_b() {
num_ref_idx_active[1] = r.read_ue()? + 1;
num_ref_idx_active[1] = plus_one(r.read_ue()?)?;
}
}
let max = if field_pic { 32 } else { 16 };
@@ -223,7 +223,7 @@ impl SliceHeader {
ensure!(cabac_init_idc <= 2, "cabac_init_idc out of range");
}
let slice_qp_delta = r.read_se()?;
let qp = pps.pic_init_qp + slice_qp_delta;
let qp = pps.pic_init_qp.checked_add(slice_qp_delta).ok_or_else(|| Error::Invalid("slice QP out of range".to_string()))?;
ensure!((0..=51).contains(&qp), "slice QP {qp} out of range");
let mut sp_for_switch = false;
let mut slice_qs_delta = 0;
@@ -308,6 +308,11 @@ struct RefFields {
mmcos: Vec<Mmco>,
}
/// `num_ref_idx_active_minus1 + 1`, or the error its range check gives.
fn plus_one(v: u32) -> Result<u32> {
v.checked_add(1).ok_or_else(|| Error::Invalid("num_ref_idx_active out of range".to_string()))
}
fn parse_reference_fields(
r: &mut BitReader<'_>,
nal: NalHeader,
@@ -549,3 +554,88 @@ impl PocState {
self.prev_frame_num = frame_num;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bitstream::BitWriter;
fn sps_pps() -> (Sps, Pps) {
let mut w = BitWriter::new();
w.write_bits(66, 8);
w.write_bits(0xC0, 8);
w.write_bits(30, 8);
w.write_ue(0);
w.write_ue(0);
w.write_ue(2);
w.write_ue(1);
w.write_bit(false);
w.write_ue(10);
w.write_ue(8);
w.write_bit(true);
w.write_bit(true);
w.write_bit(false);
w.write_bit(false);
w.rbsp_trailing();
let sps = Sps::parse(&w.finish()).unwrap();
let mut w = BitWriter::new();
w.write_ue(0);
w.write_ue(0);
w.write_bit(false);
w.write_bit(false);
w.write_ue(0);
w.write_ue(0);
w.write_ue(0);
w.write_bit(false);
w.write_bits(0, 2);
w.write_se(0);
w.write_se(0);
w.write_se(0);
w.write_bit(false);
w.write_bit(false);
w.write_bit(false);
w.rbsp_trailing();
let pps = Pps::parse(&w.finish(), &[Some(sps.clone())]).unwrap();
(sps, pps)
}
/// Parse a non-IDR slice header of `slice_type` whose override flag is set with the given counts.
fn parse_override(slice_type: u32, l0_minus1: u32, l1_minus1: u32, qp_delta: i32) -> Result<SliceHeader> {
let (sps, pps) = sps_pps();
let mut w = BitWriter::new();
w.write_ue(0);
w.write_ue(slice_type);
w.write_ue(0);
w.write_bits(0, 4);
if slice_type == 1 {
w.write_bit(true); // direct_spatial_mv_pred
}
w.write_bit(true); // num_ref_idx_active_override_flag
w.write_ue(l0_minus1);
if slice_type == 1 {
w.write_ue(l1_minus1);
}
w.write_bit(false); // ref_pic_list_modification_flag_l0
if slice_type == 1 {
w.write_bit(false);
}
w.write_bit(false); // adaptive_ref_pic_marking_mode_flag
w.write_se(qp_delta);
w.rbsp_trailing();
let nal = NalHeader { nal_ref_idc: 1, nal_unit_type: 1 };
SliceHeader::parse(&w.finish(), nal, |_| Ok((&pps, &sps))).map(|(h, _, _)| h)
}
#[test]
fn huge_num_ref_idx_override_is_invalid() {
assert!(matches!(parse_override(0, u32::MAX, 0, 0), Err(crate::h264::Error::Invalid(_))));
assert!(matches!(parse_override(1, 0, u32::MAX, 0), Err(crate::h264::Error::Invalid(_))));
assert!(matches!(parse_override(0, 16, 0, 0), Err(crate::h264::Error::Invalid(_))));
assert_eq!(parse_override(0, 15, 0, 0).unwrap().num_ref_idx_active, [16, 0]);
}
#[test]
fn huge_slice_qp_delta_is_invalid() {
assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_))));
}
}
+1 -1
View File
@@ -485,7 +485,7 @@ impl Movie {
let s = st.next;
st.next += 1;
let pts = self.rank.get(s).copied().unwrap_or(0) as i64;
let data = match self.mp4.read_sample(&*self.src, self.track, s) {
let data = match self.read(s) {
Ok(d) => d,
Err(e) => {
last_err = Some(e.to_string());
+44 -1
View File
@@ -3,10 +3,12 @@
//! `image` encoder, muxed with the bundled MP4/MOV writer. When `ffmpeg` with libx264 is on PATH,
//! real High-profile streams with B-frames are checked too (skipped otherwise).
use crate::isobmff::{Brand, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use crate::isobmff::{Brand, ByteSource, Mp4Writer, SampleEntry, TrackConfig, WriteSample, WriterOptions};
use crate::synth::{frame_color, h264_mp4, mjpeg_mov, prores_mov};
use crate::{Movie, VideoError};
use std::io::Cursor;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn expected_rgb(i: usize) -> [u8; 3] {
let [y, cb, cr] = frame_color(i);
@@ -118,6 +120,47 @@ fn prores_frame_size_is_checked_before_decoding() {
assert!(matches!(m.frame(0), Err(VideoError::Decode(e)) if e.contains("56x48 frame in a 64x48 track")));
}
/// In-memory movie bytes, zero-padded up to `len` without storing the padding. Records the
/// largest read asked of it and refuses reads over 1 MiB (the test movies are much smaller).
struct PaddedSource {
bytes: Vec<u8>,
len: u64,
largest: AtomicUsize,
}
impl ByteSource for PaddedSource {
fn len(&self) -> u64 {
self.len
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
self.largest.fetch_max(buf.len(), Ordering::Relaxed);
if buf.len() > 1 << 20 {
return Err(std::io::Error::other("read too large for the test source"));
}
let start = usize::try_from(offset).unwrap_or(usize::MAX);
for (i, b) in buf.iter_mut().enumerate() {
*b = self.bytes.get(start.saturating_add(i)).copied().unwrap_or(0);
}
Ok(())
}
}
#[test]
fn h264_sample_reads_are_capped() {
// One frame whose `stsz` entry claims 300 MiB, in a source long enough to hold it.
let mut bytes = h264_mp4(1, 1).unwrap();
let at = bytes.windows(4).position(|w| w == b"stsz").unwrap();
// After the type: version and flags, the constant size (0: one size per sample), the count.
let field = if bytes[at + 8..at + 12] == [0; 4] { at + 16 } else { at + 8 };
bytes[field..field + 4].copy_from_slice(&(300u32 << 20).to_be_bytes());
let src = Arc::new(PaddedSource { bytes, len: 1 << 30, largest: AtomicUsize::new(0) });
let mut m = Movie::open_source(src.clone()).unwrap();
let r = m.frame(0);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("sample larger than 256 MiB")), "{r:?}");
let largest = src.largest.load(Ordering::Relaxed);
assert!(largest <= 256 << 20, "a {largest}-byte read reached the source");
}
#[test]
fn unsupported_codec_opens_but_does_not_decode() {
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
+42 -16
View File
@@ -35,6 +35,11 @@ pub(crate) struct Layout {
pub event_input: bool,
}
/// `"input"` or `"output"`, for error messages.
fn dir_name(dir: sv::BusDirections) -> &'static str {
if dir == sv::BusDirections_::kInput { "input" } else { "output" }
}
pub(super) fn apply_transport(c: &mut sv::ProcessContext, t: &Transport) {
use sv::ProcessContext_::StatesAndFlags_::{
kBarPositionValid, kContTimeValid, kPlaying, kProjectTimeMusicValid, kRecording, kTempoValid, kTimeSigValid,
@@ -248,7 +253,17 @@ impl Instance {
fn bus_count(&self, media: sv::MediaTypes, dir: sv::BusDirections) -> i32 {
// SAFETY: valid component.
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.clamp(0, MAX_BUSES)
unsafe { self.component.getBusCount(media as sv::MediaType, dir as sv::BusDirection) }.max(0)
}
/// The plugin's audio buses in one direction. More than `MAX_BUSES` is an error: we never
/// hand a plugin fewer buses than it advertised.
fn audio_bus_count(&self, dir: sv::BusDirections) -> Result<i32, Vst3Error> {
let n = self.bus_count(sv::MediaTypes_::kAudio, dir);
if n > MAX_BUSES {
return Err(Vst3Error::Process(format!("{n} audio {} buses (at most {MAX_BUSES} supported)", dir_name(dir))));
}
Ok(n)
}
fn arrangement(&self, dir: sv::BusDirections, index: i32) -> Option<sv::SpeakerArrangement> {
@@ -258,30 +273,41 @@ impl Instance {
ok(unsafe { p.getBusArrangement(dir as sv::BusDirection, index, &mut arr) }).then_some(arr)
}
fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> u32 {
if let Some(arr) = self.arrangement(dir, index) {
return arr.count_ones().min(MAX_BUS_CHANNELS);
}
// SAFETY: plain data; all-zero is valid.
let mut info: sv::BusInfo = unsafe { std::mem::zeroed() };
// SAFETY: valid component; index below the bus count; `info` is writable.
if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) {
u32::try_from(info.channelCount).unwrap_or(0).min(MAX_BUS_CHANNELS)
/// Channels of one audio bus. More than `MAX_BUS_CHANNELS` is an error, as for the bus count.
fn bus_channels(&self, dir: sv::BusDirections, index: i32) -> Result<u32, Vst3Error> {
let channels = if let Some(arr) = self.arrangement(dir, index) {
arr.count_ones()
} else {
0
// SAFETY: plain data; all-zero is valid.
let mut info: sv::BusInfo = unsafe { std::mem::zeroed() };
// SAFETY: valid component; index below the bus count; `info` is writable.
if ok(unsafe { self.component.getBusInfo(sv::MediaTypes_::kAudio as sv::MediaType, dir as sv::BusDirection, index, &mut info) }) {
u32::try_from(info.channelCount).unwrap_or(0)
} else {
0
}
};
if channels > MAX_BUS_CHANNELS {
return Err(Vst3Error::Process(format!(
"audio {} bus {index} has {channels} channels (at most {MAX_BUS_CHANNELS} supported)",
dir_name(dir)
)));
}
Ok(channels)
}
/// Stops processing, deactivates, negotiates the bus layout (main buses mono or stereo as
/// asked, falling back to stereo and then to the plugin's own choice), activates the main
/// buses, sets up 32-bit processing and activates the component.
/// buses, sets up 32-bit processing and activates the component. A layout beyond
/// `MAX_BUSES` buses or `MAX_BUS_CHANNELS` channels per bus is an error, and the component
/// stays inactive.
pub fn configure(&mut self, sample_rate: f64, max_frames: usize, mono: bool) -> Result<Layout, Vst3Error> {
self.shutdown();
let audio = sv::MediaTypes_::kAudio;
let event = sv::MediaTypes_::kEvent;
let (din, dout) = (sv::BusDirections_::kInput, sv::BusDirections_::kOutput);
let nin = self.bus_count(audio, din);
let nout = self.bus_count(audio, dout);
let nin = self.audio_bus_count(din)?;
let nout = self.audio_bus_count(dout)?;
let current = |s: &Self, dir: sv::BusDirections, n: i32| -> Vec<sv::SpeakerArrangement> {
(0..n).map(|i| s.arrangement(dir, i).unwrap_or(sv::SpeakerArr::kStereo)).collect()
};
@@ -311,8 +337,8 @@ impl Instance {
log::debug!("vst3: plugin refused mono/stereo bus arrangements; using its own");
}
let layout = Layout {
inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect(),
outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect(),
inputs: (0..nin).map(|i| self.bus_channels(din, i)).collect::<Result<_, _>>()?,
outputs: (0..nout).map(|i| self.bus_channels(dout, i)).collect::<Result<_, _>>()?,
event_input: self.bus_count(event, din) > 0,
};
// Main buses on, aux buses off (VST3 still gets buffers for every bus).
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path {
let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder);
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(&built, bin.join(exe)).unwrap();
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 2);
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5);
dir
})
}
+109 -2
View File
@@ -1,4 +1,4 @@
//! Test-only VST3 plugin for soundcraft-vst3-host (two audio module classes):
//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes):
//!
//! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller
//! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain"
@@ -11,6 +11,11 @@
//! its edit controller). No audio input, one stereo output, one event input; every output
//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and
//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too.
//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects
//! advertising wide layouts (16 buses of 64 channels, 17 buses of 2 channels, one bus of 65
//! channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not.
//! Report 3 samples of latency.
//!
//! The module checks the host's module lifecycle the way real plugins depend on it (see "module
//! lifecycle" below) and aborts the process when it is broken.
@@ -28,6 +33,9 @@ use vst3::{Class, ComRef, ComWrapper, uid};
const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666);
const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666);
const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666);
const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666);
const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666);
fn copy_c8(src: &str, dst: &mut [c_char]) {
for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) {
@@ -626,6 +634,98 @@ impl IEditControllerTrait for Synth {
}
}
// ---- wide layouts --------------------------------------------------------------------------
struct Wide {
buses: i32,
channels: i32,
_live: Live,
}
impl Class for Wide {
type Interfaces = (IComponent, IAudioProcessor);
}
impl IPluginBaseTrait for Wide {
unsafe fn initialize(&self, _context: *mut FUnknown) -> tresult {
kResultOk
}
unsafe fn terminate(&self) -> tresult {
kResultOk
}
}
impl IComponentTrait for Wide {
unsafe fn getControllerClassId(&self, _class_id: *mut TUID) -> tresult {
kNotImplemented
}
unsafe fn setIoMode(&self, _mode: IoMode) -> tresult {
kResultOk
}
unsafe fn getBusCount(&self, media: MediaType, _dir: BusDirection) -> i32 {
if media == MediaTypes_::kAudio as MediaType { self.buses } else { 0 }
}
unsafe fn getBusInfo(&self, media: MediaType, dir: BusDirection, index: i32, bus: *mut BusInfo) -> tresult {
if media != MediaTypes_::kAudio as MediaType || !(0..self.buses).contains(&index) {
return kInvalidArgument;
}
audio_bus(bus, dir, "Wide");
unsafe { (*bus).channelCount = self.channels };
kResultOk
}
unsafe fn getRoutingInfo(&self, _i: *mut RoutingInfo, _o: *mut RoutingInfo) -> tresult {
kNotImplemented
}
unsafe fn activateBus(&self, _m: MediaType, _d: BusDirection, _i: i32, _s: TBool) -> tresult {
kResultOk
}
unsafe fn setActive(&self, _state: TBool) -> tresult {
kResultOk
}
unsafe fn setState(&self, _state: *mut IBStream) -> tresult {
kResultOk
}
unsafe fn getState(&self, _state: *mut IBStream) -> tresult {
kResultOk
}
}
impl IAudioProcessorTrait for Wide {
unsafe fn setBusArrangements(&self, _i: *mut SpeakerArrangement, _n_in: i32, _o: *mut SpeakerArrangement, _n_out: i32) -> tresult {
kResultFalse
}
unsafe fn getBusArrangement(&self, _dir: BusDirection, _index: i32, _arr: *mut SpeakerArrangement) -> tresult {
kNotImplemented
}
unsafe fn canProcessSampleSize(&self, size: i32) -> tresult {
if size == SymbolicSampleSizes_::kSample32 as i32 { kResultOk } else { kNotImplemented }
}
unsafe fn getLatencySamples(&self) -> u32 {
3
}
unsafe fn setupProcessing(&self, _setup: *mut ProcessSetup) -> tresult {
kResultOk
}
unsafe fn setProcessing(&self, _state: TBool) -> tresult {
kResultOk
}
unsafe fn process(&self, data: *mut ProcessData) -> tresult {
let d = unsafe { &*data };
let fits =
|bufs: *mut AudioBusBuffers, n: i32| n == self.buses && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.numChannels == self.channels);
let ok = fits(d.inputs, d.numInputs) && fits(d.outputs, d.numOutputs);
if d.numOutputs > 0 {
for ch in unsafe { channels(&*d.outputs, d.numSamples.max(0) as usize) } {
ch.fill(if ok { 1.0 } else { -1.0 });
}
}
kResultOk
}
unsafe fn getTailSamples(&self) -> u32 {
0
}
}
// ---- factory -------------------------------------------------------------------------------
struct Factory {
@@ -636,10 +736,13 @@ impl Class for Factory {
type Interfaces = (IPluginFactory2,);
}
const CLASSES: [(TUID, &str, &str, &str); 3] = [
const CLASSES: [(TUID, &str, &str, &str); 6] = [
(GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"),
(GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""),
(SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"),
(WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"),
(WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"),
(WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"),
];
impl IPluginFactoryTrait for Factory {
@@ -682,6 +785,10 @@ impl IPluginFactoryTrait for Factory {
.to_com_ptr::<FUnknown>()
} else if cid == SYNTH_CID {
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else if let Some((buses, channels)) =
[(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout))
{
ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else {
None
};
+21 -1
View File
@@ -15,7 +15,7 @@ fn id_of(name: &str) -> String {
fn scan_lists_both_audio_modules_but_not_the_controller_class() {
let dir = fixture_dir();
let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]);
assert_eq!(found.len(), 2, "{found:?}");
assert_eq!(found.len(), 5, "{found:?}");
let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap();
// `uid()` lays out the first 8 bytes differently on Windows (COM GUID order).
let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" };
@@ -311,6 +311,26 @@ fn host_set_controller_param_survives_state_round_trip() {
assert!((r.param("12").unwrap() - 200.0).abs() < 0.01, "a loaded state replaces earlier edits: {:?}", r.param("12"));
}
#[test]
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
// Control: exactly at the limits (16 buses of 64 channels) activates and gets its full layout.
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 16x64")).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 3);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
// 17 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] {
let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 0, "{name}: not activated");
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 0.5), "{name}: the plugin never ran: {:?}", &io[0][..4]);
}
}
#[test]
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");
+8 -2
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-25 · **Change:** trivial (G-04: Clip Looping crossfade and loop count; previously trivial: G-08 and G-20: built-in plugin count, the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (G-01: `setup.playback_engine` applies the device buffer in the desktop app; G-04: Clip Looping crossfade and loop count) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
@@ -46,11 +46,16 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
device, sample rate and device buffer size (kept with the preferences, per machine; `ui.audio_device`
for agents); f32 and 16/32-bit integer devices; ASIO behind the opt-in `asio` build feature
(verified on Windows with an SSL 2+ at 128 samples).
- **Done (2026-10-25):** `setup.playback_engine` and `setup.hardware` apply `device` (the output
name) and `buffer_size` in the desktop app on the same path as the Playback Engine window
(`ui.audio_device`) and return the open device. `buffer_size` is any integer 16..=8192 and is
not rounded onto the window's power-of-two list. Headless (CLI, no app) still stores the request
and returns `applied: false`, with `buffer_size` null when unset; playback does not read those
session values.
- **Missing:** routing to physical channels (I/O Setup), one duplex stream on one clock (input and
output are still separate cpal streams), latency reporting and record-offset compensation, ASIO
in official builds (owner decision: Steinberg's SDK is proprietary or GPLv3), JACK, and
verification on macOS and Linux. The mix still runs in fixed 512-sample blocks.
`setup.playback_engine` still stores `device`/`buffer_size` in the session, unused.
- **Evidence:** code above; Playback Engine window says "Mix block size: 512 samples"; issue #42
(no input device selection, forced monitoring feedback with virtual routing).
- **Impact:** blocks tracking with any multichannel interface; latency makes overdubs hard.
@@ -226,6 +231,7 @@ missing. Out of scope unless the owner decides otherwise.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | G-04: Clip Looping (#1030) lacks the loop crossfade option, and Number of Loops stops at 512 while length and fill make up to 1000 loops |
| 2026-10-25 | trivial | G-01: `setup.playback_engine` and `setup.hardware` apply the device and buffer in the desktop app; headless returns `applied: false` |
| 2026-10-24 | trivial | G-08: 27 processors + 3 instruments, as the plugin registry holds; G-20: the sampler shipped (#108), so it is no longer listed as missing |
| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
+3 -2
View File
@@ -1,6 +1,6 @@
# SoundCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** trivial (built-in plugin count; the sampler shipped) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** trivial (`setup.playback_engine` applies the device buffer in the desktop app) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Forward-looking plan. Status and numbers: [`../ROADMAP.md`](../ROADMAP.md) and
[`target-app-parity.md`](target-app-parity.md); the ranked work list: [`gaps.md`](gaps.md).
@@ -23,7 +23,7 @@ main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are th
| Workflow | Works end to end? | Evidence | Hours to pass |
|---|---|---|---:|
| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Default output and input devices only. Fixed 512-sample block. Separate input and output streams. `setup.playback_engine` params ignored by playback. Takes held in RAM until stop (PR #84). No input device choice (#42). | 35–55 |
| Record audio from a chosen interface at a chosen buffer size, monitor, punch, keep the takes | **no** | Device, rate and buffer are chosen in the Playback Engine window and, in the desktop app, by `setup.playback_engine` (headless returns `applied: false`). The mix still runs in fixed 512-sample blocks. Separate input and output streams. Takes held in RAM until stop (PR #84). | 35–55 |
| Record MIDI from a controller and play it back through an instrument | **no** | Playback of drawn and imported MIDI works. No MIDI device layer at all, so nothing can be recorded and instruments can't be played live (G-02). | 20–35 |
| Edit: select, trim, fade, comp playlists, Slip/Shuffle/Grid | yes (rough) | Every Edit menu command; property-tested with undo; open bugs #3, #5, #140 don't block the workflow | — |
| Mix with built-in and third-party plugins, sends, busses, automation | partial (passes on CLAP/AU) | 27 built-in processors; CLAP and AU host on the signed macOS build, but its missing entitlement blocks third-party VST3 (#153). No AU plugin windows yet; VST3 windows on macOS only | 1–2 (entitlement) |
@@ -89,6 +89,7 @@ There is no `cargo xtask scorecard` in this repo; the measured number is `cargo
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Record-workflow evidence: `setup.playback_engine` applies the device and buffer in the desktop app; headless returns `applied: false` |
| 2026-10-24 | trivial | 27 built-in processors, as the plugin registry holds; the sampler (#108) is no longer listed under plugin essentials |
| 2026-10-14 | trivial | M8: reject oversized MCP batches before execution (#460) |
| 2026-10-10 | minor | Added the alpha gate (3 of 6 core workflows fail → pre-alpha) |
+4 -3
View File
@@ -1,8 +1,9 @@
#!/usr/bin/env bash
# Build and package SoundCraft for FreeBSD:
#
# $DIST/soundcraft-<version>-freebsd-x86_64.tar.gz a /usr/local-style tree:
# soundcraft-<version>-freebsd-x86_64/{bin, man/man1, share/applications, share/icons,
# $DIST/soundcraft-<version>-freebsd-<arch>.tar.gz a /usr/local-style tree (<arch> is x86_64, or
# aarch64 when CARGO_BUILD_TARGET=aarch64-unknown-freebsd):
# soundcraft-<version>-freebsd-<arch>/{bin, man/man1, share/applications, share/icons,
# share/mime, share/metainfo, share/doc/soundcraft, share/{bash-completion,zsh,fish}}
#
# Install by copying the tree's contents into /usr/local, e.g.
@@ -23,7 +24,7 @@ SKIP_BUILD=0
while [[ $# -gt 0 ]]; do
case "$1" in
--skip-build) SKIP_BUILD=1; shift ;;
-h | --help) sed -n '2,15p' "$0"; exit 0 ;;
-h | --help) sed -n '2,16p' "$0"; exit 0 ;;
*) echo "unknown argument: $1" >&2; exit 2 ;;
esac
done
@@ -23,6 +23,8 @@ finish-args:
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# Update checks reach GitHub (Help > Check for Updates and the startup check).
- --share=network
# GPU access for the wgpu UI (Vulkan, or GL as a fallback).
- --device=dri
# Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its
@@ -28,6 +28,8 @@ finish-args:
- --socket=fallback-x11
# X11 shared memory (MIT-SHM). Without it the X11 fallback is slow or fails.
- --share=ipc
# Update checks reach GitHub (Help > Check for Updates and the startup check).
- --share=network
# GPU access for the wgpu UI (Vulkan, or GL as a fallback).
- --device=dri
# Playback and recording. cpal talks to ALSA; the runtime's alsa-lib routes ALSA through its