Compare commits

...
68 Commits
Author SHA1 Message Date
FredApps 7e58c3a617 Release: SoundCraft v0.20.0 (main c25b12ba46) 2026-11-11 23:11:56 +01:00
FredApps 89bd8d86ff Release: SoundCraft v0.20.0 2026-11-11 23:11:55 +01:00
Dylan M. Taylor c25b12ba46 Engine I/O: exclusive output files, read-checked probes, resolved clip-group paths (#1328)
* fix(engine): create output files exclusively and check read access before probing

* address review: canonicalize through the checked io gateway, treat any occupied export name as taken

* address review: read group audio through the validated file handle

* address review: document that a write-only root writes a new numbered WAV per save

* address review: clip-group audio is opened without waiting for a FIFO writer
2026-11-11 16:15:08 -05:00
Dylan M. Taylor 296bfbb2e1 Strip Silence: cut and remove pieces in one pass over the playlist (#1349)
* Strip Silence: cut and remove pieces in one pass over the playlist

* address review: document the sorted-points order and test stretched audio offsets
2026-11-11 16:14:42 -05:00
Dylan M. Taylor f7a5adb474 ui: read-check movie paths, regular files only, private take folder (#1337)
* fix(ui): check movie read access and file type; private take folder without settings

* address review: make the fallback take folder exclusively under an unguessable name; qualify the owner-only claim to Unix

* address review: unique folder prefix must be one name; no fallback take folder for a saved session

* add tests for the take folder and the movie open checks
2026-11-11 16:14:17 -05:00
Dylan M. Taylor 23242338a6 ui: bound MIDI grid lines and Video Universe thumbnails per repaint (#1336)
* fix(ui): bound MIDI grid lines and Video Universe thumbnails per repaint

* address review: grid fallback stays on snap positions and bars; thumbnail positions in f64

* address review: no thumbnails for an empty shown span
2026-11-11 16:13:52 -05:00
Dylan M. Taylor e01ea00cdd audio-io: cap decoder packet samples and MP4 audio timing runs (#1332)
* fix(audio-io): bound packet samples and MP4 audio timing runs

* address review: decide MP4 audio timing bound from the stsd entry and bound PCM packets by their byte size

* address review: the last stsd in a stbl decides whether the stts bound applies
2026-11-11 16:13:28 -05:00
Dylan M. Taylor 659ec7a8e2 Video: check MJPEG frame size before decoding; budget each H.264 frame request (#1326)
* fix(video): check MJPEG frame size from its header and budget H.264 frame requests

* address review: check the sample budget before reading, keep the decoder from skipping a refused sample, stop on a failed read

* address review: restore lenient MJPEG sizing and unreadable-sample skipping; per-request sample cap 2 GiB

* address review: an unreadable sample counts toward the no-picture budget
2026-11-11 16:13:03 -05:00
FredApps 90e069f626 Merge pull request #1492 from istefp/fix/io-menu-search
I/O menus: the search finds the hardware inputs, the main output and no route, not just buses
2026-11-11 21:06:43 +01:00
FredApps 7a4f9c911d Merge pull request #1490 from istefp/fix/long-menu-scroll
Main menu: a menu taller than the window scrolls
2026-11-11 21:06:25 +01:00
Dylan M. Taylor 60f97a99a5 chore: bump version to 0.19.1 for the Flatpak update fix (#1507) 2026-11-11 14:56:05 -05:00
FredApps d255ca4fe9 Merge pull request #1436 from Silvertree2010/fix/1433-separate-splits-locked-clips-edi
Keep time- and edit-locked clips whole on separate and range edits
2026-11-11 20:37:20 +01:00
FredApps 1fe1d06073 test: cover Event time edits on locked clips 2026-11-11 20:24:33 +01:00
Dylan M. Taylor 487b5ebde0 Release 0.19.1: repair Flatpak update checks and self-updates (#1505)
* Release 0.19.1: fix Flatpak update permissions

* test: avoid unchecked option parsing in Flatpak regression
2026-11-11 14:21:43 -05:00
Dylan M. Taylor 10e4bc7b87 Fix Flatpak permissions for update checks and self-updates (#1504)
* fix(flatpak): allow release checks and self-update helpers

* test: keep Flatpak permission parsing within strict Clippy rules
2026-11-11 14:19:10 -05:00
1d696bb3ef Replace hard-linked export destinations (#1360)
* Replace hard-linked export destinations

Fixes #1359

Co-Authored-By: GPT-6-Luna <noreply@openai.com>

* fix(engine): preserve export links and private permissions

* fix(io): preserve export security by refusing hard-linked destinations

* test(automation): require safe hard-link export refusal on Windows and Unix

---------

Co-authored-by: GPT-6-Luna <noreply@openai.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 20:04:44 +01:00
Dylan M. Taylor 9f342bf403 Merge pull request #1502 from storytold/chore/craft-update-6e03f65
Updates: take craft-update from craft-libs main at 6e03f65bf
2026-11-11 12:45:08 -05:00
Dylan M. Taylor 17649aa693 Updates: take craft-update from craft-libs main at 6e03f65bf 2026-11-11 12:45:03 -05:00
Dylan M. Taylor 0e511b1259 ci: run Harden-Runner on every job (#1501)
Adds step-security/harden-runner (audit, pinned to v2.22.4 like
craft-common-actions) as the first step of each job that did not have it,
so every workflow in the org's public repos reports its egress to
StepSecurity. Audit, not block: block-mode allow lists come from each
job's observed traffic.
2026-11-11 12:41:53 -05:00
Jonathan CardosoandFredApps 927c4f7b72 Read volume automation at every sample, not once per block (#1355)
* Read volume automation at every sample, not once per block

Track and master faders read the dB curve at a block's first and last
sample and ramped linearly in gain between them, so a breakpoint acted up
to a block early or late and a short crossfade came out differently
depending on where the block edges fell. Both now take the gain at each
sample: the curve is read at its breakpoints and interpolated in dB in
between, as the lane defines it. Blocks with no change keep one gain.

Fixes #1354.

* docs: volume breakpoints no longer depend on the render block (G-04)

* Step fader ramps by a ratio and scale channels in contiguous runs

Reading the fader at every sample converted dB to gain per sample on
every ramp, and applied it sample by sample across channels, even when
nothing moved. A stretch that is linear in dB is geometric in gain, so
each stretch now converts its two ends exactly and steps the samples in
between by one ratio (in f64, so it does not drift), with the -144 dB
floor and the cap checked per sample. Each channel is scaled over the
whole stretch at once, and a stretch with no change is a plain multiply.

Tests: stepped ramps against db_to_gain sample by sample (floor, cap,
NaN, one or two samples, a 16384-sample ramp), and summed volume, trim
and VCA lanes with a muted track, read from renders that start inside a
ramp or on block edges.

* fix(mix): recover extreme automation ramps from silence

* fix(mix): preserve extreme ramp endpoints and silence NaNs

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 18:41:07 +01:00
69af50aac2 fix(engine): trim skips locked clips (#1432) (#1442)
* trim skips locked clips

* docs(engine): add missing docstring on trim_end_to

* test(engine): cover insertion trims beside locked clips

---------

Co-authored-by: rixpsed <183939167+rixpsed@users.noreply.github.com>
Co-authored-by: rixpsed <rixpsed@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 18:18:03 +01:00
Vladimir BabinandFredApps e86d09ab2a Compensate a hosted instrument's latency (#1476)
* Compensate a hosted instrument's latency

Delay compensation summed only insert latency, so a synth reporting
latency played late against everything else in playback and bounces.
run_inserts now seeds the strip latency with the instrument's.

Fixes #1474. Added a mix test that fails without the change: a latent
hosted instrument now contributes its latency to the mix.

Signed-off-by: Vladimir Babin <vovababin@gmail.com>

* test(mix): verify rendered instrument latency alignment

---------

Signed-off-by: Vladimir Babin <vovababin@gmail.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 18:08:21 +01:00
FredApps ad34dbf87e Merge pull request #1364 from MMexicann/fix/stem-bounce-output-format
fix(engine): reject unsupported stem bounce formats
2026-11-11 17:43:58 +01:00
istefpandPatrick Istefanidis f780dcab37 Main menu test: scroll to the Options menu's last entry, not its last submenu (#1482)
Co-Authored-By: Patrick Istefanidis <smallroomproductionsfl@gmail.com>
2026-11-11 14:37:44 +00:00
ljkx ff4be04936 CLAP host: a reset keeps the queued note-offs, so instruments stop at Stop, Play and Locate (#1464) (#1470)
* CLAP host: a reset keeps the queued note-offs and releases held notes (#1464)

ClapPlugin::reset kept only parameter changes in its event queue and cleared
the held keys, so the note-offs all_notes_off had just queued on Stop, Play
and Locate never reached the plugin. A plugin whose own reset leaves its
voices sounding (Surge XT) played them on at the next Play, and All MIDI
Notes Off had nothing left to release.

Like the VST3 host since #441, reset now keeps the pending releases (CLAP
and MIDI note-offs, one per key, at the start of the next block) and
releases whatever is still held. The fixture synth's reset no longer
silences it, so the integration test sees what Surge XT does.

* CLAP host: keep queue room for the releases a reset sends (#1464)

A queue that distinct parameter changes filled had no room left for the
releases reset queues, so a held note could still sound on. The last 128
queue slots now take only note_off releases, and reset queues one release
per held key that has none yet, which always fits in that reserve.

* CLAP host: a release the full queue drops leaves its key held (#1464)

note_off cleared the key's held flag even when its release did not fit in
the queue, so the reset after all_notes_off had nothing left to release.
The flag is now cleared only when the release was queued; the reset then
sends it, after keep_releases has made room.
2026-11-11 15:07:54 +01:00
NetterSilverandClaude Haiku 5.5 1483dc833b Fix AutomationLane::value_at overflow between extreme opposite-sign points (#1444)
Found while testing crates/model/src/automation.rs:~161 (AutomationLane::value_at)

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-11 15:05:23 +01:00
istefpandPatrick Istefanidis 2c042c599f I/O menus: the search finds the hardware inputs, the main output and no route, not just buses (#1491)
Co-Authored-By: Patrick Istefanidis <smallroomproductionsfl@gmail.com>
2026-11-11 05:21:27 +00:00
istefpandPatrick Istefanidis e94a7875d3 Main menu: a menu taller than the window scrolls (#1482)
Co-Authored-By: Patrick Istefanidis <smallroomproductionsfl@gmail.com>
2026-11-11 05:06:26 +00:00
Dylan M. Taylor dc4f82ea9f packaging: use [[ for bash tests (SonarCloud S7688) (#1488)
Behaviour-preserving: `[[ ]]` in place of `[ ]` for bash tests (SonarCloud shell:S7688). Every script has a bash shebang; no test uses `-a`/`-o`, escaped parentheses or an unquoted pattern on the right of `=`. `bash -n` passes on each script; the rest is left to CI.

- `packaging/linux/flatpak-bundle.sh` (4)
2026-11-10 18:45:08 -05:00
Dylan M. Taylor df67089ccd Consolidate: run clip effects over the clip and a bounded tail (#1307)
* Consolidate: run clip effects over the clip and a bounded tail

render_clips allocated and ran a clip-effects workspace spanning the
whole selection for every clip with effects, however short the clip.
Many tiny effect clips in a long selection multiplied allocation and
effect processing by the selection length.

Process each clip's effects in reused 4096-frame blocks from the clip's
start (the effects start from silence, so nothing comes out before it)
through its end, then keep rendering the tail until a block falls below
-180 dBFS or 10 s past the clip's end.

* address review: settle clip effect smoothers on a bounded silent lead-in

* address review: skip the lead-in for gain-only clip effects; keep the full-span chunk grid
2026-11-10 17:58:40 -05:00
Dylan M. Taylor 44e80dcc24 Import: refuse sample-rate conversions that grow audio more than 128-fold (#1327)
* fix(engine): refuse extreme sample-rate expansions before resampling

* address review: widened integer rate-ratio check and a distinct rate-limit error
2026-11-10 17:58:18 -05:00
Dylan M. Taylor 04878f8fda Release 0.19.0 2026-11-09 06:33:34 -05:00
FredApps ef58a90f4a Merge pull request #1007 from Audiosyncrasy/smart-tool-shortcuts
Edit tools: F6+F7 / F7+F8 and Cmd/Ctrl+7 select the Smart Tool; Cmd/Ctrl+6 selects the Pencil
2026-11-09 11:56:05 +01:00
Dylan M. Taylor 3f6703f87f Merge pull request #1463 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 22:55:48 -05:00
dotCooCoo c4c09ca789 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:24 -08:00
FredApps 7c6315d999 Naming: descriptive names for Pro Tools product and trademark labels (#1457)
Display labels only; command ids, catalog paths, session tokens and processor ids are unchanged.

- Avid-listed names and bundled plug-in products get our own names (Beat Analysis, Punch Recording, Room Reverb, Compressor/Limiter, Saturator, Bit Crusher, Rendered (High Quality), ...).
- Features we don't have stay in the menu as disabled placeholders with "Not available yet." (Tempo Sync, No ARA plug-ins, Analog Saturation).
- AudioSuite, Elastic Audio, TrackPunch, Pitch Shift Legacy and Time Compression Expansion keep their names until RFC-0029 settles.
- parity_floor and catalog dispatch unchanged; docs/feature-naming.md has the crosswalk.
2026-11-09 04:31:12 +01:00
Dylan M. Taylor 0ea934290d Merge pull request #1456 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:13:10 -05:00
dotCooCoo 20338e0832 Updates: the updater reports installing, ready, installed and failed, and installs at exit
Moves the craft-libs crates (craft-update, craft-docpath, craft-media,
craft-ui) from c5a1cbfb5f to a branch that merges storytold/craft-libs
#687 and #696.

- The result dialog shows whenever the updater reports a status, so the
  first start after an update says whether it installed or failed.
  Closing it dismisses the status; the startup prompt still takes over.
- Restart to Update in the dialog asks the app to close through the
  usual close path.
- on_exit starts the helper of a ready update, since the system Quit
  ends the process before the updater is dropped.
2026-11-08 10:02:58 -08:00
FredApps ddeb7e791f Merge pull request #1454 from FredApps/fix/restore-shortcut-plain-guard
fix(ui): restore plain arrow guard after zoom shortcut extraction
2026-11-08 17:54:53 +01:00
FredApps 507786ad7a fix(ui): restore plain arrow guard after zoom shortcut extraction 2026-11-08 17:43:16 +01:00
FredApps a7b7fb949e Merge pull request #1398 from ZIDAN44/fix/1377-zoom-key-repeat
fix(ui): holding a zoom key keeps zooming (#1377)
2026-11-08 17:07:09 +01:00
FredApps 16e495550c Merge pull request #1397 from ZIDAN44/fix/1254-color-strip-double-click
fix(ui): double-clicking a track's colour strip opens the Color Palette (#1254)
2026-11-08 17:05:43 +01:00
Mex e1b22258bc test(engine): cover explicit WAV stems and reusable scratch directories
Address the review by clearing stale test scratch directories before setup and exercising an explicit supported WAV format through the real command, including decoded output format, frames and audio.
2026-11-08 13:42:03 +01:00
Mex 61becd52c5 Merge branch 'storytold:main' into fix/stem-bounce-output-format 2026-11-08 13:33:28 +01:00
FredApps 76cc2aeac6 Merge pull request #1006 from Silvertree2010/fix/886-audit-the-audio-callback-blocks
Render strips serially on the audio thread instead of using rayon
2026-11-08 02:21:27 +01:00
FredApps 26cafa85bb test(mix): report strip helper errors without unwrap 2026-11-08 02:10:22 +01:00
FredApps 8cdcab2697 fix(mix): keep audio thread tests native-only 2026-11-08 02:05:36 +01:00
Luis 436a2e22a8 Release: refresh contributor credits for each release (#1424) (#1425)
About > Contributors still showed an old snapshot, because nothing
regenerated contributors/contributors.json before a release. Opt in to
the shared refresh from craft-common-actions: a read-only credits job
regenerates the snapshot for the release commit, and the shared release
workflow installs it before every source build. The snapshot is not
committed back.
2026-11-08 01:41:45 +01:00
FredApps b6f9f150a1 Merge pull request #1417 from ZIDAN44/fix/1374-sub-counter-format-menu
fix(ui): the Sub counter opens its format menu, as the Main counter does (#1374)
2026-11-07 15:41:05 +01:00
FredApps fbd7789b4a Merge pull request #1416 from ZIDAN44/fix/1409-web-export-clips
fix(engine): Bounce Stems and Export Clips work in the web build (#1409)
2026-11-07 15:40:58 +01:00
FredApps 4edd315adb Merge pull request #1415 from ZIDAN44/fix/1376-arrows-scroll-to-selection
fix(ui): Left and Right arrows centre the Edit window on the selection (#1376)
2026-11-07 15:40:53 +01:00
NetterSilverandClaude Opus 5.5 654deabd54 Keep time- and edit-locked clips whole on separate and range edits
Fixes #1433

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:41:54 +00:00
Zinadin Zidan 6c917ea378 fix(ui): a MIDI editor behind another tab leaves the arrows to the Edit window (review)
The piano roll's rect stayed recorded after its tab went behind another
one, so with the pointer over the panel now in front, Left and Right
still went to the hidden piano roll and the Edit window did not scroll.
The rect is now recorded only by a frame that draws the MIDI panel.
2026-11-07 02:02:43 +06:00
Zinadin Zidan c05dfc0a8e fix(engine): Export Clips names a run of same-named clips in one pass (review)
Each clip looked for its file name from the start again, checking the
disk for every earlier name, so exporting many clips of one name took
time growing with the square of their number. The export now remembers
the next suffix to try for each name. Files already in the folder are
still skipped.
2026-11-07 02:01:24 +06:00
Zinadin Zidan 6f54446423 fix(ui): the Sub counter opens its format menu, as the Main counter does (#1374)
Clicking the Sub counter (its label, value or the new small triangle) opens
the time formats and runs view.sub_counter. The Main counter's menu moved
into format_menu(), which both counters use, and view.sub_counter leaves the
list of commands with no way in from the UI.
2026-11-07 00:27:10 +06:00
Zinadin Zidan 24451d5c42 fix(ui): Left and Right arrows centre the Edit window on the selection (#1376)
With a selection in the Edit window, plain Left scrolls the view so the
selection's start is in the middle of the timeline, and Right its end.
Without a selection the keys do nothing, as before. With the pointer over
the MIDI editor the arrows keep moving notes there.
2026-11-07 00:27:09 +06:00
Zinadin Zidan 7d508e2e44 fix(engine): Bounce Stems and Export Clips work in the web build (#1409)
Both made a folder first, which fails in the browser. Making a folder is
now a no-op on wasm32, where each file is delivered by name, and Export
Clips writes through write_file, the path bounce and export already use,
instead of std::fs. Two clips with the same name no longer get the same
file name when nothing is on disk. Native behaviour is unchanged.
2026-11-07 00:27:09 +06:00
FredApps bb0e070ab9 Merge pull request #1407 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:25 +01:00
Zinadin ZidanandRahul fca0130b72 fix(ui): double-clicking a track's colour strip opens the Color Palette (#1254)
The colour strip at the left of an Edit-window track header now has its own
click area. A double-click selects the track, keeping a multi-selection it
is part of, and opens Window > Color Palette, which colours the selected
tracks. A click on an unselected track's strip selects it as the header
did; a plain click on an already selected track's strip leaves the selection
alone, so a double-click can colour several tracks. Right-click still opens
the track menu.

Co-Authored-By: Rahul <rahulsingh.ranjan06@gmail.com>
2026-11-06 00:50:57 +06:00
Zinadin Zidan 77b788007a fix(ui): holding a zoom key keeps zooming (#1377)
The shortcut dispatcher drops every key auto-repeat (#1063), so a held zoom
key zoomed one step. A repeat now runs when its chord leads to view.zoom_in,
view.zoom_out or view.waveform_zoom (T/R, Cmd+[ / ], Cmd+Alt+[ / ]); every
other repeat is still dropped, so a held Cmd+Z still undoes once.
2026-11-06 00:50:46 +06:00
Mex b93a182ef7 test(engine): cover non-string stem output formats
Exercise null, numeric, boolean, array and object format values through Engine::execute and verify the parameter error without creating a destination. The regression fails before the guard; all 363 engine unit tests pass with it.
2026-11-05 00:11:18 +01:00
Mex bf2a6b52de fix(engine): reject non-string stem output formats
Address review r3017244428: only an absent format defaults to WAV; a present non-string value returns a parameter error before creating the output directory.
2026-11-05 00:09:33 +01:00
Mex b8e1e69134 test(engine): cover unsupported stem output formats 2026-11-04 23:35:04 +01:00
Mex b6946d3a94 fix(engine): reject unsupported stem bounce formats
Refactor file.bounce_stems command to improve format handling.
2026-11-04 23:33:22 +01:00
NetterSilverandClaude Haiku 5.5 e569fbc50e Render strips serially on the audio thread instead of using rayon
Fixes #886

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 02:07:56 +01:00
audiosyncrasy bf56b66b5c Test: cover F5, F9 and F10 alone in the tool-key test 2026-10-24 00:41:55 -04:00
audiosyncrasy 82202fe88c Merge remote-tracking branch 'upstream/main' into smart-tool-shortcuts 2026-10-23 23:54:40 -04:00
audiosyncrasy ef14d75534 Edit tools: F6+F7 / F7+F8 and Cmd/Ctrl+7 select the Smart Tool; Cmd/Ctrl+6 selects the Pencil 2026-10-23 23:54:40 -04:00
78 changed files with 3582 additions and 351 deletions
+4
View File
@@ -27,6 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+8
View File
@@ -15,7 +15,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
@@ -26,6 +33,7 @@ jobs:
app-name: soundcraft
app-title: SoundCraft
version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
Generated
+26 -37
View File
@@ -1064,17 +1064,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"thiserror 2.0.21",
]
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1126,7 +1126,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1136,6 +1136,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -4330,7 +4331,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"chrono",
"clap",
@@ -4354,7 +4355,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"serde",
@@ -4364,7 +4365,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"proptest",
@@ -4376,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"clap",
"log",
@@ -4389,7 +4390,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4402,7 +4403,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"clap",
"clap_complete",
@@ -4420,7 +4421,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"proptest",
@@ -4431,7 +4432,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"craft-docpath",
"log",
@@ -4449,12 +4450,13 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4464,7 +4466,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"log",
"rayon",
@@ -4480,7 +4482,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4493,7 +4495,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"cpal",
"log",
@@ -4508,7 +4510,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"proptest",
"serde",
@@ -4518,7 +4520,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -4548,7 +4550,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"craft-media",
"image",
@@ -4560,7 +4562,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4576,7 +4578,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"eframe",
"egui",
@@ -6224,7 +6226,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.20.0"
dependencies = [
"serde_json",
]
@@ -6456,7 +6458,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6471,18 +6472,6 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+5 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.17.0"
version = "0.20.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
cpal = "0.18"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
egui = { version = "0.37" }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
+2 -2
View File
@@ -134,8 +134,8 @@ where things are.
playlist per take, input monitoring through the full channel strip, and autosave with recovery.
- **Time and tempo:** Bars|Beats, Min:Secs, Timecode (every rate, drop-frame included),
Feet+Frames and Samples; tempo and meter maps with a tempo editor; linear, parabolic and
S-curve tempo ramps; key signatures and detected chords; Beat Detective and Identify Beat;
pitch-preserving Elastic warping.
S-curve tempo ramps; key signatures and detected chords; Beat Analysis and Identify Beat;
pitch-preserving Elastic Audio warping.
- **Audio files:** WAV/BWF/RF64, AIFF/AIFC and FLAC in and out; MP3, Ogg Vorbis, AAC, ALAC, CAF
and the audio of MP4/MOV movies in. Any sample rate and bit depth.
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
+3 -1
View File
@@ -108,9 +108,11 @@ impl eframe::App for App {
logger.report_audio_thread();
}
// The system Quit (⌘Q) ends the process from inside the event loop, so `run_native` never
// returns: release the player's plugin instances and shut plugin hosting down here.
// returns: release the player's plugin instances and shut plugin hosting down here, and
// start the helper of an update that waits for the app to close.
self.0.player = None;
soundcraft_engine::shutdown_plugin_hosts();
self.0.start_pending_update();
}
}
+55 -6
View File
@@ -4,6 +4,12 @@
use crate::pcm::{be_u32, be_u64};
use crate::{AudioError, Result};
/// The most `stts` entries a sound track may have. Symphonia finds each packet's timestamp by
/// scanning the table from its start, so a table of one-sample runs makes decoding quadratic in
/// the packet count. Audio encoders write one run per packet length (a handful of entries);
/// this bound keeps the worst case at a few hundred million steps.
const MAX_AUDIO_STTS_ENTRIES: u32 = 1 << 14;
#[derive(Clone, Copy)]
enum Level {
Top,
@@ -11,7 +17,11 @@ enum Level {
Trak,
Mdia,
Minf,
Stbl,
/// `audio`: the sample entry is an audio format, so symphonia will decode its packets (whatever the
/// handler says: it builds codec parameters from `stsd` alone).
Stbl {
audio: bool,
},
Udta,
Meta,
Ilst,
@@ -55,13 +65,20 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
(Level::Moov, b"udta") => atoms(b, body, inner, Level::Udta)?,
(Level::Trak, b"mdia") => atoms(b, body, inner, Level::Mdia)?,
(Level::Mdia, b"minf") => atoms(b, body, inner, Level::Minf)?,
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl)?,
(Level::Minf, b"stbl") => atoms(b, body, inner, Level::Stbl { audio: entry_is_audio(b, body, inner) })?,
(Level::Meta, b"ilst") => atoms(b, body, inner, Level::Ilst)?,
(Level::Ilst, _) => atoms(b, body, inner, Level::Tag)?,
(Level::Stbl, b"stts" | b"co64") => table(b, body + 4, 8, inner)?,
(Level::Stbl, b"stsc") => table(b, body + 4, 12, inner)?,
(Level::Stbl, b"stco") => table(b, body + 4, 4, inner)?,
(Level::Stbl, b"stsz") => sample_sizes(b, body, inner)?,
(Level::Stbl { audio }, b"stts") => {
let fits = table(b, body + 4, 8, inner)?;
if audio && be_u32(b, body + 4).is_some_and(|n| n > MAX_AUDIO_STTS_ENTRIES) {
return Err(AudioError::Malformed("MP4 audio timing table has more runs than packets could need".into()));
}
fits
}
(Level::Stbl { .. }, b"co64") => table(b, body + 4, 8, inner)?,
(Level::Stbl { .. }, b"stsc") => table(b, body + 4, 12, inner)?,
(Level::Stbl { .. }, b"stco") => table(b, body + 4, 4, inner)?,
(Level::Stbl { .. }, b"stsz") => sample_sizes(b, body, inner)?,
(Level::Mdia, b"hdlr") | (Level::Tag, b"data" | b"name" | b"mean") if len > (b.len() - pos) as u64 => {
return Err(malformed(&format!("`{}` atom", String::from_utf8_lossy(kind))));
}
@@ -75,6 +92,38 @@ fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
Ok(true)
}
/// The sample entry formats symphonia reads as audio (the list `stsd` is matched against in
/// symphonia-format-isomp4 0.6); every other format, video and subtitles included, has no audio packets.
const AUDIO_FORMATS: [&[u8; 4]; 18] = [
b"mp4a", b"alac", b"ac-3", b"ec-3", b"fLaC", b"Opus", b".mp3", b"lpcm", b"wave", b"alaw", b"ulaw", b"raw ", b"sowt", b"twos", b"in24", b"in32",
b"fl32", b"fl64",
];
/// Whether the `stbl` atom whose children lie in `pos..end` describes audio: its `stsd` may come before
/// or after the timing tables, so the children are walked once more for it. Symphonia keeps the last
/// `stsd` it reads, so the last one decides.
fn entry_is_audio(b: &[u8], mut pos: usize, end: usize) -> bool {
let mut audio = false;
while pos < end {
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return audio };
let head = if size == 1 { 16 } else { 8 };
if kind == b"stsd" {
// Version and flags, the entry count, then the first entry's size and format.
audio = b.get(pos + head + 12..pos + head + 16).is_some_and(|f| AUDIO_FORMATS.iter().any(|a| a.as_slice() == f));
}
let len = match size {
0 => return audio,
1 => be_u64(b, pos + 8).unwrap_or(0),
n => u64::from(n),
};
match usize::try_from(len).ok().filter(|&n| n >= head).and_then(|n| pos.checked_add(n)) {
Some(next) => pos = next,
None => return audio,
}
}
audio
}
/// A sample table's entries, counted at `count`, must fit in the file: symphonia reserves room for
/// all of them first. False if they run past the table's atom (`end`), where symphonia stops.
fn table(b: &[u8], count: usize, entry: u64, end: usize) -> Result<bool> {
+15 -1
View File
@@ -22,6 +22,11 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// The most samples (frames × channels) a packet may declare: the PCM decoder allocates that
/// many for its output up front, so a CAF with thousands of channels and a long packet could
/// otherwise claim gigabytes before a sample is decoded (64 MiB of f32).
const MAX_PACKET_SAMPLES: u64 = 1 << 24;
/// How far [`first_marker`] looks for the first format marker: the same 1 MiB a default symphonia
/// probe scans.
const MAX_PREFIX_SCAN: usize = 1 << 20;
@@ -508,7 +513,9 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
}
let params = audio_params(track).cloned().ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
let ch = u64::from(info.channels);
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES || n.saturating_mul(ch) > MAX_PACKET_SAMPLES)
{
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
}
let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &decoder_options()).map_err(map_err)?;
@@ -531,6 +538,13 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if packet.track_id != track_id {
continue;
}
// The declared frames per packet are not the packet's size: a container supplies that on its
// own, and the PCM decoder sizes its output by the bytes it is given.
if let Some(bytes_per_sample) = params.bits_per_coded_sample.map(|b| u64::from(b / 8)).filter(|&n| n > 0)
&& packet.data.len() as u64 / bytes_per_sample > MAX_PACKET_SAMPLES
{
return Err(AudioError::Malformed("a packet holds far more samples than any codec uses".into()));
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::DecodeError(m)) => {
+58 -1
View File
@@ -321,7 +321,7 @@ fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| i.wrapping_mul(40).to_le_bytes()).collect();
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
}
@@ -373,6 +373,63 @@ fn packets_longer_than_any_codec_uses_are_refused() {
for entry in [lpcm(1 << 21), alac(1 << 21)] {
assert!(matches!(decode(&mp4(480, &entry, |_, a| a), None), Err(AudioError::Malformed(m)) if m.contains("declares packets")));
}
// Frames and channels multiply into the decoder's packet buffer; an ordinary stereo stream
// with long packets stays well inside the bound.
let wide = |channels: u32, frames: u32| {
let fields = [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[channels, 0x7f00_0000, 16, 4, 2 * channels, frames])].concat();
sample_entry(b"lpcm", 2, &fields, &[])
};
assert!(decode(&mp4(480, &wide(2, 1 << 13), |_, a| a), None).is_ok());
}
#[test]
fn an_audio_timing_table_with_a_run_per_sample_is_bounded() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
// One stts run per sample: symphonia scans the runs from the start for every packet.
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
let frames = 1 << 14;
let (_, buf) = decode(&mp4(frames, &pcm, |k, a| if k == b"stts" { runs(frames) } else { a }), None).unwrap();
assert_eq!(buf.frames(), frames as usize, "a table at the bound still decodes");
let f = mp4(frames + 1, &pcm, |k, a| if k == b"stts" { runs(frames + 1) } else { a });
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "one run past the bound is refused");
}
#[test]
fn the_audio_timing_bound_follows_the_sample_entry_not_the_handler() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
// an audio sample entry under a video handler is still decoded as audio by symphonia
let video_handler = atom(b"hdlr", &[0u32, 0, u32::from_be_bytes(*b"vide"), 0, 0, 0, 0].map(u32::to_be_bytes).concat());
let frames = (1 << 14) + 1;
let f = mp4(frames, &pcm, |k, a| match k {
b"stts" => runs(frames),
b"hdlr" => video_handler.clone(),
_ => a,
});
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the handler does not exempt an audio entry");
}
#[test]
fn the_last_sample_entry_decides_whether_the_timing_bound_applies() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let video = sample_entry(b"avc1", 0, &[], &[]);
let runs = |n: u32| atom(b"stts", &[&[0; 4][..], &n.to_be_bytes(), &[1u32, 1].map(u32::to_be_bytes).concat().repeat(n as usize)[..]].concat());
let audio_stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &pcm].concat());
let frames = (1 << 14) + 1;
// A video `stsd` first and an audio one after it: symphonia keeps the last, so it decodes audio.
let f = mp4(frames, &video, |k, a| if k == b"stts" { [audio_stsd.clone(), runs(frames)].concat() } else { a });
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("timing table")), "the later audio entry counts");
}
#[test]
fn a_pcm_packet_longer_than_its_declared_frames_is_refused_before_decoding() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
// one `stsz` sample of 2 * 2^24 + 2 bytes (just over the packet bound) in a file that holds it
let big = 2 * (1usize << 24) + 2;
let stsz = atom(b"stsz", &[0u32, big as u32, 1].map(u32::to_be_bytes).concat());
let mut f = mp4(1, &pcm, |k, a| if k == b"stsz" { stsz.clone() } else { a });
f.resize(f.len() + big, 0);
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("packet holds far more samples")));
}
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
@@ -310,6 +310,36 @@ fn links_out_of_the_roots_are_refused() {
ok(&mut via_alias, "file.import_audio", json!({"path": s(f.read.join("tone.wav"))}));
}
#[cfg(any(unix, windows))]
#[test]
fn exports_refuse_hard_links_without_changing_either_name() {
let f = fixture("hard-links");
let midi = f.write.join("linked.mid");
let preset = f.write.join("linked.json");
std::fs::hard_link(f.outside.join("secret.mid"), &midi).unwrap();
std::fs::hard_link(f.outside.join("secret.json"), &preset).unwrap();
let mut srv = demo_server(Some(&f.read), Some(&f.write));
let before = listing(&f.write);
// Refusal is the safe alternative allowed by #1359: neither the outside inode nor its
// in-root alias may be truncated, and no replacement or temporary file may be left behind.
refused(&mut srv, "file.export_midi", json!({"path": s(&midi)}), "hard-linked");
refused(&mut srv, "track.save_preset", json!({"path": s(&preset), "tracks": ["Kick"]}), "hard-linked");
for path in [&midi, &preset, &f.outside.join("secret.mid"), &f.outside.join("secret.json")] {
assert_eq!(std::fs::read(path).unwrap(), SECRET.as_bytes());
}
assert_eq!(listing(&f.write), before);
// The error offers a new export path; both commands must still work at ordinary paths.
let fresh_midi = f.write.join("fresh.mid");
ok(&mut srv, "file.export_midi", json!({"path": s(&fresh_midi)}));
assert!(std::fs::read(fresh_midi).unwrap().starts_with(b"MThd"));
let fresh_preset = f.write.join("fresh.json");
ok(&mut srv, "track.save_preset", json!({"path": s(&fresh_preset), "tracks": ["Kick"]}));
let preset_json: Value = serde_json::from_slice(&std::fs::read(fresh_preset).unwrap()).unwrap();
assert_eq!(preset_json["name"], "Kick");
}
/// A session file is hostile input too: media it names outside the read root is not loaded,
/// and the refusal is listed with the other missing media.
#[test]
+209 -16
View File
@@ -10,6 +10,10 @@ use std::sync::atomic::Ordering;
const MAX_BLOCK: usize = 1 << 16;
/// Most channels a host strip may ask for.
const MAX_CHANNELS: usize = 64;
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
/// a reset (#1464).
const RELEASE_RESERVE: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq)]
enum NoteMode {
@@ -79,14 +83,104 @@ impl ClapPlugin {
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
/// Queues an event other than a note release; see [`push_event`].
fn push(&mut self, e: Event) {
if self.queue.len() >= self.queue.capacity() {
return;
push_event(&mut self.queue, e, false);
}
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
/// Returns whether the event was queued.
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
if queue.len() >= room {
return false;
}
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
queue.insert(pos, e);
true
}
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
/// later reset still releases it (#1464).
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
if let Some(e) = release_event(notes, time, key)
&& push_event(queue, e, true)
&& let Some(h) = held.get_mut(usize::from(key))
{
*h = false;
}
}
/// The release of `key` in the note port's dialect (`None` without a note port).
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
match notes {
NoteMode::Clap => Some(Event::NoteOff { time, key }),
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
NoteMode::None => None,
}
}
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
///
/// It never runs out of room: events other than `note_off` releases fill at most
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
keep_releases(queue);
for e in queue.iter() {
let key = match *e {
Event::NoteOff { key, .. } => Some(key),
// Channel 1, where `note_off` sends them.
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
_ => None,
};
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
*h = false;
}
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
self.queue.insert(pos, e);
}
for key in 0..128u8 {
if held.get(usize::from(key)).copied().unwrap_or(false)
&& let Some(e) = release_event(notes, 0, key)
{
let _ = push_event(queue, e, true);
}
}
*held = [false; 128];
}
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
/// for `all_notes_off`.
fn keep_releases(queue: &mut Vec<Event>) {
let mut clap_off = [false; 128];
let mut midi_off = [[false; 128]; 16];
queue.retain(|e| match *e {
Event::Param { .. } => true,
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
}
_ => false,
});
for e in queue.iter_mut() {
e.set_time(0);
}
}
/// Marks a key's release as seen; true only the first time (false for a key out of range).
fn first_release(seen: Option<&mut bool>) -> bool {
seen.is_some_and(|s| !std::mem::replace(s, true))
}
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
match status & 0xF0 {
0x80 => true,
0x90 => velocity == 0,
_ => false,
}
}
@@ -185,8 +279,10 @@ impl Plugin for ClapPlugin {
fn reset(&mut self) {
self.inst.reset();
self.queue.retain(|e| matches!(e, Event::Param { .. }));
self.held = [false; 128];
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
// left to release them (#1464).
release_on_reset(&mut self.queue, &mut self.held, self.notes);
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
@@ -287,14 +383,7 @@ impl Plugin for ClapPlugin {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
match self.notes {
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
NoteMode::None => return,
}
if let Some(h) = self.held.get_mut(note as usize) {
*h = false;
}
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
@@ -383,3 +472,107 @@ impl PluginEditor for ClapEditor {
self.link.host().dirty.swap(false, Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A reset keeps parameter changes and one release per key, at the start of the next block,
/// and drops note-ons and other MIDI.
#[test]
fn reset_keeps_params_and_one_release_per_key() {
let mut q = vec![
Event::Param { time: 0, id: 4, value: 0.5 },
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
Event::NoteOff { time: 5, key: 60 },
Event::NoteOff { time: 9, key: 60 },
Event::NoteOff { time: 12, key: 64 },
Event::Midi { time: 20, data: [0x90, 62, 90] },
Event::Midi { time: 21, data: [0xB0, 64, 127] },
Event::Midi { time: 22, data: [0x80, 62, 0] },
Event::Midi { time: 23, data: [0x80, 62, 40] },
Event::Midi { time: 24, data: [0x83, 62, 0] },
Event::Midi { time: 25, data: [0x91, 67, 0] },
Event::Midi { time: 26, data: [0x80, 200, 0] },
];
keep_releases(&mut q);
assert_eq!(
q,
vec![
Event::Param { time: 0, id: 4, value: 0.5 },
Event::NoteOff { time: 0, key: 60 },
Event::NoteOff { time: 0, key: 64 },
Event::Midi { time: 0, data: [0x80, 62, 0] },
Event::Midi { time: 0, data: [0x83, 62, 0] },
Event::Midi { time: 0, data: [0x91, 67, 0] },
]
);
}
/// A queue that parameter changes filled still releases every held key on a reset, once
/// each, and keeps the parameter changes.
#[test]
fn a_full_queue_still_releases_every_held_key_on_reset() {
for notes in [NoteMode::Clap, NoteMode::Midi] {
let mut q = Vec::with_capacity(EVENT_CAP);
for id in 0..EVENT_CAP as u32 {
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
}
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
// Keys 0..4 already have a release queued, and are held again after it.
for key in 0..4u8 {
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
}
let mut held = [true; 128];
release_on_reset(&mut q, &mut held, notes);
assert_eq!(held, [false; 128]);
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
for key in 0..128u8 {
let want = release_event(notes, 0, key).unwrap();
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
}
assert_eq!(q.len(), EVENT_CAP);
}
}
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
/// then `reset` does on Stop.
#[test]
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
for notes in [NoteMode::Clap, NoteMode::Midi] {
let mut q = Vec::with_capacity(EVENT_CAP);
let mut held = [false; 128];
for id in 0..EVENT_CAP as u32 {
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
}
// Key 0 pressed and released over and over fills the reserve with its releases.
for t in 0..RELEASE_RESERVE as u32 {
held[0] = true;
queue_release(&mut q, &mut held, notes, t, 0);
}
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
held[5] = true;
queue_release(&mut q, &mut held, notes, 0, 5);
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
release_on_reset(&mut q, &mut held, notes);
for key in [0u8, 5] {
let want = release_event(notes, 0, key).unwrap();
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
}
assert_eq!(held, [false; 128]);
}
}
/// A queue full of releases still has room to release every held key after a reset.
#[test]
fn reset_keeps_room_to_release_every_held_key() {
let mut q = Vec::with_capacity(EVENT_CAP);
for i in 0..EVENT_CAP {
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
}
keep_releases(&mut q);
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
assert_eq!(q, want, "one release per key remains");
}
}
+4 -4
View File
@@ -4,7 +4,7 @@
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
//! MIDI CC 7 (volume) sets the constant to its value / 127.
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
//!
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
@@ -166,9 +166,9 @@ unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
unsafe { fx(p) }.processing = false;
}
unsafe extern "C" fn reset(p: *const clap_plugin) {
unsafe { fx(p) }.level = 0.0;
}
/// Leaves the synth's note sounding. CLAP asks `reset` to kill voices, but some synths' doesn't
/// (Surge XT's), so the host must send the releases itself.
unsafe extern "C" fn reset(_p: *const clap_plugin) {}
fn apply(f: &mut Fx, h: &clap_event_header) {
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
+42
View File
@@ -109,6 +109,48 @@ fn synth_plays_notes_at_their_offsets() {
assert!(io[0][19].abs() < 1e-6 && (io[0][20] - 64.0 / 127.0).abs() < 1e-6, "controllers arrive as MIDI at their offset");
}
/// Renders one 64-frame stereo block of `p` and returns its left channel.
fn render_block(p: &mut dyn soundcraft_dsp::Plugin) -> Vec<f32> {
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
p.process(&mut io, 64);
io.swap_remove(0)
}
/// Stop, Play and Locate run `all_notes_off` then `reset`: the releases reach a synth whose own
/// reset leaves its voices sounding, and a later block is silent (#1464).
#[test]
fn reset_delivers_the_releases_of_held_notes() {
fixture_dir();
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
p.prepare(48_000.0, 64, 2);
let silent = |b: &[f32]| b.iter().all(|x| x.abs() < 1e-6);
// As the mix engine does on Stop: release the held note, then reset.
p.note_on(0, 60, 127);
assert!(render_block(p.as_mut()).iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note sounds");
p.all_notes_off();
p.reset();
assert!(silent(&render_block(p.as_mut())), "all_notes_off's release was dropped by reset");
// A reset alone releases what is still held.
p.note_on(0, 61, 127);
render_block(p.as_mut());
p.reset();
assert!(silent(&render_block(p.as_mut())), "reset did not release the held note");
// A release queued for a block that never ran is still delivered.
p.note_on(0, 62, 127);
render_block(p.as_mut());
p.note_off(30, 62);
p.reset();
assert!(silent(&render_block(p.as_mut())), "the pending release was dropped by reset");
// A note-on queued for the position being left is dropped, not played after the reset.
p.note_on(10, 63, 127);
p.reset();
assert!(silent(&render_block(p.as_mut())), "a note-on from before the reset sounded");
}
#[test]
fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
fixture_dir();
+3 -3
View File
@@ -1,4 +1,4 @@
//! Harmonic effects: Saturator (tanh), Lo-Fi (bit/sample-rate reduction), Rectifier and
//! Harmonic effects: Saturator (tanh), Bit Crusher (bit/sample-rate reduction), Rectifier and
//! Subharmonic (octave-down bass synthesis).
use crate::biquad::{Biquad, FilterType};
@@ -134,8 +134,8 @@ static LOFI_PARAMS: [ParamInfo; 4] = [
pub static LOFI_INFO: PluginInfo = PluginInfo {
id: "lofi",
name: "Lo-Fi",
short_name: "LoFi",
name: "Bit Crusher",
short_name: "Crush",
category: Category::Harmonic,
params: &LOFI_PARAMS,
is_instrument: false,
+3
View File
@@ -25,6 +25,9 @@ log.workspace = true
zip.workspace = true
craft-docpath.workspace = true
[target.'cfg(windows)'.dependencies]
winsafe = { version = "0.0.29", features = ["kernel"] }
[dev-dependencies]
proptest.workspace = true
+45
View File
@@ -454,6 +454,32 @@ pub fn create_folder(dir: &Path) -> std::io::Result<()> {
builder.create(dir)
}
/// A new folder directly in `parent`, named `{prefix}-` and 32 random hex digits. It is made
/// exclusively (it is created by this call or the call fails), so a folder or link another account
/// put there first is never used, and the name cannot be guessed ahead. On Unix it is owner-only
/// (`0o700`); on other systems it has the mode `parent` gives it.
pub fn create_unique_folder(parent: &Path, prefix: &str) -> std::io::Result<PathBuf> {
use std::hash::{BuildHasher, Hasher};
// One plain name: a prefix like `../x` would make a sibling of `parent`, not a child.
let mut parts = Path::new(prefix).components();
if !matches!((parts.next(), parts.next()), (Some(std::path::Component::Normal(_)), None)) || prefix.contains(['/', '\\']) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidInput, "the folder prefix must be a single name"));
}
for _ in 0..16 {
let random = |_| std::collections::hash_map::RandomState::new().build_hasher().finish();
let dir = parent.join(format!("{prefix}-{:016x}{:016x}", random(0), random(1)));
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => return Ok(dir),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(err),
}
}
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "no unused folder name"))
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
@@ -476,6 +502,25 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
mod tests {
use super::*;
use crate::io::open_session;
#[test]
fn unique_folders_are_new_and_owner_only() {
let parent = std::env::temp_dir().join(format!("soundcraft-unique-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&parent);
std::fs::create_dir_all(&parent).unwrap();
let (a, b) = (create_unique_folder(&parent, "T").unwrap(), create_unique_folder(&parent, "T").unwrap());
assert_ne!(a, b);
assert!(a.is_dir() && a.parent() == Some(parent.as_path()));
assert_eq!(a.file_name().unwrap().len(), "T-".len() + 32);
#[cfg(unix)]
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&a).unwrap().permissions()) & 0o777, 0o700);
assert!(create_unique_folder(&parent.join("missing"), "T").is_err(), "no parents are made");
for bad in ["../outside", "a/b", "a\\b", "..", ".", "", "/abs"] {
assert!(create_unique_folder(&parent, bad).is_err(), "{bad:?} is not a single name");
}
let _ = std::fs::remove_dir_all(&parent);
}
use serde_json::json;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
+75 -2
View File
@@ -65,13 +65,50 @@ pub const ALIASES: &[(&str, &str)] = &[
("View > Rulers > Chord Symbols", "view.ruler"),
];
// Name migration fallbacks run after explicit UI mappings so opening a dialog
// keeps its original command and enablement rather than becoming a direct edit.
const LEGACY_LABEL_ALIASES: &[(&str, &str)] = &[
// Historical catalog paths remain valid when their display names change.
("Clip > Elastic Properties", "clip.elastic_properties"),
("Options > QuickPunch", "options.quickpunch"),
("Options > TrackPunch", "options.trackpunch"),
("View > Transport > Ableton Link", "view.transport_ableton_link"),
("Event > Beat Detective", "event.beat_detective"),
("File > Export > Sibelius...", "file.export_sibelius"),
("File > Send To... > Sibelius", "file.send_to_sibelius"),
];
/// Catalog leaves naming features we do not have. They keep the incumbent's place — and with it
/// the parity floor — under a descriptive label, but they are never interactive: the UI draws them
/// disabled with a "Not available yet." hover until the behaviour exists, as Preferences does for
/// settings without a backend. Naming the place does not claim the protocol, plug-in format or
/// processor behind it.
pub const PLACEHOLDERS: &[&str] = &[
"View > Transport > Ableton Link",
"Track > ARA Plugins > Melodyne",
"Clip > ARA Plugins > Melodyne",
"View > Mix Window Views > HEAT",
"Options > Activate HEAT",
];
/// Whether `path` is a catalog leaf kept as a placeholder for a feature we do not have.
pub fn is_placeholder(path: &str) -> bool {
PLACEHOLDERS.contains(&path)
}
pub fn catalog() -> Vec<&'static str> {
MENUS.lines().map(str::trim).filter(|l| !l.is_empty()).collect()
}
/// Command id implementing a catalog entry, if any.
pub fn implemented_by(entry: &str, extra: &[(&str, &str)]) -> Option<String> {
if let Some((_, id)) = ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(extra.iter()).find(|(p, _)| *p == entry) {
if let Some((_, id)) = ALIASES
.iter()
.chain(crate::catalog_more::ALIASES_MORE.iter())
.chain(extra.iter())
.chain(LEGACY_LABEL_ALIASES.iter())
.find(|(p, _)| *p == entry)
{
return Some((*id).to_string());
}
let parts: Vec<&str> = entry.split(" > ").collect();
@@ -116,7 +153,7 @@ mod tests {
#[test]
fn aliases_point_at_real_commands() {
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()) {
for (path, id) in ALIASES.iter().chain(crate::catalog_more::ALIASES_MORE.iter()).chain(LEGACY_LABEL_ALIASES.iter()) {
assert!(crate::find_command(id).is_some(), "{path} → unknown {id}");
assert!(catalog().contains(path), "alias for unknown catalog entry {path}");
}
@@ -131,4 +168,40 @@ mod tests {
assert!(pct >= 25.0, "engine parity regressed: {pct}%");
assert!(done >= 396, "engine parity regressed: {done} menu items");
}
/// A placeholder must keep its catalog place (so the floor holds and the menu still lists it)
/// and must not read as the incumbent's own name: the label says what it is, not who makes it.
#[test]
fn placeholders_keep_their_place_with_honest_labels() {
let entries = catalog();
for path in PLACEHOLDERS {
assert!(entries.contains(path), "placeholder for an unknown catalog entry: {path}");
assert!(is_placeholder(path), "{path}");
let leaf = path.rsplit_once(" > ").map_or(*path, |(_, leaf)| leaf);
let label = crate::feature_names::display_label(leaf);
assert_ne!(label, leaf, "{path} still shows the incumbent's name");
for retired in ["ableton", "melodyne", "heat"] {
assert!(!label.to_lowercase().contains(retired), "{path}: {label}");
}
}
assert!(!is_placeholder("Options > Loop Record"), "an implemented item is not a placeholder");
assert!(!is_placeholder("View > Transport > MIDI Controls"));
}
#[test]
fn renamed_labels_keep_legacy_catalog_dispatch() {
let ui = [("Event > Beat Detective", "window.beat_detective")];
assert_eq!(implemented_by("Event > Beat Detective", &ui).as_deref(), Some("window.beat_detective"));
for (path, id, label) in [
("Options > QuickPunch", "options.quickpunch", "Punch Recording"),
("Options > TrackPunch", "options.trackpunch", "TrackPunch"),
("View > Transport > Ableton Link", "view.transport_ableton_link", "Tempo Sync"),
("Event > Beat Detective", "event.beat_detective", "Beat Analysis"),
("File > Export > Sibelius...", "file.export_sibelius", "MusicXML..."),
("File > Send To... > Sibelius", "file.send_to_sibelius", "MusicXML"),
] {
assert_eq!(implemented_by(path, &[]).as_deref(), Some(id));
assert_eq!(crate::find_command(id).unwrap().label, label);
}
}
}
+61 -4
View File
@@ -87,23 +87,30 @@ fn decode_group_audio(
cmd_id: &str,
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
// The folder as the file system knows it, so a link among the group's files can't lead out.
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let root = crate::io::canonical_path(dir)?;
for (old, rel) in audio_entries {
let full = dir.join(rel);
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
if !full.starts_with(dir)
|| Path::new(rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
{
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
return Err(escapes(rel));
}
let key = crate::io::canonical_path(&full)?;
if !key.starts_with(&root) {
return Err(escapes(rel));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
Some(n) => *n,
None => {
let bytes = crate::io::read_file(&full)?;
let bytes = crate::io::read_file_inside(&full, &root)?;
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
let buf = crate::io::at_rate(buf, rate, crate::io::MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{rel}: too large to import")))?;
.map_err(|r| EngineError::Io(format!("{rel}: {}", r.message("too large to import"))))?;
decoded.push((rel.clone(), buf));
index.insert(key, decoded.len() - 1);
decoded.len() - 1
@@ -268,9 +275,59 @@ mod tests {
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
// A link inside the folder that leads out of it is refused once resolved.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("link.wav");
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "links out of the folder are refused: {r:?}");
let _ = std::fs::remove_file(&outside);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
#[cfg(unix)]
#[test]
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (dir, outside) = (base.join("group"), base.join("outside"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
let mut e = crate::demo::demo_engine();
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
let mut refusals = Vec::new();
for target in ["out/real.wav", "out/missing.wav"] {
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(target);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = crate::file_access::confine(Some(&roots), || {
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
});
refusals.push(r.unwrap_err().to_string());
}
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn imported_clips_carry_no_auto_rename_scheme() {
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
+1 -1
View File
@@ -165,7 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
always,
shuffle_lock
),
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
cmd!(noundo "edit.tool", "Edit Tool", [], Some("F5-F10, F6+F7, F7+F8, Cmd+6-7"), "{tool: zoom|trim|selector|grabber|scrubber|pencil|smart}", always, |e, p| {
let t = str_param(p, "tool").and_then(soundcraft_model::Tool::from_id).ok_or_else(|| bad("edit.tool", "unknown tool"))?;
e.session_mut().edit.tool = t;
Ok(json!({"tool": t}))
+3
View File
@@ -154,6 +154,7 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::insert_silence(s, t, r);
if let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
@@ -185,6 +186,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::separate_all_at(s, t, r.end);
crate::edit::clear_range(s, t, r, false);
crate::edit::shift_from(s, t, r.end, -r.len());
if let Some(tr) = s.track_mut(t) {
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"event.beat_detective",
"Beat Detective",
"Beat Analysis",
["Event"],
None,
"{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}",
+10 -5
View File
@@ -94,9 +94,14 @@ pub fn specs() -> Vec<CommandSpec> {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let format = str_param(p, "format")
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
let format_name = match p.get("format") {
None => "wav",
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
};
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
})?;
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
@@ -144,10 +149,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_session(&mut tmp, &path)?;
Ok(json!({"path": path}))
}),
cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
cmd!(noundo "file.export_sibelius", "MusicXML...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
write_score(e, p, "file.export_sibelius", false)
}),
cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
cmd!(noundo "file.send_to_sibelius", "MusicXML", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
write_score(e, p, "file.send_to_sibelius", false)
}),
cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| {
+65
View File
@@ -118,6 +118,71 @@ fn import_audio_honours_new_tracks() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_stems_rejects_unsupported_formats_before_writing() {
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let stems = dir.join("stems");
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
let created_output = stems.exists();
// Own this directory exclusively; remove any baseline output before the assertion can fail.
std::fs::remove_dir_all(&dir).unwrap();
let err = result.map(|_| ()).unwrap_err();
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
assert!(err.to_string().contains("supported: wav"), "{err}");
assert!(!created_output, "an unsupported format must not create the output directory");
}
#[test]
fn bounce_stems_rejects_non_string_formats_before_writing() {
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
.into_iter()
.enumerate()
.map(|(index, format)| {
let stems = dir.join(index.to_string());
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
(result, stems.exists())
})
.collect();
std::fs::remove_dir_all(&dir).unwrap();
for (result, created_output) in results {
let err = result.map(|_| ()).unwrap_err();
assert!(err.to_string().contains("`format` must be a string"), "{err}");
assert!(!created_output, "a non-string format must not create the output directory");
}
}
#[test]
fn bounce_stems_accepts_explicit_wav_format() {
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let mut e = Engine::default();
let wav = tone_wav(&dir, "tone.wav");
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stems = dir.join("stems");
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 1);
let path = std::path::Path::new(files[0].as_str().unwrap());
assert_eq!(path.parent(), Some(stems.as_path()));
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
let (info, audio) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
assert_eq!(info.format, soundcraft_audio_io::FileFormat::Wav);
assert_eq!(audio.frames(), 32);
assert_eq!(audio.num_channels(), e.session().main_format().channels());
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
+1 -1
View File
@@ -19,7 +19,7 @@ macro_rules! toggle {
pub fn specs() -> Vec<CommandSpec> {
vec![
toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record),
toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch),
toggle!("options.quickpunch", "Punch Recording", ["Options"], Some("Cmd+Shift+P"), quickpunch),
toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll),
toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback),
toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit),
+1 -1
View File
@@ -75,7 +75,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(EngineError::Io(format!("{name}: no audio")));
}
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, name, buf, path, format.format);
+1 -1
View File
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
flag!("view.transport_counters", "Counters", ["View", "Transport"], "view.transport.counters"),
flag!("view.transport_midi_controls", "MIDI Controls", ["View", "Transport"], "view.transport.midi_controls"),
flag!("view.transport_synchronization", "Synchronization", ["View", "Transport"], "view.transport.synchronization"),
flag!("view.transport_ableton_link", "Ableton Link", ["View", "Transport"], "view.transport.ableton_link"),
flag!("view.transport_ableton_link", "Tempo Sync", ["View", "Transport"], "view.transport.ableton_link"),
flag!("view.transport_output_meters", "Output Meters", ["View", "Transport"], "view.transport.output_meters"),
flag!("view.transport_expanded", "Expanded", ["View", "Transport"], "view.transport.expanded"),
cmd!(query "view.flags", "View Flags", [], None, "{prefix?} — lists the view/option flags that are on", always, |e, p| {
+65 -4
View File
@@ -78,9 +78,9 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
}
}
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
/// scheme when it has one.
/// Split every clip on a track's active playlist that spans `at`, except time- or edit-locked ones.
/// The piece before `at` keeps the clip's name. The piece after it is a new clip, and takes the next
/// name of the clip's Auto Rename scheme when it has one.
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
separate_at_if(s, track, at, |_| true)
}
@@ -88,11 +88,21 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
split_at(s, track, at, auto_name, true)
}
/// [`separate_at`], splitting time- and edit-locked clips too. For the Event time operations, which
/// move the whole timeline, locked clips included.
pub fn separate_all_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
split_at(s, track, at, |_| true, false)
}
fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool, skip_locked: bool) -> usize {
let tc_tempo = s.tempo.clone();
let sr = s.sample_rate;
let t = TimeCtx { tempo: &tc_tempo, sr };
let mut ids = Vec::new();
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end() && !(skip_locked && locked(c))).count());
for _ in 0..n_split {
ids.push(s.new_clip_id());
}
@@ -106,6 +116,7 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
for c in pl.clips.drain(..) {
if at > c.start
&& at < c.end()
&& !(skip_locked && locked(&c))
&& let Some(id) = ids.pop()
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
{
@@ -125,6 +136,45 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
count
}
/// Split every clip on a track's active playlist at each of `points`, in one pass over the
/// playlist: [`separate_at_if`] once per point with `|_| false` (no piece takes an Auto Rename
/// name here), without rebuilding the playlist per point. Returns the number of splits.
/// `points` are sorted and deduplicated first, so the source offsets of stretched audio can differ
/// from per-point calls given in another order (each trim rounds its own adjustment).
pub fn separate_at_points(s: &mut Session, track: TrackId, points: &[Samples]) -> usize {
let mut pts = points.to_vec();
pts.sort_unstable();
pts.dedup();
let tc_tempo = s.tempo.clone();
let t = TimeCtx { tempo: &tc_tempo, sr: s.sample_rate };
// The points strictly inside a clip.
let within = |c: &Clip| {
let (lo, hi) = (pts.partition_point(|p| *p <= c.start), pts.partition_point(|p| *p < c.end()));
lo..hi.max(lo)
};
let n_split: usize = s.track(track).map_or(0, |tr| tr.clips().iter().map(|c| within(c).len()).sum());
let mut ids: Vec<ClipId> = (0..n_split).map(|_| s.new_clip_id()).collect();
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut out = Vec::with_capacity(pl.clips.len() + n_split);
for c in pl.clips.drain(..) {
let mut rest = c;
for p in &pts[within(&rest)] {
let Some(id) = ids.pop() else { break };
match split_clip(&rest, *p, id, t) {
Some((l, r)) => {
out.push(l);
rest = r;
}
None => break,
}
}
out.push(rest);
}
pl.clips = out;
pl.sort();
n_split
}
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
@@ -427,6 +477,10 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
// Locked clips keep their extent, like they don't move for nudge.
if locked(c) {
continue;
}
if let Some(i) = c.range().intersect(&range)
&& i != c.range()
{
@@ -448,6 +502,9 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
if locked(c) {
continue;
}
if c.range().contains(at) && at > c.start {
let old = c.start;
c.trim_start_to(at);
@@ -458,10 +515,14 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
n
}
/// Trim the end of clips after `at` to `at` (Trim End to Insertion).
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
if locked(c) {
continue;
}
if at > c.start && at < c.end() {
c.trim_end_to(at);
n += 1;
+56
View File
@@ -0,0 +1,56 @@
//! Display-only terminology. Catalog paths, command ids, processor ids and saved values are stable.
//! Never use the returned label for command dispatch or persistence. Labels for features we do not
//! have describe a placeholder, which the menu draws disabled ("Not available yet.") until the
//! behaviour exists: `catalog::PLACEHOLDERS` lists them.
//!
//! Names still being settled (RFC-0029) are not mapped here: the incumbent's label stays until the
//! RFC decides, so AudioSuite, Elastic Audio, Elastic Properties and TrackPunch pass through.
pub fn display_label(label: &str) -> &str {
match label {
"Beat Detective" => "Beat Analysis",
"x-form" => "Rendered (High Quality)",
"QuickPunch" => "Punch Recording",
"Ableton Link" => "Tempo Sync",
"Pro Tools Search" => "Search",
"Pro Tools Projects" => "Shared Projects",
"Melodyne" => "No ARA plug-ins",
"HEAT" => "Analog Saturation",
"Activate HEAT" => "Activate Analog Saturation",
"Sibelius" => "MusicXML",
"Sibelius..." => "MusicXML...",
_ => label,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn feature_labels_are_descriptive_without_changing_unknown_text() {
for (old, new) in [
("Beat Detective", "Beat Analysis"),
("x-form", "Rendered (High Quality)"),
("QuickPunch", "Punch Recording"),
("Ableton Link", "Tempo Sync"),
("Pro Tools Search", "Search"),
("Pro Tools Projects", "Shared Projects"),
("Melodyne", "No ARA plug-ins"),
("HEAT", "Analog Saturation"),
("Activate HEAT", "Activate Analog Saturation"),
("Sibelius...", "MusicXML..."),
] {
assert_eq!(display_label(old), new);
}
assert_eq!(display_label("My session"), "My session");
}
/// Names deferred to RFC-0029 keep the incumbent's label until the RFC settles them.
#[test]
fn deferred_names_pass_through() {
for label in ["AudioSuite", "AudioSuite Process", "Elastic Audio", "Elastic Properties", "Enable Elastic Audio on New Tracks", "TrackPunch"] {
assert_eq!(display_label(label), label);
}
}
}
+532 -21
View File
@@ -65,6 +65,69 @@ fn read_file_within(path: &Path, session: bool) -> Result<Vec<u8>> {
Ok(data)
}
/// `O_NONBLOCK` of the platforms that have a FIFO to wait on (std has no name for it, and this crate has no unsafe
/// code or libc); 0 elsewhere, where a FIFO open still waits for a writer.
#[cfg(unix)]
const O_NONBLOCK: i32 = if cfg!(all(
any(target_os = "linux", target_os = "android"),
not(any(
target_arch = "mips",
target_arch = "mips32r6",
target_arch = "mips64",
target_arch = "mips64r6",
target_arch = "sparc",
target_arch = "sparc64"
))
)) {
0x800
} else if cfg!(any(
target_os = "macos",
target_os = "ios",
target_os = "freebsd",
target_os = "netbsd",
target_os = "openbsd",
target_os = "dragonfly"
)) {
0x4
} else {
0
};
/// [`read_file`] for a file that must lie inside `root` (a canonical folder): the file is opened
/// once, and the bytes come from that handle only after the handle is shown to be the file the
/// path resolves to inside `root`. A file swapped in for the path after a check by name is
/// refused instead of read. Where the handle's identity can't be read (non-Unix targets) the
/// resolved path is checked against `root` alone.
pub(crate) fn read_file_inside(path: &Path, root: &Path) -> Result<Vec<u8>> {
use std::io::Read;
crate::file_access::check_read(path).map_err(EngineError::Io)?;
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
// opened without waiting for a FIFO's writer (the type is checked on the opened handle, below)
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::custom_flags(&mut options, O_NONBLOCK);
let mut file = options.open(path).map_err(err)?;
if !file.metadata().map_err(err)?.is_file() {
return Err(EngineError::Io(format!("{}: not a regular file", path.display())));
}
let key = canonical_path(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let (a, b) = (file.metadata().map_err(err)?, std::fs::metadata(&key).map_err(err)?);
if (a.dev(), a.ino()) != (b.dev(), b.ino()) {
return Err(EngineError::Io(format!("{}: changed while it was opened", path.display())));
}
}
if !key.starts_with(root) {
return Err(EngineError::Io(format!("{}: outside the folder it must stay in", path.display())));
}
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).map_err(err)?;
Ok(bytes)
}
/// [`read_file`] as UTF-8 text.
pub fn read_text(path: impl AsRef<Path>) -> Result<String> {
let path = path.as_ref();
@@ -178,8 +241,12 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
}
/// Create `dir` and its parents, inside the automation write root when there is one.
/// Create `dir` and its parents, inside the automation write root when there is one. The browser
/// has no folders: there each file is delivered by its name ([`write_file`]) and this does nothing.
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
if cfg!(target_arch = "wasm32") {
return Ok(());
}
let dir = dir.as_ref();
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
// check would refuse as an empty path.
@@ -188,6 +255,17 @@ pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
}
/// `path` with its links followed, inside the automation read root when there is one. The root is
/// checked on the path as named (links followed, a missing tail resolved by its nearest folder)
/// before anything is resolved, so a name that leads out of the root is refused the same way
/// whether or not it exists there.
pub(crate) fn canonical_path(path: impl AsRef<Path>) -> Result<PathBuf> {
let path = path.as_ref();
let checked = if path.as_os_str().is_empty() { Path::new(".") } else { path };
crate::file_access::check_read(checked).map_err(EngineError::Io)?;
std::fs::canonicalize(path).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))
}
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
pub(crate) fn write_path(path: impl AsRef<Path>, bytes: impl AsRef<[u8]>) -> Result<()> {
let path = path.as_ref();
@@ -442,7 +520,7 @@ impl Out {
if cfg!(target_arch = "wasm32") {
return Ok(Out::Memory(std::io::Cursor::new(Vec::new())));
}
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, std::fs::File::create(part)?)))
Ok(Out::File(std::io::BufWriter::with_capacity(1 << 20, create_new(part)?)))
}
/// Puts the finished file at `path` and returns its size: the part file is synced and renamed
@@ -563,29 +641,68 @@ impl From<StripNames> for soundcraft_model::AutoName {
}
}
/// Write each clip as a WAV file named after it into `dir` (in the browser, delivered by name
/// through [`write_file`]). Returns how many were written.
pub fn export_clips(e: &Engine, ids: &[soundcraft_model::ClipId], dir: &str) -> Result<usize> {
let s = e.session();
make_dir(dir)?;
let mut n = 0;
let mut names = ClipNames::default();
for id in ids {
let Some((t, c)) = s.find_clip(*id) else { continue };
let audio = soundcraft_mix::render_clips(s, t, c.range());
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).map_err(|err| EngineError::Io(err.to_string()))?;
let stem = sanitize_name(&c.name);
let mut path = Path::new(dir).join(format!("{stem}.wav"));
let mut k = 2u32;
while path.exists() {
path = Path::new(dir).join(format!("{stem}-{k}.wav"));
k = k.saturating_add(1);
if k > 10_000 {
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
if cfg!(target_arch = "wasm32") {
let path = clip_file(dir, &stem, &mut names, Path::exists)?;
write_file(&path.to_string_lossy(), &bytes)?;
continue;
}
write_path(&path, bytes)?;
n += 1;
// The name is taken by creating the file, so whatever entry already has it (a link
// to elsewhere included) is left alone rather than written through. An occupied name is
// taken whatever it is (a link whose target is outside the write root too): it is only
// looked at, never resolved, so the root check does not stop the search for a free name.
// The folder itself was checked above.
let (mut file, path) = loop {
let path = clip_file(dir, &stem, &mut names, |p| std::fs::symlink_metadata(p).is_ok())?;
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(f) => break (f, path),
// taken since it was looked at: `names` has it now, so the next name is tried
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
};
std::io::Write::write_all(&mut file, &bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
}
Ok(n)
Ok(names.taken.len())
}
/// The files an export has named so far, and for each name the suffix to try next, so a run of
/// same-named clips doesn't count up from 2 again for every clip.
#[derive(Default)]
struct ClipNames {
taken: std::collections::HashSet<PathBuf>,
next: std::collections::HashMap<String, u32>,
}
/// The first of `<stem>.wav`, `<stem>-2.wav`, … in `dir` that is neither `on_disk` nor already
/// named by this export, recorded in `names`: the browser has no disk to look at, so same-named
/// clips are kept apart by `names`.
fn clip_file(dir: &str, stem: &str, names: &mut ClipNames, on_disk: impl Fn(&Path) -> bool) -> Result<PathBuf> {
let file = |k: u32| Path::new(dir).join(if k < 2 { format!("{stem}.wav") } else { format!("{stem}-{k}.wav") });
let mut k = names.next.get(stem).copied().unwrap_or(1);
let mut path = file(k);
while on_disk(&path) || names.taken.contains(&path) {
k = k.saturating_add(1);
if k > 10_000 {
return Err(EngineError::Io(format!("{dir}: too many files named {stem}")));
}
path = file(k);
}
names.next.insert(stem.to_owned(), k.saturating_add(1));
names.taken.insert(path.clone());
Ok(path)
}
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
@@ -621,13 +738,13 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
{
src.path = f.join(&src.path).to_string_lossy().into_owned();
}
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && file_found(dir.join(&src.path));
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
if let Some(f) = in_folder.filter(|_| !src.unsaved && file_found(&src.path)) {
src.path = format!("Audio Files/{f}");
continue;
}
@@ -649,8 +766,11 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
// (With only an automation write root, `file_found` cannot read an existing file, so each save
// writes a new numbered WAV: reuse never reads a file the roots do not allow, which keeps a save
// from revealing whether an unreadable file matches the audio.)
stem = format!("{stem}_{}", src.id.0);
if audio_dir.join(format!("{stem}.wav")).exists() {
if file_found(audio_dir.join(format!("{stem}.wav"))) {
src.path = format!("Audio Files/{stem}.wav");
src.unsaved = false;
files.insert(Arc::as_ptr(audio), src.path.clone());
@@ -733,11 +853,22 @@ fn sync_dir(dir: &Path) {
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::File::create(path)?;
let mut f = create_new(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Creates `path` as a new file. A leftover of that name (a part file from an interrupted write)
/// is removed first, but an existing entry is never opened: a link planted at a predictable name
/// in the destination folder can't redirect the write elsewhere.
fn create_new(path: &Path) -> std::io::Result<std::fs::File> {
match std::fs::remove_file(path) {
Err(err) if err.kind() != std::io::ErrorKind::NotFound => return Err(err),
_ => {}
}
std::fs::OpenOptions::new().write(true).create_new(true).open(path)
}
/// Copies `from` to `to`, which must not exist yet; an existing entry, even a link, is never followed.
fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
let mut src = std::fs::File::open(from)?;
@@ -817,7 +948,8 @@ fn same_path(a: &Path, b: &Path) -> bool {
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
/// written.
/// written. Where the automation roots allow writing in `dir` but not reading (only a write root),
/// an existing file is never compared, so each save writes a new numbered WAV.
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
use std::io::Write;
for k in 0..10_000u32 {
@@ -832,7 +964,12 @@ fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
return Ok((name, true));
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
// Reused only when it may be read: whether it equals the audio must not be
// learnt about a file the roots allow writing beside but not reading.
if crate::file_access::check_read(&path).is_ok()
&& std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64)
&& std::fs::read(&path).is_ok_and(|b| b == bytes)
{
return Ok((name, false));
}
}
@@ -868,7 +1005,99 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
return deliver(name, bytes).map_err(EngineError::Io);
}
crate::file_access::check_write(path).map_err(EngineError::Io)?;
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
// Follow existing symlinks, and refuse dangling links even without automation roots.
let named = Path::new(path);
let path = match std::fs::symlink_metadata(named) {
Ok(m) if m.file_type().is_symlink() => std::fs::canonicalize(named).map_err(|err| EngineError::Io(format!("{path}: {err}")))?,
Ok(_) => named.to_path_buf(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => named.to_path_buf(),
Err(err) => return Err(EngineError::Io(format!("{path}: {err}"))),
};
crate::file_access::check_write(&path).map_err(EngineError::Io)?;
let metadata = match std::fs::metadata(&path) {
Ok(m) if m.is_file() => Some(m),
Ok(_) => return Err(EngineError::Io(format!("{}: not a regular file", path.display()))),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
};
let err = |err: std::io::Error| EngineError::Io(format!("{}: {err}", path.display()));
if metadata.as_ref().is_some_and(|m| m.permissions().readonly()) {
return Err(err(std::io::ErrorKind::PermissionDenied.into()));
}
write_export_checked(&path, bytes).map_err(err)
}
/// Keep the original file and its security metadata. Refuse hard links before truncation: a
/// link inside an automation root can name a file outside it (#1359).
#[cfg(unix)]
fn write_export_checked(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
use std::os::unix::fs::{MetadataExt as _, OpenOptionsExt as _};
let mut file = std::fs::OpenOptions::new().write(true).create(true).truncate(false).mode(0o600).open(path)?;
let opened = file.metadata()?;
if !opened.is_file() {
return Err(std::io::Error::other("not a regular file"));
}
if opened.nlink() > 1 {
return Err(hard_link_export_error());
}
file.set_len(0)?;
file.write_all(bytes)?;
file.sync_all()
}
#[cfg(any(unix, windows))]
fn hard_link_export_error() -> std::io::Error {
std::io::Error::other("refusing to overwrite a hard-linked file; choose a new export path")
}
#[cfg(not(any(unix, windows)))]
fn write_export_checked(_path: &Path, _bytes: &[u8]) -> std::io::Result<()> {
Err(std::io::ErrorKind::Unsupported.into())
}
/// Windows' stable std metadata API does not expose the link count. Use a safe kernel wrapper
/// and the same handle for the check and write; no replacement means the destination DACL stays.
#[cfg(windows)]
fn write_export_checked(path: &Path, mut bytes: &[u8]) -> std::io::Result<()> {
use winsafe::{HFILE, co};
// Let std retain its Windows filename normalization and create missing destinations without
// truncation. Canonicalization then supplies a verbatim path, including long and UNC paths,
// to the raw Win32 wrapper. The handle below is checked before any existing bytes change.
let _opened = std::fs::OpenOptions::new().write(true).create(true).truncate(false).open(path)?;
let absolute = std::fs::canonicalize(path)?;
let path = absolute.to_str().filter(|p| !p.contains('\0')).ok_or(std::io::ErrorKind::InvalidInput)?;
let io_error = |err: winsafe::co::ERROR| std::io::Error::other(err.to_string());
let (file, _) = HFILE::CreateFile(
path,
co::GENERIC::WRITE,
Some(co::FILE_SHARE::READ | co::FILE_SHARE::WRITE | co::FILE_SHARE::DELETE),
None,
co::DISPOSITION::OPEN_EXISTING,
co::FILE_ATTRIBUTE::NORMAL,
Some(co::FILE_FLAG::WRITE_THROUGH),
None,
None,
)
.map_err(io_error)?;
if file.GetFileType().map_err(io_error)? != co::FILE_TYPE::DISK {
return Err(std::io::Error::other("not a regular file"));
}
match file.GetFileInformationByHandle().map_err(io_error)?.nNumberOfLinks {
1 => {}
0 => return Err(std::io::ErrorKind::Unsupported.into()),
_ => return Err(hard_link_export_error()),
}
file.SetEndOfFile().map_err(io_error)?;
while !bytes.is_empty() {
// Win32 takes a DWORD length. Small bounded writes also handle short writes correctly.
let count = file.WriteFile(&bytes[..bytes.len().min(64 * 1024)]).map_err(io_error)? as usize;
if count == 0 {
return Err(std::io::ErrorKind::WriteZero.into());
}
bytes = &bytes[count..];
}
Ok(())
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
@@ -939,6 +1168,89 @@ pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<
.collect()
}
#[cfg(test)]
mod exclusive_tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-io-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[cfg(unix)]
#[test]
fn a_planted_link_at_the_part_name_is_not_written_through() {
let dir = scratch("part-link");
let (target, part) = (dir.join("elsewhere"), dir.join("session.scraft.tmp"));
std::os::unix::fs::symlink(&target, &part).unwrap();
write_synced(&part, b"new").unwrap();
assert!(!target.exists(), "the link's target was created");
assert_eq!(std::fs::read(&part).unwrap(), b"new", "the link itself was replaced by the file");
// A leftover regular part file is still replaced.
write_synced(&part, b"newer").unwrap();
assert_eq!(std::fs::read(&part).unwrap(), b"newer");
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn a_file_read_within_a_root_must_resolve_inside_it() {
let dir = scratch("within");
let (inside, outside) = (dir.join("group"), dir.join("elsewhere"));
std::fs::create_dir_all(&inside).unwrap();
std::fs::create_dir_all(&outside).unwrap();
std::fs::write(inside.join("a.wav"), b"a").unwrap();
std::fs::write(outside.join("b.wav"), b"b").unwrap();
std::os::unix::fs::symlink(outside.join("b.wav"), inside.join("link.wav")).unwrap();
let root = canonical_path(&inside).unwrap();
assert_eq!(read_file_inside(&inside.join("a.wav"), &root).unwrap(), b"a");
assert!(read_file_inside(&inside.join("link.wav"), &root).is_err());
// a FIFO with no writer is refused, not waited on (Linux: the coreutils tool makes it)
#[cfg(target_os = "linux")]
if std::process::Command::new("mkfifo").arg(inside.join("pipe.wav")).status().is_ok_and(|s| s.success()) {
let err = read_file_inside(&inside.join("pipe.wav"), &root).unwrap_err().to_string();
assert!(err.contains("not a regular file"), "{err}");
}
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
#[test]
fn export_skips_a_name_held_by_a_dangling_link() {
let dir = scratch("export-link");
let e = crate::demo::demo_engine();
let (t, c) = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).map(|c| (t.id, c.clone()))).unwrap();
let _ = t;
let target = dir.join("elsewhere.wav");
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
assert_eq!(export_clips(&e, &[c.id], &dir.to_string_lossy()).unwrap(), 1);
assert!(!target.exists(), "the export followed the link");
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
let _ = std::fs::remove_dir_all(&dir);
}
/// With a write root, a link out of it that holds the name is still just an occupied name.
#[cfg(unix)]
#[test]
fn export_skips_a_link_out_of_the_write_root_too() {
let dir = scratch("export-root-link");
let e = crate::demo::demo_engine();
let c = e.session().tracks.iter().find_map(|t| t.clips().iter().find(|c| c.is_audio()).cloned()).unwrap();
let elsewhere = scratch("export-root-elsewhere");
let target = elsewhere.join("target.wav");
std::os::unix::fs::symlink(&target, dir.join(format!("{}.wav", sanitize_name(&c.name)))).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap().unwrap();
let n = crate::file_access::confine(Some(&roots), || export_clips(&e, &[c.id], &dir.to_string_lossy())).unwrap();
assert_eq!(n, 1);
assert!(!target.exists(), "the export followed the link out of the root");
assert!(dir.join(format!("{}-2.wav", sanitize_name(&c.name))).is_file(), "the next free name was used");
let _ = std::fs::remove_dir_all(&dir);
let _ = std::fs::remove_dir_all(&elsewhere);
}
}
#[cfg(test)]
mod stem_tests {
#[test]
@@ -996,3 +1308,202 @@ mod stem_tests {
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
mod export_replacement_tests {
use super::*;
#[cfg(unix)]
use std::os::unix::fs::{PermissionsExt, symlink};
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-export-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn export_refuses_hard_links_before_truncating_and_refuses_nonfiles() {
let dir = scratch("hard-links");
let original = dir.join("original.mid");
let write_root = dir.join("write");
std::fs::create_dir(&write_root).unwrap();
let destination = write_root.join("export.mid");
std::fs::write(&original, b"old").unwrap();
std::fs::hard_link(&original, &destination).unwrap();
for confined in [false, true] {
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&write_root]).unwrap() } else { None };
let result = crate::file_access::confine(roots.as_ref(), || write_file(destination.to_str().unwrap(), b"new"));
assert!(result.unwrap_err().to_string().contains("hard-linked"));
}
assert_eq!(std::fs::read(&original).unwrap(), b"old");
assert_eq!(std::fs::read(&destination).unwrap(), b"old");
let folder = dir.join("folder");
std::fs::create_dir(&folder).unwrap();
assert!(write_file(folder.to_str().unwrap(), b"new").is_err());
assert!(folder.is_dir());
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(windows)]
#[test]
fn export_supports_paths_longer_than_max_path() {
let root = scratch("long-path");
let mut dir = root.clone();
while dir.as_os_str().len() < 280 {
dir.push("long-folder-component");
}
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("export.mid");
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
write_file(path.to_str().unwrap(), b"replacement").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"replacement");
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn export_writes_all_chunks_and_truncates_an_existing_file() {
let dir = scratch("chunks");
let path = dir.join("export.mid");
let bytes = vec![0x5a; 128 * 1024 + 17];
write_file(path.to_str().unwrap(), &bytes).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
write_file(path.to_str().unwrap(), b"").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().len(), 0);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn export_refuses_readonly_files_without_changing_their_bytes() {
let dir = scratch("readonly");
let path = dir.join("readonly.mid");
std::fs::write(&path, b"old contents").unwrap();
let original = std::fs::metadata(&path).unwrap().permissions();
let mut readonly = original.clone();
readonly.set_readonly(true);
std::fs::set_permissions(&path, readonly).unwrap();
let result = write_file(path.to_str().unwrap(), b"new");
let bytes = std::fs::read(&path).unwrap();
std::fs::set_permissions(&path, original).unwrap();
assert!(result.is_err());
assert_eq!(bytes, b"old contents");
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(any(unix, windows))]
#[test]
fn export_preserves_the_existing_file_identity_and_security_metadata() {
let dir = scratch("identity");
let path = dir.join("existing.mid");
std::fs::write(&path, b"long old contents").unwrap();
#[cfg(unix)]
let identity = |p: &Path| {
use std::os::unix::fs::MetadataExt as _;
let m = std::fs::metadata(p).unwrap();
(m.dev(), m.ino(), m.uid(), m.gid())
};
#[cfg(windows)]
let identity = |p: &Path| {
use winsafe::{HFILE, co};
let (file, _) = HFILE::CreateFile(
p.to_str().unwrap(),
co::GENERIC::READ,
None,
None,
co::DISPOSITION::OPEN_EXISTING,
co::FILE_ATTRIBUTE::NORMAL,
None,
None,
None,
)
.unwrap();
let i = file.GetFileInformationByHandle().unwrap();
(i.dwVolumeSerialNumber, i.nFileIndex())
};
let before = identity(&path);
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
// No inode/file-record replacement: owner, group and access-control lists stay attached
// to the same file rather than being inherited anew from the parent directory.
assert_eq!(identity(&path), before);
std::fs::remove_dir_all(dir).unwrap();
}
#[cfg(unix)]
#[test]
fn export_replacement_preserves_existing_permissions_and_new_files_are_private() {
let dir = scratch("permissions");
for mode in [0o600, 0o640] {
let path = dir.join(format!("{mode:o}.mid"));
std::fs::write(&path, b"old").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap();
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"new");
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, mode);
}
let path = dir.join("new.mid");
write_file(path.to_str().unwrap(), b"new").unwrap();
assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o077, 0);
let _ = std::fs::remove_dir_all(dir);
}
#[cfg(unix)]
#[test]
fn export_replacement_preserves_a_symlink_and_refuses_a_dangling_one() {
let dir = scratch("symlinks");
let target = dir.join("target.mid");
let link = dir.join("link.mid");
std::fs::write(&target, b"old").unwrap();
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o600)).unwrap();
symlink("target.mid", &link).unwrap();
for confined in [false, true] {
let roots = if confined { crate::file_access::AutomationRoots::new(&[], &[&dir]).unwrap() } else { None };
crate::file_access::confine(roots.as_ref(), || write_file(link.to_str().unwrap(), b"new")).unwrap();
assert!(std::fs::symlink_metadata(&link).unwrap().file_type().is_symlink());
assert_eq!(std::fs::read(&target).unwrap(), b"new");
assert_eq!(std::fs::metadata(&target).unwrap().permissions().mode() & 0o777, 0o600);
}
let missing = dir.join("missing.mid");
let dangling = dir.join("dangling.mid");
symlink(&missing, &dangling).unwrap();
assert!(write_file(dangling.to_str().unwrap(), b"new").is_err());
assert!(std::fs::symlink_metadata(&dangling).unwrap().file_type().is_symlink());
assert!(!missing.exists());
assert!(!std::fs::read_dir(&dir).unwrap().any(|e| e.unwrap().file_name().to_string_lossy().starts_with(".soundcraft-")));
let _ = std::fs::remove_dir_all(dir);
}
}
#[cfg(test)]
mod clip_tests {
/// The browser has no disk to find earlier files on: same-named clips get their own names
/// from the export itself, as they do from the folder natively (#1409).
#[test]
fn same_named_clips_get_their_own_names_with_nothing_on_disk() {
let dir = std::env::temp_dir().join(format!("sc-clip-names-{}", std::process::id()));
let mut names = super::ClipNames::default();
for want in ["Take.wav", "Take-2.wav", "Take-3.wav"] {
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, std::path::Path::exists).unwrap();
assert_eq!(path, dir.join(want));
}
assert!(!dir.exists());
}
/// A file already in the folder is still skipped, and a run of same-named clips picks up where
/// the last one left off instead of looking at every earlier name again for each clip.
#[test]
fn same_named_clips_skip_files_on_disk_and_do_not_count_up_from_the_start_each_time() {
let dir = std::env::temp_dir().join(format!("sc-clip-disk-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("Take-2.wav"), b"").unwrap();
let looked = std::cell::Cell::new(0usize);
let on_disk = |p: &std::path::Path| {
looked.set(looked.get() + 1);
p.exists()
};
let mut names = super::ClipNames::default();
for want in ["Take.wav", "Take-3.wav", "Take-4.wav"] {
let path = super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
assert_eq!(path, dir.join(want));
}
let before = looked.get();
for _ in 0..1_000 {
super::clip_file(&dir.to_string_lossy(), "Take", &mut names, on_disk).unwrap();
}
assert_eq!(looked.get() - before, 1_000);
let _ = std::fs::remove_dir_all(&dir);
}
}
+51 -7
View File
@@ -7,18 +7,48 @@ use super::{
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
pub(crate) const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples: a file with a
/// tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> Option<AudioBuffer> {
/// The most a file's audio may grow when converted to the session rate. Sessions run at 8 kHz to
/// 768 kHz (96×); past this a file is lying about its rate, and converting a few kilobytes would
/// otherwise allocate and filter hundreds of megabytes.
const MAX_RATE_EXPANSION: u32 = 128;
/// Why [`at_rate`] refused a conversion.
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum RateError {
/// The converted audio would hold more samples than the limit.
TooLarge,
/// The file's rate is so far below the session's that converting would grow it past [`MAX_RATE_EXPANSION`].
Ratio { from: u32, to: u32 },
}
impl RateError {
/// The refusal as a sentence: `size` says what a size refusal means to the caller.
pub(crate) fn message(&self, size: &str) -> String {
match self {
RateError::TooLarge => size.to_string(),
RateError::Ratio { from, to } => {
format!("its {from} Hz sample rate is too low to convert to the session's {to} Hz (at most {MAX_RATE_EXPANSION}-fold)")
}
}
}
}
/// `buf` converted to `rate`, unless it would then hold more than `limit` samples, or grow more
/// than [`MAX_RATE_EXPANSION`]-fold: a file with a tiny sample rate grows by orders of magnitude.
pub(crate) fn at_rate(mut buf: AudioBuffer, rate: u32, limit: usize) -> std::result::Result<AudioBuffer, RateError> {
let from = buf.sample_rate.max(1);
// widened integers: dividing first would truncate 128.86-fold down to 128
if u64::from(rate) > u64::from(from) * u64::from(MAX_RATE_EXPANSION) {
return Err(RateError::Ratio { from, to: rate });
}
if buf.frames() as f64 * buf.num_channels() as f64 * f64::from(rate) / f64::from(from) > limit as f64 {
return None;
return Err(RateError::TooLarge);
}
if from != rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, from, rate);
buf.sample_rate = rate;
}
Some(buf)
Ok(buf)
}
/// A relative `Audio Files/` path no other source in `s` already uses.
@@ -115,7 +145,7 @@ pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| EngineError::Io(format!("{name}: {}", e.message("file is too large to import"))))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
@@ -215,8 +245,22 @@ mod rate_tests {
#[test]
fn a_tiny_sample_rate_is_refused_before_it_is_converted() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
assert!(at_rate(buf(1), 48_000, 1_000_000).is_none());
assert!(at_rate(buf(1), 48_000, 1_000_000).is_err());
let ok = at_rate(buf(8_000), 48_000, 1_000_000).unwrap();
assert_eq!((ok.sample_rate, ok.frames()), (48_000, 600));
}
#[test]
fn an_extreme_rate_expansion_is_refused_whatever_the_sample_budget() {
let buf = |rate| AudioBuffer { sample_rate: rate, channels: vec![vec![0.5; 100]; 2] };
// 100 frames at 300 Hz would fit the budget as 128 000 samples, but grow 640-fold.
assert_eq!(at_rate(buf(300), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 300, to: 192_000 });
assert!(at_rate(buf(1_500), 192_000, usize::MAX).is_ok(), "128-fold is the supported extreme");
assert!(at_rate(buf(8_000), 768_000, usize::MAX).is_ok(), "the slowest file at the fastest session");
// 128.86-fold: integer division would truncate it to 128 and let it through
assert_eq!(at_rate(buf(1_490), 192_000, usize::MAX).unwrap_err(), RateError::Ratio { from: 1_490, to: 192_000 });
// a short file that is only over the sample budget is a size refusal, not a rate one
assert_eq!(at_rate(buf(48_000), 48_000, 10).unwrap_err(), RateError::TooLarge);
assert!(RateError::Ratio { from: 300, to: 192_000 }.message("big").contains("300 Hz"));
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn open_session_linked(e: &mut Engine, path: &Path, links: &Path) ->
/// Decode one source's audio file at the session rate.
pub(super) fn decode_source(bytes: &[u8], ext: Option<&str>, rate: u32) -> std::result::Result<Arc<SourceAudio>, String> {
let (_, buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or("too large to load")?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).map_err(|e| e.message("too large to load"))?;
Ok(Arc::new(SourceAudio::new(buf)))
}
+68 -5
View File
@@ -117,9 +117,7 @@ pub fn strip_silence(
}
cut_points.push(r.end);
let old: std::collections::HashSet<_> = s.track(t).map(|x| x.clips().iter().map(|c| c.id).collect()).unwrap_or_default();
for p in &cut_points {
crate::edit::separate_at_if(s, t, *p, |_| false);
}
crate::edit::separate_at_points(s, t, &cut_points);
let kept = strip_pieces(s, t, r, &keep, mode);
crate::edit::auto_name_new_clips(s, t, &old, |c| !(named && c.is_audio() && c.start >= r.start && c.end() <= r.end));
Ok(kept)
@@ -144,10 +142,24 @@ fn strip_pieces(s: &mut Session, t: TrackId, r: Range, keep: &[(Samples, Samples
let audio: Vec<Range> = keep.iter().map(|(a, b)| Range::new(*a, *b)).collect();
let remove = if mode == StripMode::Extract { &audio } else { &gaps };
let inside = |c: &Clip, g: &Range| c.start >= g.start && c.end() <= g.end;
// The range of `rs` holding `c`: by binary search when `rs` is ascending and disjoint (the
// usual case, checked once by the caller), by scan otherwise.
let holder = |rs: &[Range], sorted: bool, c: &Clip| {
if sorted {
let i = rs.partition_point(|g| g.end < c.end());
rs.get(i).filter(|g| inside(c, g)).map(|_| i)
} else {
rs.iter().position(|g| inside(c, g))
}
};
let ordered = |rs: &[Range]| rs.windows(2).all(|w| w[0].end <= w[1].start);
let Some(pl) = s.track_mut(t).and_then(|x| x.playlist_mut()) else { return if mode == StripMode::Extract { 0 } else { keep.len() } };
pl.clips.retain(|c| !remove.iter().any(|g| inside(c, g)));
let sorted = ordered(remove);
pl.clips.retain(|c| holder(remove, sorted, c).is_none());
if mode == StripMode::Extract {
return gaps.iter().filter(|g| pl.clips.iter().any(|c| inside(c, g))).count();
let sorted = ordered(&gaps);
let held: std::collections::HashSet<usize> = pl.clips.iter().filter_map(|c| holder(&gaps, sorted, c)).collect();
return held.len();
}
keep.len()
}
@@ -294,6 +306,57 @@ mod strip_tests {
(r, e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect())
}
#[test]
fn cutting_at_many_points_in_one_pass_equals_cutting_at_each() {
let spans = |e: &Engine, t: TrackId| {
let mut v: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.end())).collect();
v.sort_unstable();
v
};
let points = [30_000, 10, 5_000, 30_000, 120_000, 0, 20_000, 1_000_000];
let (mut one, t) = tones(&[0, 20_000, 100_000], 40_000);
let (mut each, _) = tones(&[0, 20_000, 100_000], 40_000);
crate::edit::separate_at_points(one.session_mut(), t, &points);
for p in points {
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
}
assert_eq!(spans(&one, t), spans(&each, t));
assert!(spans(&one, t).len() > 3);
let ids: std::collections::HashSet<_> = one.session().track(t).unwrap().clips().iter().map(|c| c.id).collect();
assert_eq!(ids.len(), spans(&one, t).len(), "new pieces have distinct ids");
}
#[test]
fn cutting_stretched_audio_at_ascending_points_matches_cutting_at_each() {
let pieces = |e: &Engine, t: TrackId| {
let mut v: Vec<_> = e
.session()
.track(t)
.unwrap()
.clips()
.iter()
.map(|c| (c.start, c.end(), if let soundcraft_model::ClipContent::Audio { offset, .. } = c.content { offset } else { -1 }))
.collect();
v.sort_unstable();
v
};
let stretched = || {
let (mut e, t) = tones(&[0], 40_000);
for c in &mut e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap().clips {
c.stretch = 2.0;
}
(e, t)
};
let points = [1, 2, 3_001, 12_345];
let ((mut one, t), (mut each, _)) = (stretched(), stretched());
crate::edit::separate_at_points(one.session_mut(), t, &points);
for p in points {
crate::edit::separate_at_if(each.session_mut(), t, p, |_| false);
}
assert_eq!(pieces(&one, t), pieces(&each, t));
assert_eq!(pieces(&one, t).len(), points.len() + 1);
}
#[test]
fn strip_keeps_the_audio_and_removes_the_silence() {
let (r, clips) = strip_mode("strip");
+1
View File
@@ -13,6 +13,7 @@ pub mod cmd;
pub mod demo;
mod dirty;
pub mod edit;
pub mod feature_names;
pub mod file_access;
pub mod inspect;
pub mod io;
+45
View File
@@ -579,3 +579,48 @@ fn moving_a_group_left_past_zero_keeps_its_spacing() {
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)]);
}
#[test]
fn separate_and_range_edits_leave_locked_clips_whole() {
let edits = [
("edit.separate", json!({"at": 24_000})),
("edit.separate", json!({"start": 12_000, "end": 36_000})),
("edit.separate_on_grid", json!({"start": 12_000, "end": 36_000})),
("edit.clear", json!({"start": 12_000, "end": 36_000})),
("edit.insert_silence", json!({"start": 24_000, "end": 25_000})),
];
for lock in [None, Some("clip.edit_lock"), Some("clip.time_lock")] {
for (cmd, p) in &edits {
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
if let Some(lock) = lock {
run(&mut e, lock, json!({"clips": [ids[0].0]}));
}
let mut p = p.clone();
p["tracks"] = json!([t.0]);
run(&mut e, cmd, p);
if lock.is_some() {
assert_eq!(clips(&e, t), vec![(0, 48_000)], "{lock:?} {cmd}");
} else {
assert_ne!(clips(&e, t), vec![(0, 48_000)], "unlocked control {cmd}");
}
}
}
}
/// Event time edits move the entire timeline, including clips protected from ordinary edits.
#[test]
fn event_time_edits_split_and_move_locked_clips() {
for lock in ["clip.edit_lock", "clip.time_lock"] {
for (command, expected) in [
("event.insert_time", vec![(0, 12_000, 0), (36_000, 36_000, 12_000)]),
("event.cut_time", vec![(0, 12_000, 0), (12_000, 12_000, 36_000)]),
] {
let (mut e, t, ids) = long("slip", &[(0, 48_000, 0)]);
run(&mut e, lock, json!({"clips": [ids[0].0]}));
run(&mut e, command, json!({"start": 12_000, "end": 36_000}));
let actual: Vec<_> = e.session().track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect();
assert_eq!(actual, expected, "{lock} {command}");
assert!(e.session().track(t).unwrap().clips().iter().all(soundcraft_engine::edit::locked));
}
}
}
@@ -18,3 +18,68 @@ fn trim_to_selection_keeps_clips_sorted() {
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(long, 0, 30), (short, 5, 5)]);
}
fn three_clip_session() -> (Session, soundcraft_model::TrackId, [soundcraft_model::ClipId; 3]) {
let mut session = Session::default();
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let ids = [session.new_clip_id(), session.new_clip_id(), session.new_clip_id()];
session.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![
Clip::audio(ids[0], "free", SourceId(1), 0, 0, 100),
Clip::audio(ids[1], "edit-locked", SourceId(1), 0, 100, 100),
Clip::audio(ids[2], "time-locked", SourceId(1), 0, 200, 100),
];
let pl = session.track_mut(track).unwrap().playlist_mut().unwrap();
pl.clips[1].edit_locked = true;
pl.clips[2].time_locked = true;
(session, track, ids)
}
#[test]
fn trim_to_selection_leaves_locked_clips_whole() {
let (session, track, ids) = three_clip_session();
let mut engine = Engine::new(session);
let result = engine.execute("edit.trim_to_selection", &json!({"tracks": [track.0], "start": 50, "end": 250})).unwrap();
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(), vec![(ids[0], 50, 50), (ids[1], 100, 100), (ids[2], 200, 100)]);
assert_eq!(result["trimmed"], json!(1));
engine.undo();
assert_eq!(engine.session().track(track).unwrap().clips()[0].length, 100);
}
#[test]
fn edit_trim_skips_locked_clips() {
let (session, track, ids) = three_clip_session();
let mut engine = Engine::new(session);
engine.execute("edit.trim_start_to_insertion", &json!({"tracks": [track.0], "at": 150})).unwrap();
engine.execute("edit.trim_end_to_insertion", &json!({"tracks": [track.0], "at": 250})).unwrap();
let clips = engine.session().track(track).unwrap().clips();
assert_eq!(
clips.iter().map(|c| (c.id, c.start, c.length)).collect::<Vec<_>>(),
vec![(ids[0], 0, 100), (ids[1], 100, 100), (ids[2], 200, 100)],
"only unlocked clips trim; the edit-locked and time-locked ones keep their extent"
);
}
/// A lock on one clip must not suppress eligible unlocked clips on the same track.
#[test]
fn insertion_trims_still_trim_unlocked_clips_beside_both_lock_kinds() {
for (command, expected_start) in [("edit.trim_start_to_insertion", 50), ("edit.trim_end_to_insertion", 0)] {
let (mut session, track, ids) = three_clip_session();
for clip in &mut session.track_mut(track).unwrap().playlist_mut().unwrap().clips {
clip.start = 0;
}
let before = session.clone();
let mut engine = Engine::new(session);
let result = engine.execute(command, &json!({"tracks": [track.0], "at": 50})).unwrap();
assert_eq!(result["trimmed"], json!(1), "{command}");
let clips = engine.session().track(track).unwrap().clips();
let free = clips.iter().find(|c| c.id == ids[0]).unwrap();
assert_eq!((free.start, free.length), (expected_start, 50), "{command}");
for id in &ids[1..] {
let locked = clips.iter().find(|c| c.id == *id).unwrap();
assert_eq!((locked.start, locked.length), (0, 100), "{command}");
}
assert!(engine.undo());
assert_eq!(engine.session(), &before, "{command} undo");
}
}
+136 -21
View File
@@ -1,4 +1,6 @@
use super::{AutoParam, Samples, Session, TICKS_PER_QUARTER, Track, Transport, db_to_gain};
use soundcraft_dsp::MIN_DB;
use soundcraft_model::AutomationLane;
pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording: bool) -> Transport {
let sr = s.sample_rate;
@@ -21,15 +23,146 @@ pub(crate) fn transport_at(s: &Session, pos: Samples, playing: bool, recording:
}
}
pub(crate) fn volume_db_at(t: &Track, pos: Samples) -> f32 {
/// One term of a fader's level in dB: a fixed value, or an automation lane (with its default).
#[derive(Clone, Copy)]
pub(crate) enum Level<'a> {
Fixed(f32),
Lane(&'a AutomationLane, f32),
}
impl Level<'_> {
fn at(&self, pos: Samples) -> f32 {
match self {
Level::Fixed(v) => *v,
Level::Lane(l, default) => l.value_at(pos, *default),
}
}
/// The lane's first breakpoint after `pos`. Up to it, the level is linear in time.
fn next_point(&self, pos: Samples) -> Option<Samples> {
match self {
Level::Fixed(_) => None,
Level::Lane(l, _) => l.points.get(l.points.partition_point(|p| p.at <= pos)).map(|p| p.at),
}
}
}
/// A track's volume: its automation lane when the mode reads it, else the fader.
pub(crate) fn volume_level(t: &Track) -> Level<'_> {
if t.mixer.automation_mode.reads()
&& t.mixer.automation_mode != soundcraft_model::AutomationMode::Write
&& let Some(l) = t.lane(&AutoParam::Volume)
&& !l.points.is_empty()
{
return l.value_at(pos, t.mixer.volume_db);
return Level::Lane(l, t.mixer.volume_db);
}
t.mixer.volume_db
Level::Fixed(t.mixer.volume_db)
}
/// Calls `f(i, j, d0, d1)` for each stretch `i..=j` of the block at `pos` over which the sum of
/// `levels` is linear in dB, with its values at `i` and `j`. Lanes are linear between breakpoints,
/// so a stretch ends at the next breakpoint or at the block's last frame.
fn for_each_segment(levels: &[Level<'_>], pos: Samples, frames: usize, mut f: impl FnMut(usize, usize, f32, f32)) {
let db = |at: Samples| levels.iter().map(|l| l.at(at)).sum::<f32>();
let last = frames.saturating_sub(1);
let mut i = 0usize;
while i < frames {
let at = pos.saturating_add(i as i64);
let j = levels
.iter()
.filter_map(|l| l.next_point(at))
.min()
.map_or(last, |k| usize::try_from(k.saturating_sub(pos)).map_or(last, |j| j.min(last)));
f(i, j, db(at), db(pos.saturating_add(j as i64)));
i = j + 1;
}
}
/// Multiplies `x` by a gain that goes from `d0` dB at its first sample to `d1` dB at its last,
/// linearly in dB, capped at `max_db` and silent at or below [`MIN_DB`] (or NaN), as `db_to_gain`
/// would give it sample by sample. Linear in dB is geometric in gain, so the ramp steps by one ratio
/// instead of converting every sample; the ends are converted exactly.
pub(crate) fn scale(x: &mut [f32], d0: f32, d1: f32, max_db: f32) {
let Some(n) = x.len().checked_sub(1) else { return };
// Match db_to_gain's ceiling even when the caller requests no fader cap.
let max_db = max_db.min(200.0);
if d0.is_nan() || d1.is_nan() {
x.fill(0.0);
return;
}
if d0 == d1 || n == 0 {
let g = gain(d0, max_db);
x.iter_mut().for_each(|v| *v *= g);
return;
}
// Extreme finite trim values can underflow or overflow the geometric recurrence.
// Convert those rare ramps directly so an inaudible prefix can recover normally.
if !(-1000.0..=1000.0).contains(&d0) || !(-1000.0..=1000.0).contains(&d1) {
for (i, v) in x.iter_mut().enumerate() {
let t = i as f64 / n as f64;
let db = if i == 0 {
f64::from(d0)
} else if i == n {
f64::from(d1)
} else {
f64::from(d0) * (1.0 - t) + f64::from(d1) * t
};
*v *= gain(db as f32, max_db);
}
return;
}
let step = (f64::from(d1) - f64::from(d0)) / n as f64;
let ratio = 10f64.powf(step / 20.0);
let (floor, cap) = (f64::from(MIN_DB), f64::from(max_db));
let gcap = f64::from(db_to_gain(max_db));
let (mut d, mut g) = (f64::from(d0), 10f64.powf(f64::from(d0) / 20.0));
if let Some((first, rest)) = x.split_first_mut() {
*first *= gain(d0, max_db);
if let Some((last, mid)) = rest.split_last_mut() {
for v in mid {
d += step;
g *= ratio;
let gd = if d.is_nan() || d <= floor {
0.0
} else if d >= cap {
gcap
} else {
g
};
*v *= gd as f32;
}
*last *= gain(d1, max_db);
}
}
}
/// The gain of `db`, at most `max_db`.
fn gain(db: f32, max_db: f32) -> f32 {
if db.is_nan() { 0.0 } else { db_to_gain(db.min(max_db)) }
}
/// Fader gain over the block at `pos`, read at every frame: multiplies `buf`'s channels by the
/// gain of the sum of `levels` (dB, at most `max_db`).
pub(crate) fn apply_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, buf: &mut [Vec<f32>]) {
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
for c in buf.iter_mut() {
if let Some(x) = c.get_mut(i..=j) {
scale(x, d0, d1, max_db);
}
}
});
}
/// [`apply_gain`] from `src` into `dst`.
pub(crate) fn copy_with_gain(levels: &[Level<'_>], pos: Samples, frames: usize, max_db: f32, src: &[Vec<f32>], dst: &mut [Vec<f32>]) {
for_each_segment(levels, pos, frames, |i, j, d0, d1| {
for (d, s) in dst.iter_mut().zip(src) {
if let (Some(d), Some(s)) = (d.get_mut(i..=j), s.get(i..=j)) {
d.copy_from_slice(s);
scale(d, d0, d1, max_db);
}
}
});
}
pub(crate) fn pan_at(t: &Track, idx: usize, pos: Samples) -> f32 {
@@ -69,21 +202,3 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
}
base
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct GainRamp {
g0: f32,
g1: f32,
n: usize,
}
impl GainRamp {
pub(crate) fn new(db0: f32, db1: f32, n: usize) -> Self {
GainRamp { g0: db_to_gain(db0), g1: db_to_gain(db1), n }
}
#[inline]
pub(crate) fn at(&self, i: usize) -> f32 {
if self.n == 0 { self.g0 } else { self.g0 + (self.g1 - self.g0) * (i as f32 / self.n as f32) }
}
}
+4
View File
@@ -105,6 +105,10 @@ impl ClipFx {
}
}
}
/// Whether the EQ or compressor is on: only they carry parameters that settle (a gain does not).
pub(crate) fn has_smoothed_modules(&self) -> bool {
self.eq.is_some() || self.comp.is_some()
}
pub(crate) fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
if let Some(eq) = &mut self.eq {
eq.process(io, frames);
+3 -16
View File
@@ -71,7 +71,7 @@ mod routing;
mod strip;
mod threads;
use automation::{GainRamp, pan_at, pan_segment, send_level, transport_at, volume_db_at};
use automation::{copy_with_gain, pan_at, pan_segment, send_level, transport_at, volume_level};
pub use instances::{INSTRUMENT_SLOT, InstanceSpec, NewInstance, PluginFailures, PreparedInstance, create_instance, instance_specs, is_third_party};
pub use meter::StripMeter;
use midi::sync_sample;
@@ -539,21 +539,8 @@ impl MixEngine {
}
}
}
// Match track faders: the next block's first sample must not ramp this one.
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let v0 = volume_db_at(t, pos);
let v1 = volume_db_at(t, last);
let ramp = GainRamp::new(v0, v1, frames.saturating_sub(1));
for (ch, src) in strip.buf.iter().enumerate().take(self.main.len()) {
if let Some(m) = self.main.get_mut(ch) {
for i in 0..frames {
let g = ramp.at(i);
if let (Some(d), Some(x)) = (m.get_mut(i), src.get(i)) {
*d = x * g;
}
}
}
}
// Read at every sample, like track faders: a breakpoint acts on its own sample.
copy_with_gain(&[volume_level(t)], pos, frames, f32::INFINITY, &strip.buf, &mut self.main);
let m = self.meters.entry(t.id).or_default();
m.measure(&self.main, frames, 0.0);
m.plugin_failures = strip.failures;
+59 -7
View File
@@ -132,33 +132,85 @@ pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
out
}
/// Block size for clip effects in [`render_clips`].
const CLIP_FX_BLOCK: usize = 4096;
/// Most a clip effect keeps rendering past its clip's end in [`render_clips`], in seconds.
const MAX_CLIP_FX_TAIL_SECONDS: f64 = 10.0;
/// Silent lead-in run before a clip with effects so smoothed parameters (EQ) settle, in seconds
/// (the smoothers settle within ~0.3 s).
const CLIP_FX_SETTLE_SECONDS: f64 = 1.0;
/// Frames an effect advances its smoothed parameters by at a time (the EQ's chunk). Processing starts on a
/// multiple of it from the selection's start, as a render of the whole selection would, so the smoothers
/// step at the same samples.
const CLIP_FX_CHUNK: usize = 32;
/// A tail block whose peak is below this (-180 dBFS) ends a clip effect's tail.
const CLIP_FX_TAIL_FLOOR: f32 = 1e-9;
/// Render only one track's clips (no mixer processing) — Consolidate.
pub fn render_clips(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
let Some(t) = s.track(track) else { return Vec::new() };
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / t.channels().max(1));
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
let mut scratch = Vec::new();
// One reusable block for every clip with effects.
let mut block: Vec<Vec<f32>> = Vec::new();
let max_tail = s.sample_rate.samples(MAX_CLIP_FX_TAIL_SECONDS).max(0);
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
match clip_fx_params(s, c.id.0) {
None => render_clip(s, c, range.start, len, &mut out, &mut scratch),
Some(params) => {
let mut tmp = vec![vec![0.0f32; len]; out.len()];
render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, tmp.len());
if block.len() != out.len() {
block = vec![vec![0.0f32; CLIP_FX_BLOCK]; out.len()];
}
let mut fx = ClipFx::new(s.sample_rate.as_f64() as f32, out.len());
fx.configure(params);
let mut done = 0;
while done < len {
let n = (len - done).min(4096);
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch.get(done..done + n).map(<[f32]>::to_vec).unwrap_or_default()).collect();
// The effects start from silence, so nothing comes out before the clip starts:
// process from there through the clip and a bounded tail, not the whole selection.
let offset = |at: Samples| usize::try_from(at.saturating_sub(range.start).max(0)).unwrap_or(usize::MAX).min(len);
let first = offset(c.start);
let clip_end = offset(c.end());
let tail_end = offset(c.end().saturating_add(max_tail));
// Smoothed parameters start at their defaults; settle them on silence, as the
// full-span render did, so the clip opens at the configured setting.
// (the clip's own samples start inside the first chunk when `first` is not on the grid)
let grid = first - first % CLIP_FX_CHUNK;
let mut lead = grid.min(usize::try_from(s.sample_rate.samples(CLIP_FX_SETTLE_SECONDS).max(0)).unwrap_or(0));
lead -= lead % CLIP_FX_CHUNK;
// a gain-only effect has no smoother to settle
if !fx.has_smoothed_modules() {
lead = 0;
}
while lead > 0 {
let n = lead.min(CLIP_FX_BLOCK);
for ch in &mut block {
ch.fill(0.0);
}
fx.process(&mut block, n);
lead -= n;
}
let mut done = grid;
while done < len {
let n = (len - done).min(CLIP_FX_BLOCK);
for ch in &mut block {
ch.fill(0.0);
}
if done < clip_end {
render_clip(s, c, range.start.saturating_add(done as i64), n, &mut block, &mut scratch);
}
fx.process(&mut block, n);
let mut peak = 0.0f32;
for (o, b) in out.iter_mut().zip(block.iter()) {
if let Some(dst) = o.get_mut(done..done + n) {
for (x, y) in dst.iter_mut().zip(b.iter()) {
*x += *y;
peak = peak.max(y.abs());
}
}
}
done += n;
if done >= clip_end && (done >= tail_end || peak < CLIP_FX_TAIL_FLOOR) {
break;
}
}
}
}
+9 -22
View File
@@ -1,4 +1,4 @@
use super::automation::{GainRamp, transport_at, volume_db_at};
use super::automation::{Level, apply_gain, transport_at, volume_level};
use super::clips::{ClipFx, clip_fx_params, render_clip};
use super::instances::{INSTRUMENT_SLOT, PluginFailures, PreparedInstance, create_instance_for, is_third_party};
use super::midi::{chase_controllers, schedule_notes};
@@ -334,7 +334,7 @@ fn render_instrument(s: &Session, t: &Track, strip: &mut Strip, blk: Block, tran
fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport) {
let Block { pos, frames, .. } = blk;
let mut gr = 0.0f32;
let mut latency = 0usize;
let mut latency = strip.instrument.as_ref().filter(|i| !i.failed).map_or(0, |i| i.plugin.latency());
for (i, ins) in t.mixer.inserts.iter().enumerate() {
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
if !ins.active || ins.bypass || slot.failed {
@@ -356,41 +356,28 @@ fn run_inserts(t: &Track, strip: &mut Strip, blk: Block, transport: &Transport)
strip.latency = latency.min(MAX_LATENCY);
}
/// Fader gain (volume, trim and VCA automation), ramped across the block.
/// Fader gain (volume, trim and VCA automation), read at every sample of the block.
fn apply_fader(s: &Session, t: &Track, strip: &mut Strip, blk: Block) {
let Block { pos, frames, .. } = blk;
// Endpoints are the first and last sample of this block. The next block starts on its own
// first sample, so a breakpoint there does not ramp the block before it.
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let mut v0 = volume_db_at(t, pos);
let mut v1 = volume_db_at(t, last);
// Trim automation (an offset on top of the volume curve).
if let Some(l) = t
let trim = t
.automation
.iter()
.find(|l| matches!(&l.param, AutoParam::Plugin { slot: u8::MAX, param } if param == "trim"))
.filter(|l| !l.points.is_empty())
{
v0 += l.value_at(pos, 0.0);
v1 += l.value_at(last, 0.0);
}
.map_or(Level::Fixed(0.0), |l| Level::Lane(l, 0.0));
// VCA master: its fader offsets the member's, and its mute mutes the member.
let mut vca_level = Level::Fixed(0.0);
if let Some(vid) = t.mixer.vca
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
{
v0 += volume_db_at(vca, pos);
v1 += volume_db_at(vca, last);
vca_level = volume_level(vca);
if vca.mixer.mute {
strip.muted = true;
}
}
let (g0, g1) = if strip.muted { (-144.0, -144.0) } else { (v0.min(12.0), v1.min(12.0)) };
let ramp = GainRamp::new(g0, g1, frames.saturating_sub(1));
for c in strip.buf.iter_mut() {
for (i, x) in c.iter_mut().enumerate().take(frames) {
*x *= ramp.at(i);
}
}
let levels = if strip.muted { [Level::Fixed(-144.0), Level::Fixed(0.0), Level::Fixed(0.0)] } else { [volume_level(t), trim, vca_level] };
apply_gain(&levels, pos, frames, 12.0, &mut strip.buf);
}
/// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
+309 -7
View File
@@ -941,6 +941,84 @@ fn a_plugins_bogus_latency_is_capped() {
assert_eq!((eng.track_latency, eng.latency), (MAX_LATENCY, 2 * MAX_LATENCY));
}
#[test]
fn a_hosted_instruments_latency_is_compensated() {
// #1474: a synth that reports latency plays late. Its delay must count toward the mix
// latency and align, the way an insert's does.
struct LatentSynth {
inner: Box<dyn Plugin>,
n: usize,
t: usize,
pending: Option<usize>,
}
impl Plugin for LatentSynth {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.inner.info()
}
fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {
self.inner.prepare(sr, mb, ch);
}
fn reset(&mut self) {
self.inner.reset();
}
fn set_param(&mut self, id: &str, v: f32) -> bool {
self.inner.set_param(id, v)
}
fn param(&self, id: &str) -> Option<f32> {
self.inner.param(id)
}
fn note_on(&mut self, offset: usize, _: u8, _: u8) {
self.pending = Some(self.t + offset + self.n);
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
for c in io.iter_mut() {
for (i, x) in c.iter_mut().take(frames).enumerate() {
*x = if Some(self.t + i) == self.pending { 0.25 } else { 0.0 };
}
}
self.t += frames;
}
fn latency(&self) -> usize {
self.n
}
}
use soundcraft_midi::{Note, Sequence};
let n = 500;
let mut s = Session::default();
s.edit.delay_compensation = true;
let audio = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let mut click = vec![0.0f32; 48_000];
click[24_000] = 0.25;
s.pool.insert(SourceId(1474), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![click.clone(), click] })));
let cid = s.new_clip_id();
s.track_mut(audio).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "click", SourceId(1474), 0, 0, 48_000));
let inst = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
let mut seq = Sequence::default();
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 960, length: 240 });
let mcid = s.new_clip_id();
s.track_mut(inst).unwrap().playlist_mut().unwrap().clips.push(Clip::midi(mcid, "note", 0, 48_000, seq));
s.track_mut(inst).unwrap().instrument = Some(Insert::new("clap:test.latent"));
let mut eng = MixEngine::new(48_000.0, 1024);
eng.set_external_instances(true);
eng.adopt(vec![PreparedInstance {
track: inst,
slot: INSTRUMENT_SLOT,
id: "clap:test.latent".into(),
ch: 2,
plugin: Box::new(LatentSynth { inner: soundcraft_dsp::create("gain").unwrap(), n, t: 0, pending: None }),
}]);
let mut out = vec![vec![0.0; 1024]; 2];
let mut rendered = Vec::new();
for b in 0..48 {
eng.render(&s, b * 1024, 1024, &mut out);
rendered.extend_from_slice(&out[0]);
}
assert_eq!(eng.latency, n, "the hosted instrument's latency is part of the mix latency");
let impulses: Vec<_> = rendered.iter().enumerate().filter(|(_, v)| v.abs() > 1e-6).map(|(i, _)| i).collect();
assert_eq!(impulses, vec![24_000 + n], "both the audio click and instrument impulse align");
assert!((rendered[24_000 + n] - 0.5).abs() < 1e-6, "the aligned impulses add, neither is missing");
}
#[test]
fn input_monitoring_replaces_clips() {
let (mut s, t) = session_with_dc(0.5, 4800);
@@ -1197,14 +1275,238 @@ fn apply_matrix_span_matches_the_plain_per_sample_sum() {
}
}
/// A volume lane through `points` (sample, dB).
fn volume_lane(points: &[(i64, f32)]) -> soundcraft_model::AutomationLane {
let mut l = soundcraft_model::AutomationLane::new(AutoParam::Volume);
for &(at, db) in points {
l.set_point(at, db);
}
l
}
/// Volume automation is read at every sample: a breakpoint acts on its own sample, and between
/// breakpoints the gain follows the lane (linear in dB), whatever size the render blocks are. A
/// 480-sample crossfade used to be resolved once per block, so it moved by up to a block and left
/// a hole or not depending on where the block edge fell.
#[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}");
fn volume_automation_follows_its_breakpoints_in_any_block_size() {
let (mut s, a) = session_with_dc(0.5, 8192);
let b = add_dc_track(&mut s, 0.5, 8192);
// A falls from 0 to -144 dB across 2760..3240 while B rises, and back across 5760..6240.
let fall = volume_lane(&[(0, 0.0), (2760, 0.0), (3240, -144.0), (5760, -144.0), (6240, 0.0)]);
let rise = volume_lane(&[(0, -144.0), (2760, -144.0), (3240, 0.0), (5760, 0.0), (6240, -144.0)]);
// One track's level at 0 dB, through the pan law.
let per_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
s.track_mut(a).unwrap().automation.push(fall.clone());
s.track_mut(b).unwrap().automation.push(rise.clone());
let want = |i: i64| per_track * (db_to_gain(fall.value_at(i, 0.0)) + db_to_gain(rise.value_at(i, 0.0)));
for block in [1024, 512, 333, 64] {
let out = render_range(&s, Range::new(0, 8192), block);
for (i, &x) in out[0].iter().enumerate() {
let w = want(i as i64);
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
}
}
}
/// A master fader's breakpoint acts on its own sample too.
#[test]
fn master_volume_automation_follows_its_breakpoints_in_any_block_size() {
let (mut s, _) = session_with_dc(0.5, 4096);
let dry = render_range(&s, Range::new(0, 64), 64)[0][0];
let m = s.add_track(TrackKind::Master, ChannelFormat::Stereo, None);
let lane = volume_lane(&[(0, 0.0), (1500, 0.0), (1501, -144.0), (2600, -144.0), (2700, -6.0)]);
s.track_mut(m).unwrap().automation.push(lane.clone());
for block in [1024, 256, 100] {
let out = render_range(&s, Range::new(0, 4096), block);
for (i, &x) in out[0].iter().enumerate() {
let w = dry * db_to_gain(lane.value_at(i as i64, 0.0));
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "block {block}, sample {i}: {x} vs {w}");
}
}
}
/// `scale` gives what `db_to_gain` gives sample by sample, from a ramp's first sample to its last:
/// silence at or below -144 dB, the cap, NaN, extreme values and one- or two-sample ramps.
#[test]
fn stepped_fader_ramps_match_converting_every_sample() {
let cases: &[(&str, f32, f32, usize, f32)] = &[
("down to silence", 0.0, -144.0, 480, 12.0),
("up from silence", -144.0, 0.0, 480, 12.0),
("across the cap", 6.0, 18.0, 300, 12.0),
("below the floor and back", -200.0, -20.0, 512, 12.0),
("flat", -6.0, -6.0, 1024, 12.0),
("one sample", -6.0, 3.0, 1, 12.0),
("two samples", -6.0, 3.0, 2, 12.0),
("empty", 0.0, -6.0, 0, 12.0),
("NaN start", f32::NAN, -3.0, 64, 12.0),
("long ramp in one block", 0.0, -60.0, 16_384, 12.0),
("recover from deep silence", -10_000.0, 0.0, 1024, 12.0),
("descend from extreme trim", 10_000.0, -100.0, 1024, 12.0),
("no cap", -12.0, 6.0, 1000, f32::INFINITY),
("gain helper ceiling", 300.0, 0.0, 1024, f32::INFINITY),
("extreme exact endpoint", -1e20, -20.0, 64, 12.0),
("negative infinite start", f32::NEG_INFINITY, -20.0, 64, 12.0),
("positive infinite end", -20.0, f32::INFINITY, 64, 12.0),
("opposite infinities", f32::NEG_INFINITY, f32::INFINITY, 64, 12.0),
];
for &(name, d0, d1, n, max_db) in cases {
let mut x = vec![1.0f32; n];
crate::automation::scale(&mut x, d0, d1, max_db);
for (k, &got) in x.iter().enumerate() {
let at = if k == 0 {
d0
} else if k + 1 == n {
d1
} else {
let t = k as f64 / (n - 1) as f64;
(f64::from(d0) * (1.0 - t) + f64::from(d1) * t) as f32
};
let want = if d0.is_nan() || d1.is_nan() || at.is_nan() { 0.0 } else { db_to_gain(at.min(max_db)) };
assert!((got - want).abs() <= 1e-5 * want.max(1e-6) || (got == 0.0 && want == 0.0), "{name}, sample {k} of {n}: {got} vs {want}");
}
if d0.is_nan() || d1.is_nan() {
assert!(x.iter().all(|&g| g == 0.0), "{name}: a NaN end is silence");
continue;
}
assert_eq!(x.first().copied().unwrap_or(0.0), if n == 0 { 0.0 } else { db_to_gain(d0.min(max_db)) }, "{name}: first sample exact");
if n > 1 {
assert_eq!(x[n - 1], db_to_gain(d1.min(max_db)), "{name}: last sample exact");
}
}
}
/// Volume, trim and VCA lanes with breakpoints at different samples add up at every sample, a
/// muted track stays silent, and a render that starts inside a ramp or has breakpoints on block
/// edges reads the same curve.
#[test]
fn summed_fader_lanes_are_read_at_every_sample() {
let (mut s, a) = session_with_dc(0.5, 12_288);
let muted = add_dc_track(&mut s, 0.5, 12_288);
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Mono, None);
let one_track = render_range(&s, Range::new(0, 64), 64)[0][0] / 2.0;
s.track_mut(muted).unwrap().mixer.mute = true;
let vol = volume_lane(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)]);
let mut trim = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: u8::MAX, param: "trim".into() });
for (at, db) in [(0, 0.0), (1500, 0.0), (3000, 6.0)] {
trim.set_point(at, db);
}
let vca_lane = volume_lane(&[(0, 0.0), (5120, 0.0), (7000, -12.0)]);
let tr = s.track_mut(a).unwrap();
tr.automation.push(vol.clone());
tr.automation.push(trim.clone());
tr.mixer.vca = Some(vca.0);
s.track_mut(vca).unwrap().automation.push(vca_lane.clone());
// dB at `i`, from the breakpoints themselves.
let lin = |pts: &[(i64, f32)], i: i64| match pts.iter().position(|p| p.0 > i) {
None => pts[pts.len() - 1].1,
Some(0) => pts[0].1,
Some(k) => pts[k - 1].1 + (pts[k].1 - pts[k - 1].1) * ((i - pts[k - 1].0) as f32 / (pts[k].0 - pts[k - 1].0) as f32),
};
let want = |i: i64| {
let db = lin(&[(0, 0.0), (1024, 0.0), (2048, -30.0), (4096, -6.0)], i)
+ lin(&[(0, 0.0), (1500, 0.0), (3000, 6.0)], i)
+ lin(&[(0, 0.0), (5120, 0.0), (7000, -12.0)], i);
one_track * db_to_gain(db.min(12.0))
};
for (start, block) in [(0i64, 1024), (0, 512), (1700, 333), (1024, 1024), (2047, 64)] {
let out = render_range(&s, Range::new(start, 12_288), block);
for (k, &x) in out[0].iter().enumerate() {
let i = start + k as i64;
let w = want(i);
assert!((x - w).abs() <= 1e-5 * w.max(1e-3), "start {start}, block {block}, sample {i}: {x} vs {w}");
}
}
}
/// Consolidate's clip-effect pass, processing every clip's effects across the whole selection
/// (what `render_clips` did before it limited them to the clip and its tail).
fn render_clips_full_span(s: &Session, track: TrackId, range: Range) -> Vec<Vec<f32>> {
let t = s.track(track).unwrap();
let len = range.len() as usize;
let mut out = vec![vec![0.0f32; len]; t.channels().max(1)];
let mut scratch = Vec::new();
for c in t.clips().iter().filter(|c| !c.muted && c.range().overlaps(&range)) {
let params = crate::clips::clip_fx_params(s, c.id.0).unwrap();
let mut tmp = vec![vec![0.0f32; len]; out.len()];
crate::clips::render_clip(s, c, range.start, len, &mut tmp, &mut scratch);
let mut fx = crate::clips::ClipFx::new(48_000.0, tmp.len());
fx.configure(params);
for done in (0..len).step_by(4096) {
let n = (len - done).min(4096);
let mut block: Vec<Vec<f32>> = tmp.iter().map(|ch| ch[done..done + n].to_vec()).collect();
fx.process(&mut block, n);
for (o, b) in out.iter_mut().zip(&block) {
o[done..done + n].iter_mut().zip(b).for_each(|(x, y)| *x += y);
}
}
}
out
}
#[test]
fn consolidate_clip_effects_cover_the_clip_and_its_tail() {
let fx_sets: [&[(&str, f64)]; 3] =
[&[("comp.lookahead", 5.0), ("comp.threshold", -20.0), ("gain", -6.0)], &[("eq.hpf_on", 1.0), ("gain", 3.0)], &[("eq.low_shelf_gain", 12.0)]];
for (k, params) in fx_sets.iter().enumerate() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, 20_000, 4800));
for (param, value) in params.iter() {
s.edit.values.insert(format!("clip_fx.{}.{param}", cid.0), *value);
}
let range = Range::new(5_000, 100_000);
let got = render_clips(&s, t, range);
let want = render_clips_full_span(&s, t, range);
assert_eq!(got.len(), want.len());
assert!(want[0].iter().any(|x| x.abs() > 0.05), "the fixture should sound");
if k == 0 {
// The compressor's lookahead output after the clip's end is kept.
assert!(got[0][20_000 + 4800 - 5_000 + 10].abs() > 1e-3);
}
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
assert!((a - b).abs() < 1e-5, "set {k}, sample {i}: {a} vs {b}");
}
}
}
/// A clip that starts a few frames into the selection, while the EQ's smoothers are still settling: the
/// effect's 32-frame chunks must line up with the full-span render's.
#[test]
fn consolidate_clip_effects_keep_the_reference_chunk_grid() {
for offset in [1i64, 31, 32, 33, 1000] {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4800]] })));
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "dc", SourceId(900), 0, offset, 4800));
s.edit.values.insert(format!("clip_fx.{}.eq.low_shelf_gain", cid.0), 12.0);
let range = Range::new(0, 20_000);
let got = render_clips(&s, t, range);
let want = render_clips_full_span(&s, t, range);
for (i, (a, b)) in got[0].iter().zip(&want[0]).enumerate() {
assert!((a - b).abs() < 1e-5, "clip at {offset}, sample {i}: {a} vs {b}");
}
}
}
#[test]
fn consolidate_many_tiny_effect_clips_stays_fast() {
// Thousands of one-sample clips with effects across a long selection: each one is processed
// over its own span, not the whole selection.
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.pool.insert(SourceId(900), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 1]] })));
for i in 0..2000i64 {
let cid = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "x", SourceId(900), 0, i * 1000, 1));
s.edit.values.insert(format!("clip_fx.{}.gain", cid.0), -6.0);
}
let start = std::time::Instant::now();
let out = render_clips(&s, t, Range::new(0, 4_000_000));
assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed());
assert!((out[0][1000] - 0.5 * db_to_gain(-6.0)).abs() < 1e-6);
assert_eq!(out[0][1001], 0.0);
}
+26 -10
View File
@@ -33,10 +33,10 @@ fn as_audio_thread(audio: bool, f: impl FnOnce()) {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Send) {
use rayon::prelude::*;
if work.len() >= 4 {
// The workers render on behalf of this thread: on the audio thread, they are it too.
let audio = on_audio_thread();
work.par_iter_mut().for_each(|w| as_audio_thread(audio, || f(w)));
// The audio thread is not a rayon worker, so it would park on a latch waiting for the shared
// pool (also busy with offline work): it renders its strips itself.
if work.len() >= 4 && !on_audio_thread() {
work.par_iter_mut().for_each(|w| as_audio_thread(false, || f(w)));
} else {
work.iter_mut().for_each(f);
}
@@ -47,7 +47,7 @@ pub(crate) fn run_strips<T: Send>(work: &mut [T], f: impl Fn(&mut T) + Sync + Se
work.iter_mut().for_each(f);
}
#[cfg(test)]
#[cfg(all(test, not(target_arch = "wasm32")))]
mod audio_thread_tests {
use super::*;
@@ -67,7 +67,7 @@ mod audio_thread_tests {
/// What `on_audio_thread` says inside each strip job, for strips run from a thread that is
/// (or is not) the audio thread.
fn seen_by_strips(audio: bool) -> Vec<bool> {
fn seen_by_strips(audio: bool) -> Result<Vec<bool>, &'static str> {
std::thread::spawn(move || {
if audio {
mark_audio_thread();
@@ -78,15 +78,31 @@ mod audio_thread_tests {
std::thread::sleep(std::time::Duration::from_millis(1));
*w = Some(on_audio_thread());
});
work.into_iter().map(|w| w.unwrap()).collect()
work.into_iter().collect::<Option<Vec<_>>>().ok_or("a strip did not run")
})
.join()
.unwrap()
.map_err(|_| "the strip render thread panicked")?
}
#[test]
fn strips_rendered_for_the_audio_thread_count_as_the_audio_thread_and_only_then() {
assert!(seen_by_strips(true).iter().all(|&rt| rt), "a worker rendering for the audio thread is on it");
assert!(seen_by_strips(false).iter().all(|&rt| !rt), "the workers don't stay marked afterwards");
assert_eq!(seen_by_strips(true), Ok(vec![true; 64]), "strips rendered for the audio thread are on it");
assert_eq!(seen_by_strips(false), Ok(vec![false; 64]), "the workers don't stay marked afterwards");
}
#[test]
fn the_audio_thread_never_hands_strips_to_the_rayon_pool() {
let on_pool = std::thread::spawn(|| {
mark_audio_thread();
let mut work = vec![false; 8];
run_strips(&mut work, |w| {
std::thread::sleep(std::time::Duration::from_millis(1));
*w = rayon::current_thread_index().is_some();
});
work.into_iter().filter(|&p| p).count()
})
.join()
.unwrap();
assert_eq!(on_pool, 0, "the audio thread must not block on the rayon pool");
}
}
+15 -1
View File
@@ -164,7 +164,10 @@ impl AutomationLane {
// Widen so far-apart breakpoints cannot overflow i64.
let span = i128::from(b.at) - i128::from(a.at);
let off = i128::from(at) - i128::from(a.at);
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
// Interpolate in f64: the f32 difference overflows for
// opposite-sign values near f32::MAX.
let (av, bv) = (f64::from(a.value), f64::from(b.value));
(av + (bv - av) * (off as f64 / span as f64)) as f32
}
(Some(a), _) => a.value,
_ => default,
@@ -281,6 +284,17 @@ mod tests {
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
}
#[test]
fn value_at_opposite_sign_extremes_stays_finite() {
let mut l = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "Cutoff".into() });
l.set_point(0, -3.0e38);
l.set_point(1000, 3.0e38);
let v = l.value_at(500, 0.0);
assert!(v.is_finite() && v.abs() < 1.0e32, "mid value {v}");
let w = l.value_at(1, 0.0);
assert!(w.is_finite() && w < -2.9e38, "early value {w}");
}
#[test]
fn pan_label_max_does_not_overflow() {
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
+1
View File
@@ -46,6 +46,7 @@ impl SoundApp {
services,
native_menu_bar: false,
autosave_dir: None,
fallback_take_dir: None,
autosaver: None,
autosaved_rev: 0,
leftovers: Vec::new(),
+40 -4
View File
@@ -155,15 +155,23 @@ impl SoundApp {
/// autosave folder. Returns the takes it records.
fn arm_recorder(&mut self) -> Result<Vec<soundcraft_engine::io::Take>, String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
// Without a settings folder the takes go under the temporary folder, in a folder of this
// run's own that nobody else could have made first.
let unsaved_dir = match &self.autosave_dir {
Some(d) => d.join(name),
// A saved session records beside its file: `plan_takes` never uses this folder.
None if self.engine.path.is_some() => std::path::PathBuf::new(),
None => self.fallback_take_dir()?,
};
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
// The takes are unsaved work: the folder they go into under the autosave root is
// owner-only, like the autosaves, before the recorder makes anything in it.
if self.engine.path.is_none() && self.autosave_dir.is_some() {
// The takes are unsaved work: on Unix the folder they go into is owner-only, like the
// autosaves, before the recorder makes anything in it (other systems get the folder's
// inherited access: no ACLs are set, so the temporary folder's own per-user access applies).
if self.engine.path.is_none() {
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
@@ -176,6 +184,19 @@ impl SoundApp {
Ok(takes)
}
/// The folder for the takes of a session never saved when there is no autosave folder, made on
/// first use: exclusively and under a name nobody can guess, so another account cannot have
/// made it first, with open permissions or as a link.
fn fallback_take_dir(&mut self) -> Result<std::path::PathBuf, String> {
if let Some(dir) = &self.fallback_take_dir {
return Ok(dir.clone());
}
let dir = soundcraft_engine::autosave::create_unique_folder(&std::env::temp_dir(), "SoundCraft")
.map_err(|e| format!("temporary take folder: {e}"))?;
self.fallback_take_dir = Some(dir.clone());
Ok(dir)
}
fn finish_recording(&mut self) {
self.engine.transport.recording = false;
if let Some(p) = &self.player {
@@ -453,3 +474,18 @@ impl SoundApp {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_fallback_take_folder_is_made_once_under_the_temporary_folder() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
let a = app.fallback_take_dir().unwrap();
assert_eq!(a.parent(), Some(std::env::temp_dir().as_path()));
assert!(a.is_dir());
assert_eq!(app.fallback_take_dir().unwrap(), a, "the same folder for the rest of the run");
let _ = std::fs::remove_dir_all(&a);
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
//! Event-menu operation windows: Beat Detective, Tempo Operations, Time Operations, MIDI
//! Event-menu operation windows: Beat Analysis, Tempo Operations, Time Operations, MIDI
//! Operations and MIDI Real-Time Properties. Each is a small form over engine commands.
use crate::SoundApp;
@@ -88,13 +88,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
rtp(app, ctx);
}
/// Beat Detective window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat.
/// Beat Analysis window: sensitivity, separate and set-tempo options, Analyze and Apply results, and Identify Beat.
fn beat_detective(app: &mut SoundApp, ctx: &egui::Context) {
let mut open = app.ui.show_beat_detective;
if !open {
return;
}
egui::Window::new("Beat Detective").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
egui::Window::new("Beat Analysis").open(&mut open).default_size(vec2(360.0, 220.0)).show(ctx, |ui| {
ui.label("Analyses the edit selection on the selected tracks.");
ui.add(egui::Slider::new(&mut app.ops.beat_sensitivity, 0.0..=1.0).text("Sensitivity"));
crate::widgets::checkbox(ui, &mut app.ops.beat_separate, "Separate clips at detected beats");
+5
View File
@@ -472,6 +472,11 @@ pub fn show(app: &mut crate::SoundApp, ui: &mut egui::Ui, window: crate::MainWin
// and bottom edges take frames (`drop_edges` keeps its default).
style.new_dock_zones = true;
style.side_edge_zones = false;
// The MIDI body's rect stands for a piano roll on screen (the arrow keys go to it), so only a
// frame that draws the panel records one: open behind another tab, the old rect is gone.
if edit {
ui.ctx().data_mut(|data| data.remove::<egui::Rect>(egui::Id::unique("soundcraft-midi-body")));
}
let output = DockArea::new(area(edit)).show_with_limits(
ui,
&workspace.layout,
+32 -2
View File
@@ -1,3 +1,4 @@
use super::layout::STRIP_W;
use super::tracks::{click_track, folder_depth, height_targets, in_edit_window, track_context_menu, view_label, view_menu};
use super::{
Align2, AutoParam, Color32, Pos2, Rect, Sense, Shape, SoundApp, Stroke, Tokens, Track, TrackHeight, TrackId, TrackKind, Ui, bold, groove, json,
@@ -14,6 +15,7 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:
click_track(app, id, ui.input(|i| i.modifiers), in_edit_window);
}
bg.context_menu(|ui| track_context_menu(app, ui, id));
color_strip(app, ui, id, head);
resize_edge(app, ui, track, head);
strip_buttons(app, ui, track, head);
name_plate(app, ui, track, head, selected);
@@ -26,6 +28,28 @@ pub(super) fn track_header(app: &mut SoundApp, ui: &mut Ui, track: &Track, head:
header_meter(ui, Rect::from_min_max(pos2(head.max.x - 8.0, head.min.y + 1.0), pos2(head.max.x, head.max.y - 1.5)), &md, track.channels() >= 2);
}
/// The colour strip: a click selects the track as the header does, a double click opens the Color
/// Palette for the selected tracks. A plain click on a track that is already selected keeps the
/// selection, so double-clicking one of several selected tracks colours them all. Registered after
/// the header background, so it sits on top of it, and before the strip's own buttons and the
/// resize edge, which stay on top of it.
fn color_strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, head: Rect) {
let strip = Rect::from_min_size(head.min, vec2(STRIP_W, head.height()));
let resp = ui.interact(strip, ui.scope_id().with(("color_strip", id.0)), Sense::click());
let mods = ui.input(|i| i.modifiers);
let selected = app.engine.session().edit.selected_tracks.contains(&id);
if resp.double_clicked() {
// A Cmd click toggles, so a Cmd double click's first click deselected the track: add it back.
if !selected {
click_track(app, id, mods, in_edit_window);
}
app.ui.show_color_palette = true;
} else if resp.clicked() && !(selected && mods.is_none()) {
click_track(app, id, mods, in_edit_window);
}
resp.context_menu(|ui| track_context_menu(app, ui, id));
}
/// Drag the bottom edge of the header to resize the track (snaps to the height presets).
fn resize_edge(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect) {
let id = track.id;
@@ -365,12 +389,18 @@ fn field_row(app: &mut SoundApp, ui: &mut Ui, track: &Track, head: Rect, head_bg
ui.painter().line_segment([c + vec2(0.0, -3.5), c + vec2(0.0, 1.0)], Stroke::new(1.0, t.text));
ui.painter().add(Shape::convex_polygon(vec![c + vec2(0.0, 0.5), c + vec2(3.0, 3.5), c + vec2(-3.0, 3.5)], t.text, Stroke::NONE));
if let Some(alg) = &track.elastic {
ui.painter().with_clip_rect(field).text(pos2(field.min.x + 14.0, field.center().y), Align2::LEFT_CENTER, alg, bold(10.0), t.text);
ui.painter().with_clip_rect(field).text(
pos2(field.min.x + 14.0, field.center().y),
Align2::LEFT_CENTER,
soundcraft_engine::feature_names::display_label(alg),
bold(10.0),
t.text,
);
}
egui::Popup::menu(&resp.on_hover_text("Elastic Audio")).show(|ui| {
for alg in ["none", "polyphonic", "rhythmic", "monophonic", "varispeed", "x-form"] {
let current = track.elastic.as_deref().unwrap_or("none") == alg;
if ui.selectable_label(current, alg).clicked() {
if ui.selectable_label(current, soundcraft_engine::feature_names::display_label(alg)).clicked() {
let _ = app.run("track.elastic", json!({"tracks": [id.0], "algorithm": alg}));
}
}
+47
View File
@@ -534,6 +534,53 @@ fn shift_and_cmd_click_on_track_names_extend_the_selection() {
}
}
/// #1254: double-clicking a track's colour strip selects the track and opens the Color Palette;
/// a single click only selects it.
#[test]
fn double_clicking_the_color_strip_opens_the_color_palette() {
let mut h = harness(3);
let ids = row_ids(&h);
let strip = |h: &Harness<SoundApp>, i: usize| pos2(row(h, i).min.x + layout::STRIP_W / 2.0, row(h, i).center().y);
h.state_mut().engine.execute("edit.select", &json!({"tracks": [ids[0].0]})).unwrap();
h.run_steps(2);
let at = strip(&h, 1);
click(&mut h, at);
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[1]]);
assert!(!h.state().ui.show_color_palette, "a single click on the strip doesn't open the palette");
h.run_steps(30);
let at = strip(&h, 2);
click(&mut h, at);
click(&mut h, at);
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[2]]);
assert!(h.state().ui.show_color_palette, "a double click on the strip opens the palette");
}
/// #1254: a double click on the strip of one of several selected tracks keeps them all selected,
/// so the palette colours every one; with Cmd held the double-clicked track ends selected too.
#[test]
fn double_clicking_a_selected_tracks_strip_keeps_the_selection() {
let mut h = harness(3);
let ids = row_ids(&h);
let strip = |h: &Harness<SoundApp>, i: usize| pos2(row(h, i).min.x + layout::STRIP_W / 2.0, row(h, i).center().y);
h.state_mut().engine.execute("edit.select", &json!({"tracks": [ids[0].0, ids[2].0]})).unwrap();
h.run_steps(2);
let at = strip(&h, 2);
click(&mut h, at);
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]], "a plain click on a selected strip keeps the selection");
assert!(!h.state().ui.show_color_palette);
click(&mut h, at);
assert_eq!(h.state().engine.session().edit.selected_tracks, [ids[0], ids[2]], "both tracks stay selected");
assert!(h.state().ui.show_color_palette, "a double click on the strip opens the palette");
h.state_mut().ui.show_color_palette = false;
h.run_steps(30);
let at = strip(&h, 0);
click_with(&mut h, at, egui::Modifiers::COMMAND);
click_with(&mut h, at, egui::Modifiers::COMMAND);
assert!(h.state().engine.session().edit.selected_tracks.contains(&ids[0]), "a Cmd double click leaves its track selected");
assert!(h.state().ui.show_color_palette, "a Cmd double click on the strip opens the palette");
}
#[test]
fn a_quick_drag_from_a_clip_edge_trims_it() {
let mut h = smart_demo();
+2
View File
@@ -291,6 +291,8 @@ pub struct SoundApp {
pub native_menu_bar: bool,
/// Folder for crash-recovery autosaves (native apps set it).
pub autosave_dir: Option<std::path::PathBuf>,
/// Where the takes of a session never saved go without an autosave folder: made once per run, under the temporary folder.
fallback_take_dir: Option<std::path::PathBuf>,
/// Writes the open session's autosaves in the background, once there is something to write.
autosaver: Option<soundcraft_engine::autosave::Autosaver>,
/// The document revision last handed to the autosaver (0 once that autosave is stale).
+265 -34
View File
@@ -1,5 +1,8 @@
//! The menu bar, built from the incumbent's menu catalog so every item has its place. Items
//! that map to a command are live; the rest are shown disabled (and listed in the parity report).
//! A few features we do not have yet keep their catalog place as placeholders
//! ([`catalog::PLACEHOLDERS`]): they are never interactive and say so on hover, as the Preferences
//! placeholders do.
use crate::{MainWindow, SoundApp};
use serde_json::{Value, json};
@@ -60,7 +63,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.clip_effects_dock", "Clip Effects", "View > Other Displays > Lower Dock > Clip Effects", Some("Alt+6")),
("window.search", "Search", "Window > Pro Tools Search", Some("Cmd+Ctrl+S")),
("window.score", "Score Editor", "Window > Score Editor", Some("Cmd+Ctrl+=")),
("window.beat_detective", "Beat Detective", "", None),
("window.beat_detective", "Beat Analysis", "", None),
("window.tempo_ops", "Tempo Operations", "", None),
("window.time_ops", "Time Operations", "", None),
("window.midi_ops", "MIDI Operations", "", None),
@@ -190,10 +193,10 @@ fn insert_path(roots: &mut Vec<MenuNode>, line: &str) {
path.push_str(" > ");
}
path.push_str(p);
let idx = match level.iter().position(|n| n.label == *p) {
let idx = match level.iter().position(|n| n.path == path) {
Some(i) => i,
None => {
level.push(MenuNode { label: p.to_string(), path: path.clone(), children: Vec::new() });
level.push(MenuNode { label: display_menu_label(&path, p), path: path.clone(), children: Vec::new() });
level.len() - 1
}
};
@@ -202,21 +205,57 @@ fn insert_path(roots: &mut Vec<MenuNode>, line: &str) {
}
}
/// Offline leaves whose name waits on RFC-0029. They keep the catalog's own text and are listed
/// beside the processor that implements them, so no menu item disappears.
const DEFERRED_LABELS: &[&str] = &["AudioSuite > Pitch Shift > Pitch Shift Legacy", "AudioSuite > Other > Time Compression Expansion"];
/// Display names come from our processor registry; paths remain legacy automation keys.
fn display_menu_label(path: &str, fallback: &str) -> String {
if DEFERRED_LABELS.contains(&path) {
return fallback.to_string();
}
if let Some((_, process)) = AUDIOSUITE.iter().find(|(key, _)| *key == path) {
if let Some(info) = soundcraft_dsp::plugin_info(process) {
return info.name.to_string();
}
let label = match *process {
"pitch_shift" => "Pitch Shift",
"time_stretch" => "Time Stretch",
"speed_ramp" => "Speed Ramp",
_ => fallback,
};
return label.to_string();
}
soundcraft_engine::feature_names::display_label(fallback).to_string()
}
pub fn tree() -> Vec<MenuNode> {
let mut roots: Vec<MenuNode> = Vec::new();
let mut processors = std::collections::HashSet::new();
for line in catalog::catalog() {
// Several legacy entries map to one processor. List it once per category,
// while keeping every historical path accepted by command_for_path. A deferred leaf keeps
// its own catalog name, so it is not folded into the processor it shares.
if !DEFERRED_LABELS.contains(&line)
&& let Some((_, process)) = AUDIOSUITE.iter().find(|(path, _)| *path == line)
{
let category = line.rsplit_once(" > ").map_or(line, |(category, _)| category);
if !processors.insert((category, *process)) {
continue;
}
}
insert_path(&mut roots, line);
}
insert_path(&mut roots, "Window > Reset Panel Layout");
// Not in the observed catalog either; Pro Tools lists it right under Identify Sync Point.
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
let at = clip.children.iter().position(|n| n.label == "Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
if let Some(clip) = roots.iter_mut().find(|n| n.path == "Clip") {
let at = clip.children.iter().position(|n| n.path == "Clip > Identify Sync Point").map_or(clip.children.len(), |i| i + 1);
clip.children.insert(at, MenuNode { label: "Remove Sync Point".into(), path: "Clip > Remove Sync Point".into(), children: Vec::new() });
}
// Not in the incumbent catalog, but the only UI path to an empty MIDI clip (#282).
// It creates a clip rather than acting on the selected ones, so it leads the Clip menu.
insert_path(&mut roots, "Clip > New MIDI Clip");
if let Some(clip) = roots.iter_mut().find(|n| n.label == "Clip") {
if let Some(clip) = roots.iter_mut().find(|n| n.path == "Clip") {
clip.children.rotate_right(1);
}
for (path, _) in MENU_WINDOWS {
@@ -259,32 +298,34 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
let extra = ui_aliases();
for root in tree() {
ui.menu_button(&root.label, |ui| {
ui.add_enabled_ui(menu_entries_enabled, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
if c.path == "File > Open" {
crate::recent::menu(app, ui);
scrolling(ui, |ui| {
ui.add_enabled_ui(menu_entries_enabled, |ui| {
for c in &root.children {
menu_node(app, ui, c, &extra);
if c.path == "File > Open" {
crate::recent::menu(app, ui);
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
}
}
if c.path == "Track > New..." {
instrument_menu(app, ui);
if root.path == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
}
if root.label == "AudioSuite" {
ui.separator();
ui.label(egui::RichText::new("Processes selected clips offline").small().color(t.text_dim));
}
if root.label == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
.checkbox(&mut separate, "Mix in Separate Window")
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
.changed()
{
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
ui.close();
if root.path == "Window" {
ui.separator();
let mut separate = app.ui.mix_separate;
if ui
.checkbox(&mut separate, "Mix in Separate Window")
.on_hover_text("Show Edit and Mix at once, e.g. one per monitor")
.changed()
{
let _ = run_ui_command(app, "window.mix_separate", &json!({"value": separate}));
ui.close();
}
}
}
})
});
});
}
@@ -302,6 +343,12 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
);
}
/// A menu taller than the window scrolls instead of running off its bottom edge (#1482).
fn scrolling<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let max_h = (ui.ctx().content_rect().height() - 40.0).max(120.0);
egui::ScrollArea::vertical().max_height(max_h).show(ui, add).inner
}
/// Track › Instrument: the instrument picker for the selected instrument track (#374).
fn instrument_menu(app: &mut SoundApp, ui: &mut egui::Ui) {
match instrument_track(app.engine.session()) {
@@ -322,9 +369,11 @@ fn instrument_track(s: &soundcraft_model::Session) -> Option<soundcraft_model::T
fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str, &str)]) {
if !n.children.is_empty() {
ui.menu_button(&n.label, |ui| {
for c in &n.children {
menu_node(app, ui, c, extra);
}
scrolling(ui, |ui| {
for c in &n.children {
menu_node(app, ui, c, extra);
}
});
});
return;
}
@@ -334,7 +383,9 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
let label = if checked { format!("✔ {label}") } else { label };
let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
if ui.add_enabled(enabled, btn).clicked()
let response = ui.add_enabled(enabled, btn);
let response = if catalog::is_placeholder(&n.path) { response.on_disabled_hover_text("Not available yet.") } else { response };
if response.clicked()
&& let Some(id) = id
{
invoke_menu(app, &id, &n.path);
@@ -349,7 +400,7 @@ pub fn command_for_path(path: &str, extra: &[(&str, &str)]) -> Option<String> {
/// Enabled and checked state shared by all menu presentations.
pub fn item_state(app: &SoundApp, path: &str, id: Option<&str>) -> (bool, bool) {
let enabled = if app.ui.show_preferences {
let enabled = if app.ui.show_preferences || catalog::is_placeholder(path) {
false
} else {
match id {
@@ -902,4 +953,184 @@ mod tests {
let info = soundcraft_dsp::plugin_info("ring_mod").unwrap();
assert!(info.audiosuite && info.category == soundcraft_dsp::Category::Modulation);
}
#[test]
fn offline_menu_uses_own_names_and_keeps_legacy_dispatch() {
for (path, process) in AUDIOSUITE {
assert_eq!(command_for_path(path, &ui_aliases()).as_deref(), Some("audiosuite.process"));
assert_eq!(params_for(path, "audiosuite.process")["process"], *process);
if DEFERRED_LABELS.contains(path) {
continue;
}
if let Some(info) = soundcraft_dsp::plugin_info(process) {
assert_eq!(display_menu_label(path, "ignored"), info.name);
}
}
let tree = tree();
let offline = tree.iter().find(|node| node.path == "AudioSuite").unwrap();
assert_eq!(offline.label, "AudioSuite");
let dynamics = offline.children.iter().find(|node| node.path == "AudioSuite > Dynamics").unwrap();
assert_eq!(dynamics.children.iter().filter(|node| node.label == "Compressor/Limiter").count(), 1);
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let command = command_for_path("Event > Beat Detective", &ui_aliases()).unwrap();
invoke_menu(&mut app, &command, "Event > Beat Detective");
assert!(app.ui.show_beat_detective);
for (path, expected) in [
("AudioSuite > Pitch Shift > Vari-Fi", "Speed Ramp"),
("AudioSuite > Harmonic > Lo-Fi", "Bit Crusher"),
("Track > ARA Plugins > Melodyne", "No ARA plug-ins"),
("Options > Activate HEAT", "Activate Analog Saturation"),
] {
assert_eq!(display_menu_label(path, path.rsplit(" > ").next().unwrap()), expected);
}
}
/// Leaves deferred to RFC-0029 keep the catalog's own name in the menu, next to the processor
/// that implements them: the rename waits, and no catalog item is dropped in the meantime.
#[test]
fn deferred_leaves_keep_their_catalog_names() {
fn labels<'a>(nodes: &'a [MenuNode], path: &str) -> Vec<&'a str> {
for node in nodes {
if node.path == path {
return node.children.iter().map(|child| child.label.as_str()).collect();
}
let found = labels(&node.children, path);
if !found.is_empty() {
return found;
}
}
Vec::new()
}
let tree = tree();
let pitch_shift = labels(&tree, "AudioSuite > Pitch Shift");
assert!(pitch_shift.contains(&"Pitch Shift"), "{pitch_shift:?}");
assert!(pitch_shift.contains(&"Pitch Shift Legacy"), "{pitch_shift:?}");
assert!(pitch_shift.contains(&"Time Stretch"), "the Time Shift item keeps our processor name: {pitch_shift:?}");
let other = labels(&tree, "AudioSuite > Other");
assert!(other.contains(&"Time Compression Expansion"), "{other:?}");
for path in DEFERRED_LABELS {
assert!(AUDIOSUITE.iter().any(|(key, _)| key == path), "{path}");
assert_eq!(display_menu_label(path, "kept"), "kept", "{path}");
}
}
fn retired_feature_label(label: &str) -> Option<&'static str> {
let lower = label.to_lowercase();
[
"beat detective",
"x-form",
"quickpunch",
"ableton link",
"pro tools search",
"pro tools projects",
"melodyne",
"heat",
"eq3",
"bf-76",
"dyn3",
"maxim",
"pitch ii",
"vari-fi",
"d-verb",
"mod delay iii",
"sci-fi",
"eleven lite",
"lo-fi",
"recti-fi",
"sansamp psa-1",
]
.into_iter()
// Whole labels or words: Maximizer is our processor, not the retired Maxim name.
.find(|retired| {
lower.match_indices(retired).any(|(start, token)| {
let before = lower[..start].chars().next_back();
let after = lower[start + token.len()..].chars().next();
before.is_none_or(|c| !c.is_alphanumeric()) && after.is_none_or(|c| !c.is_alphanumeric())
})
})
}
#[test]
fn retired_labels_stay_out_of_rendered_menus_and_public_registries() {
fn check(nodes: &[MenuNode]) {
for node in nodes {
assert_eq!(retired_feature_label(&node.label), None, "{}: {}", node.path, node.label);
check(&node.children);
}
}
check(&tree());
for (_, label, _, _) in UI_COMMANDS {
assert_eq!(retired_feature_label(label), None, "UI command: {label}");
}
for command in soundcraft_engine::cmd::command_specs() {
assert_eq!(retired_feature_label(command.label), None, "{}: {}", command.id, command.label);
}
for plugin in soundcraft_dsp::plugins() {
assert_eq!(retired_feature_label(plugin.name), None, "{}: {}", plugin.id, plugin.name);
assert_eq!(retired_feature_label(plugin.short_name), None, "{}: {}", plugin.id, plugin.short_name);
}
// The guard only checks built-in display values. Paths, processor ids and user text stay unchanged.
assert!(AUDIOSUITE.iter().any(|(path, _)| *path == "AudioSuite > Harmonic > Lo-Fi"));
assert!(soundcraft_dsp::plugin_info("lofi").is_some());
assert_eq!(soundcraft_engine::feature_names::display_label("A future descriptive feature"), "A future descriptive feature");
}
/// A placeholder keeps the incumbent's catalog place with an honest label, and is never
/// interactive: `item_state` refuses it however enabled its command would be.
#[test]
fn feature_placeholders_stay_disabled_with_honest_labels() {
fn leaf<'a>(nodes: &'a [MenuNode], path: &str) -> Option<&'a MenuNode> {
for node in nodes {
if node.path == path && node.children.is_empty() {
return Some(node);
}
if let Some(found) = leaf(&node.children, path) {
return Some(found);
}
}
None
}
let app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let tree = tree();
for (path, label) in [
("View > Transport > Ableton Link", "Tempo Sync"),
("Track > ARA Plugins > Melodyne", "No ARA plug-ins"),
("Clip > ARA Plugins > Melodyne", "No ARA plug-ins"),
("View > Mix Window Views > HEAT", "Analog Saturation"),
("Options > Activate HEAT", "Activate Analog Saturation"),
] {
assert!(catalog::is_placeholder(path), "{path} is not listed as a placeholder");
let node = leaf(&tree, path).unwrap_or_else(|| panic!("placeholder missing from the menu: {path}"));
assert_eq!(node.label, label, "{path}");
let id = command_for_path(path, &ui_aliases());
assert!(!item_state(&app, path, id.as_deref()).0, "{path} must stay disabled");
}
// The flag command behind Tempo Sync is still registered (ids stay), only its menu item is a
// placeholder: nothing claims a synchronization protocol we do not implement.
assert!(soundcraft_engine::find_command("view.transport_ableton_link").is_some());
}
#[test]
fn display_guard_rejects_retired_labels_without_rejecting_own_processors() {
for label in ["Beat Detective", "QuickPunch", "Activate HEAT", "BF-76", "Dyn3 De-Esser", "Lo-Fi"] {
assert!(retired_feature_label(label).is_some(), "{label}");
}
// Deferred to RFC-0029: these keep the incumbent's label, so the guard must allow them.
for label in [
"Offline Processing",
"AudioSuite",
"Beat Analysis",
"Elastic Audio",
"Elastic Properties",
"TrackPunch",
"Pitch Shift Legacy",
"Time Compression Expansion",
"Rendered (High Quality)",
"Maximizer",
"Bit Crusher",
"Room Reverb",
] {
assert_eq!(retired_feature_label(label), None, "{label}");
}
}
}
+30 -19
View File
@@ -25,34 +25,45 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
}
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8 (inputs).
/// Input or output menu: no route and buses on both, plus the main output (outputs) or inputs 1-8
/// (inputs). Its search field takes typing at once and finds any of them, not just the buses.
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
let key = if input { "input" } else { "output" };
let cmd = if input { "track.input" } else { "track.output" };
if ui.button(if input { "no input" } else { "no output" }).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
ui.close();
}
let hint = if input { "Search inputs…" } else { "Search outputs…" };
let search = MenuSearch::show(ui, egui::Id::unique(("route-search", id.0, input)), hint);
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
if !input && ui.button(main_name).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
ui.close();
}
// (label, the route the command is given)
let mut fixed = vec![(if input { "no input" } else { "no output" }.to_string(), "none".to_string())];
if input {
for i in 1..=8 {
if ui.button(format!("In {i}")).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
ui.close();
}
fixed.extend((1..=8).map(|i| (format!("In {i}"), format!("In {i}"))));
} else {
fixed.push((main_name, "main".to_string()));
}
if search.typing() {
let visible: Vec<(&String, &String)> =
fixed.iter().map(|(l, v)| (l, v)).chain(busses.iter().map(|b| (b, b))).filter(|(l, _)| search.matches(l)).collect();
let picked = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| visible.get(i).is_some_and(|(l, _)| menu_row(ui, highlighted, l)));
if let Some((_, value)) = picked.and_then(|i| visible.get(i)) {
let _ = app.run(cmd, json!({"track": id.0, key: value}));
ui.close();
}
return;
}
for (label, value) in &fixed {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: value}));
ui.close();
}
}
ui.separator();
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
ui.strong("Bus");
let search = MenuSearch::show(ui, egui::Id::unique(("route-bus-search", id.0, input)), "Search buses…");
let visible: Vec<&String> = busses.iter().filter(|b| search.matches(b)).collect();
if let Some(i) = search.rows(ui, visible.len(), 420.0, |ui, i, highlighted| menu_row(ui, highlighted, visible[i])) {
let _ = app.run(cmd, json!({"track": id.0, key: visible[i]}));
if let Some(bus) = search
.rows(ui, busses.len(), 420.0, |ui, i, highlighted| busses.get(i).is_some_and(|b| menu_row(ui, highlighted, b)))
.and_then(|i| busses.get(i))
{
let _ = app.run(cmd, json!({"track": id.0, key: bus}));
ui.close();
}
if ui.button("new bus...").clicked() {
+47 -7
View File
@@ -265,20 +265,42 @@ fn draw_roll_background(painter: &egui::Painter, g: &Roll<'_>) {
}
}
// Grid.
let step = grid_stride(grid_ticks, px_per_tick);
let mut tick = 0;
while tick <= len_ticks {
let bar = tick % (TICKS_PER_QUARTER * 4) == 0;
let x = g.x_of(tick);
if grid_ticks as f32 * px_per_tick > 4.0 || bar {
painter.line_segment(
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
);
}
tick += grid_ticks;
painter.line_segment(
[pos2(x, roll.min.y), pos2(x, roll.max.y)],
Stroke::new(1.0, if bar { Color32::from_rgb(80, 80, 86) } else { Color32::from_rgb(50, 50, 54) }),
);
tick = tick.saturating_add(step);
}
}
/// Closest lines the grid draws, in points.
const MIN_GRID_PX: f32 = 4.0;
/// Ticks between grid lines: the grid when its lines are more than [`MIN_GRID_PX`] apart, else
/// as few common multiples of the grid and a bar as keep that spacing (so every line is a snap
/// position and falls on a bar). The lines drawn are then bounded by the roll's width, however long
/// the clip, instead of by its length.
fn grid_stride(grid_ticks: i64, px_per_tick: f32) -> i64 {
let bar_ticks = TICKS_PER_QUARTER * 4;
if grid_ticks as f32 * px_per_tick > MIN_GRID_PX {
return grid_ticks.max(1);
}
let grid = grid_ticks.max(1);
let (mut a, mut b) = (grid, bar_ticks);
while b != 0 {
(a, b) = (b, a % b);
}
let common = (grid / a).saturating_mul(bar_ticks);
// `as` saturates a huge or NaN quotient; a zero `px_per_tick` gives one common multiple.
let n = (MIN_GRID_PX / (common as f32 * px_per_tick)).ceil().max(1.0) as i64;
common.saturating_mul(n)
}
/// The notes, with a drag in progress applied.
fn draw_notes(app: &SoundApp, painter: &egui::Painter, g: &Roll<'_>, base: Color32) {
let Roll { sequence, grid_ticks, .. } = *g;
@@ -478,6 +500,24 @@ fn velocity_lane(app: &mut SoundApp, ui: &mut Ui, g: &Roll<'_>, cid: ClipId, bas
mod tests {
use super::*;
#[test]
fn grid_lines_are_bounded_by_the_roll_width_not_the_clip_length() {
let sixteenth = TICKS_PER_QUARTER / 4;
// A 1000 pt roll showing a 4-bar clip: every sixteenth is 16 pt apart and drawn.
assert_eq!(grid_stride(sixteenth, 1000.0 / (16.0 * TICKS_PER_QUARTER as f32)), sixteenth);
// The same roll over a clip a billion ticks long: at most a line every 4 pt.
let (len, px_per_tick) = (1_000_000_000i64, 1000.0 / 1_000_000_000.0f32);
let step = grid_stride(sixteenth, px_per_tick);
assert_eq!(step % (TICKS_PER_QUARTER * 4), 0, "lines fall on bars");
assert!((len / step) <= 251, "{} lines", len / step);
// a dotted quarter grid (1440 ticks): every line is also a grid position
let dotted = TICKS_PER_QUARTER * 3 / 2;
let step = grid_stride(dotted, px_per_tick);
assert_eq!((step % dotted, step % (TICKS_PER_QUARTER * 4)), (0, 0), "lines are snap positions on bars: {step}");
assert!((len / step) <= 251, "{} lines", len / step);
assert!(grid_stride(0, 0.0) >= TICKS_PER_QUARTER * 4, "a zero grid or width still advances");
}
#[test]
fn switching_clip_drops_selection_and_gestures() {
let mut st = MidiEditorState { selected: vec![0, 2], clip: Some(ClipId(1)), band: Some(pos2(1.0, 2.0)), ..Default::default() };
+35 -4
View File
@@ -115,6 +115,22 @@ fn first_movie_aspect(s: &soundcraft_model::Session) -> Option<f32> {
s.videos.first().map(|v| v.aspect() as f32)
}
/// Most thumbnails one clip draws in the Video Universe strip: 8 pt each, so more than the
/// widest strip holds.
const MAX_UNIVERSE_THUMBS: usize = 1 << 12;
/// Left edges of the thumbnails of a clip starting at `clip_x` (each `tw` wide) that lie in
/// `shown_min..shown_max`. The index and position are worked out in `f64`: an `f32` this far from
/// the origin (a clip starting a billion points left) cannot step by a thumbnail's width.
fn thumb_xs(clip_x: f32, shown_min: f32, shown_max: f32, tw: f32) -> impl Iterator<Item = f32> {
let (origin, tw) = (f64::from(clip_x), f64::from(tw));
// Nothing of the clip is in view when the shown span is empty (or inverted).
let empty = shown_max <= shown_min;
let first = ((f64::from(shown_min) - origin) / tw).floor().max(0.0);
let count = if empty { 0 } else { ((f64::from(shown_max) - origin) / tw - first).ceil().max(0.0) as usize };
(0..count.min(MAX_UNIVERSE_THUMBS)).map(move |i| (origin + (first + i as f64) * tw) as f32)
}
fn universe(app: &mut SoundApp, ctx: &egui::Context) {
app.video.poll(ctx);
let s = app.engine.session_arc();
@@ -145,8 +161,10 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
}
let tw = (cr.height() * v.aspect() as f32).max(8.0);
let hpx = ((cr.height() * ppp / 16.0).ceil() * 16.0).clamp(16.0, 256.0) as u32;
let mut x = cr.min.x;
while x < cr.max.x {
// Only the thumbnails inside the strip, counted rather than stepped: a clip starting
// far left of it has an edge so large that adding a thumbnail's width wouldn't move it.
let shown = cr.intersect(r);
for x in thumb_xs(cr.min.x, shown.min.x, shown.max.x, tw) {
let secs = movie_secs(&s, c.start, offset, strip_position(x, r.min.x, r.width(), end, c.start)).max(0.0);
if let Some(tex) = app.video.thumb(ctx, key, &v.path, secs, hpx) {
let tr = Rect::from_min_size(pos2(x, cr.min.y), vec2(tw, cr.height()));
@@ -154,7 +172,6 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((vis.width() / tw).clamp(0.0, 1.0), 1.0));
painter.image(tex.id(), vis, uv, Color32::WHITE);
}
x += tw;
}
}
// The Edit window's visible range and the playhead.
@@ -182,7 +199,21 @@ fn universe(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(test)]
mod tests {
use super::strip_position;
use super::{strip_position, thumb_xs};
#[test]
fn thumbnails_of_a_clip_far_left_of_the_strip_are_evenly_spaced() {
// 2^30 points left of the strip with 8 pt thumbnails: f32 steps of `first + i` here are 16 apart
let xs: Vec<f32> = thumb_xs(-1_073_741_824.0, 0.0, 80.0, 8.0).collect();
assert_eq!(xs.len(), 10);
assert!(xs.windows(2).all(|w| w[1] - w[0] == 8.0), "{xs:?}");
assert_eq!(xs[0], 0.0);
// a clip inside the strip starts at its own edge
assert_eq!(thumb_xs(10.0, 10.0, 34.0, 8.0).collect::<Vec<_>>(), vec![10.0, 18.0, 26.0]);
// a clip ending left of the strip (the shown span is empty or inverted) draws none
assert_eq!(thumb_xs(-10.0, 0.0, -1.0, 8.0).count(), 0);
assert_eq!(thumb_xs(-10.0, 0.0, 0.0, 8.0).count(), 0);
}
// A 1024 px strip over 1_024_000 samples: 1000 samples per point, exact in f32.
const LEFT: f32 = 100.0;
+157 -33
View File
@@ -54,8 +54,13 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
mac && m.command && m.ctrl
}
/// Commands a host auto-repeat runs again, so holding a zoom key keeps zooming (#1377). Every other
/// shortcut ignores auto-repeats (#1063): holding Cmd+Z undoes once. Decided by the command a chord
/// resolves to, not by its keys.
const REPEATING: [&str; 3] = ["view.zoom_in", "view.zoom_out", "view.waveform_zoom"];
/// Dispatch global keyboard shortcuts once no text field or dialog wants input, ignoring non-scripted auto-repeats
/// while still running a host press alongside a scripted one.
/// (except the [`REPEATING`] zoom commands) while still running a host press alongside a scripted one.
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
@@ -64,29 +69,29 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
}
// A scripted press is identified by its exact position in this pass's events, not by matching
// key/modifier values. A fresh host press with the same chord must still run its own action in
// addition to the scripted one, while a host auto-repeat stays ignored: egui rewrites an
// event's `repeat` flag from the physical key state, which says nothing about whether that
// particular event was injected. Every non-scripted repeat is ignored as before.
// addition to the scripted one, while a host auto-repeat runs only a zoom command: egui rewrites
// an event's `repeat` flag from the physical key state, which says nothing about whether that
// particular event was injected.
let scripted = app.scripted_key_press_indices();
let events: Vec<(Key, Modifiers)> = ctx.input(|i| {
let events: Vec<(Key, Modifiers, bool)> = ctx.input(|i| {
i.events
.iter()
.enumerate()
.filter_map(|(index, e)| match e {
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => {
if scripted.contains(&index) {
Some((*key, *modifiers))
} else {
(!*repeat).then_some((*key, *modifiers))
}
}
egui::Event::Key { key, pressed: true, repeat, modifiers, .. } => Some((*key, *modifiers, *repeat && !scripted.contains(&index))),
_ => None,
})
.collect()
});
// Cmd and Ctrl are separate keys only on macOS (asked at runtime so the web build in a Mac browser agrees).
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
for (key, mods) in events {
for (key, mods, repeat) in events {
if repeat {
if let Some((id, params)) = repeating_command(key, mods, mac) {
let _ = app.run(id, params);
}
continue;
}
if fixed(app, key, mods, mac) {
continue;
}
@@ -105,6 +110,14 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
}
/// The command a host auto-repeat of this chord runs, if it is one of [`REPEATING`]. A repeat
/// resolves its chord the way a press falls through to these zooms ([`zoom_key`], then the
/// registry) without the earlier [`fixed`] / [`fixed_more`] arms, so an arm added there for one of
/// these chords must keep the two paths in agreement.
fn repeating_command(key: Key, mods: Modifiers, mac: bool) -> Option<(&'static str, serde_json::Value)> {
zoom_key(key, mods, mac).or_else(|| command_for(key, mods, mac).map(|id| (id, json!({})))).filter(|(id, _)| REPEATING.contains(id))
}
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
soundcraft_engine::command_specs()
.iter()
@@ -166,14 +179,13 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
true
}
Key::F5 | Key::F6 | Key::F7 | Key::F8 | Key::F9 | Key::F10 if plain => {
let tool = match key {
Key::F5 => "zoom",
Key::F6 => "trim",
Key::F7 => "selector",
Key::F8 => "grabber",
Key::F9 => "scrubber",
_ => "pencil",
};
let _ = app.run("edit.tool", json!({"tool": tool_key(app, key)}));
true
}
// Cmd+6 / Cmd+7 (Ctrl off macOS) are Pencil and the Smart Tool. Cmd+1–5 keep their window and Session
// keys: egui reports the numeric keypad's digits as the main keyboard's, so the two can't be told apart.
Key::Num6 | Key::Num7 if mods_match(Modifiers::COMMAND, m, mac) => {
let tool = if key == Key::Num6 { "pencil" } else { "smart" };
let _ = app.run("edit.tool", json!({"tool": tool}));
true
}
@@ -199,12 +211,7 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("edit.tab", json!({"back": m.alt || (!mac && m.ctrl), "extend": m.shift}));
true
}
// The registry's key families "Cmd+Alt+[ / ]", "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read.
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
let factor = if key == Key::CloseBracket { 2.0 } else { 0.5 };
let _ = app.run("view.waveform_zoom", json!({"factor": factor}));
true
}
// The registry's key families "Cmd+Alt+0-5" and "Cmd+Ctrl+1-5", which `parse` can't read ("Cmd+Alt+[ / ]" is in `zoom_key`).
Key::Num0 | Key::Num1 | Key::Num2 | Key::Num3 | Key::Num4 | Key::Num5 if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
let _ = app.run("clip.rating", json!({"rating": digit(key)}));
true
@@ -232,6 +239,11 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run(id, json!({}));
true
}
// With an edit selection (a selected clip makes one), Left/Right centre the Edit window on its start/end.
// Over the piano roll the arrows move its notes instead.
Key::ArrowLeft | Key::ArrowRight if plain && app.ui.window == MainWindow::Edit && !piano_roll_has_pointer(app) => {
centre_on_selection(app, key == Key::ArrowRight)
}
// Commands Keyboard Focus: single-key editing (Edit window only).
k if !m.command
&& !m.alt
@@ -242,13 +254,26 @@ fn fixed_more(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
{
true
}
Key::R | Key::T if plain => {
// Zoom out / in on the timeline (Commands Focus style).
let id = if key == Key::T { "view.zoom_in" } else { "view.zoom_out" };
let _ = app.run(id, json!({}));
true
_ => match zoom_key(key, m, mac) {
Some((id, params)) => {
let _ = app.run(id, params);
true
}
None => false,
},
}
}
/// The zoom keys [`fixed`] handles, shared with [`repeating_command`]: T / R zoom the timeline in / out
/// (Commands Focus style) and the registry's "Cmd+Alt+[ / ]", which `parse` can't read, steps the waveform zoom.
fn zoom_key(key: Key, m: Modifiers, mac: bool) -> Option<(&'static str, serde_json::Value)> {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
match key {
Key::R | Key::T if plain => Some((if key == Key::T { "view.zoom_in" } else { "view.zoom_out" }, json!({}))),
Key::OpenBracket | Key::CloseBracket if mods_match(Modifiers::COMMAND | Modifiers::ALT, m, mac) => {
Some(("view.waveform_zoom", json!({"factor": if key == Key::CloseBracket { 2.0 } else { 0.5 }})))
}
_ => false,
_ => None,
}
}
@@ -288,6 +313,22 @@ fn edit_mode_key(app: &mut SoundApp, pressed: Key) {
let _ = app.run("edit.mode", json!({"mode": mode, "snap_to_grid": snap}));
}
/// F5–F10 choose the Edit tools; F7 with F6 or F8 held, in either order, chooses the Smart Tool.
fn tool_key(app: &SoundApp, pressed: Key) -> &'static str {
let ctx = app.docking_context.as_ref();
let held = |k: Key| ctx.is_some_and(|c| c.input(|i| i.key_down(k)));
match pressed {
Key::F6 | Key::F8 if held(Key::F7) => "smart",
Key::F7 if held(Key::F6) || held(Key::F8) => "smart",
Key::F5 => "zoom",
Key::F6 => "trim",
Key::F7 => "selector",
Key::F8 => "grabber",
Key::F9 => "scrubber",
_ => "pencil",
}
}
/// What choosing Grid picks: in Grid the other Grid mode, from another mode the one used last.
pub(crate) fn grid_mode(app: &SoundApp) -> &'static str {
use soundcraft_model::EditMode;
@@ -393,6 +434,30 @@ fn tab_clip(app: &mut SoundApp, back: bool) {
}
}
/// Scroll the Edit window so the edit selection's start (or `end`) is in its middle. Returns false, leaving
/// the key unhandled, when there is only an insertion point.
fn centre_on_selection(app: &mut SoundApp, end: bool) -> bool {
let s = app.engine.session();
let sel = s.edit.selection;
if sel.start >= sel.end {
return false;
}
let [x0, _, x1, _] = app.edit_layout.timeline;
let half = f64::from((x1 - x0).max(0.0)) / 2.0 * s.edit.zoom.samples_per_px.max(0.01);
let at = if end { sel.end } else { sel.start };
let _ = app.run("view.scroll", json!({"to": at.saturating_sub(soundcraft_time::to_samples(half)).max(0)}));
true
}
/// Whether the pointer is over the MIDI editor, whose piano roll moves the selected notes with the arrow keys.
fn piano_roll_has_pointer(app: &SoundApp) -> bool {
app.ui.show_midi_editor
&& app.docking_context.as_ref().is_some_and(|c| {
let body = c.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body")));
c.pointer_hover_pos().zip(body).is_some_and(|(p, r)| r.contains(p))
})
}
fn digit(key: Key) -> u8 {
key.name().parse().unwrap_or(0)
}
@@ -480,4 +545,63 @@ mod tests {
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
assert!(crate::dialogs::takes_path("file.export_clips"));
}
fn app_with_selection() -> crate::SoundApp {
let mut app = crate::SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
app.ui.window = crate::MainWindow::Edit;
app.edit_layout.timeline = [0.0, 0.0, 100.0, 100.0];
app.run("view.zoom_set", serde_json::json!({"samples_per_px": 10.0})).unwrap();
app.run("edit.select", serde_json::json!({"start": 2000, "end": 4000})).unwrap();
app
}
#[test]
fn plain_arrows_centre_selection_but_modified_arrows_do_not() {
for mac in [false, true] {
for (key, expected) in [(Key::ArrowLeft, 1500), (Key::ArrowRight, 3500)] {
let mut app = app_with_selection();
assert!(super::fixed_more(&mut app, key, Modifiers::NONE, mac));
assert_eq!(app.engine.session().edit.zoom.scroll, expected);
for modifiers in [Modifiers::SHIFT, Modifiers::ALT, Modifiers::CTRL, Modifiers::COMMAND, PC_CTRL, MAC_CMD] {
app.run("view.scroll", serde_json::json!({"to": 123})).unwrap();
assert!(!super::fixed_more(&mut app, key, modifiers, mac));
assert_eq!(app.engine.session().edit.zoom.scroll, 123, "{key:?} {modifiers:?}");
}
}
}
}
#[test]
fn selection_arrows_do_not_scroll_the_mix_window_or_an_insertion_point() {
for key in [Key::ArrowLeft, Key::ArrowRight] {
let mut app = app_with_selection();
app.ui.window = crate::MainWindow::Mix;
assert!(!super::fixed_more(&mut app, key, Modifiers::NONE, false));
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
app.ui.window = crate::MainWindow::Edit;
app.run("edit.select", serde_json::json!({"start": 2000, "end": 2000})).unwrap();
assert!(!super::fixed_more(&mut app, key, Modifiers::NONE, false));
assert_eq!(app.engine.session().edit.zoom.scroll, 0);
}
}
#[test]
fn selection_arrows_leave_the_piano_roll_in_control_under_its_pointer() {
let ctx = egui::Context::default();
let mut app = app_with_selection();
app.ui.show_midi_editor = true;
app.docking_context = Some(ctx.clone());
let body = egui::Rect::from_min_max(egui::pos2(10.0, 10.0), egui::pos2(100.0, 100.0));
ctx.data_mut(|d| d.insert_temp(egui::Id::unique("soundcraft-midi-body"), body));
for (pointer, handled) in [(body.center(), false), (egui::pos2(200.0, 200.0), true)] {
let mut output = ctx.run_ui(egui::RawInput { events: vec![egui::Event::PointerMoved(pointer)], ..Default::default() }, |_| {
for key in [Key::ArrowLeft, Key::ArrowRight] {
app.run("view.scroll", serde_json::json!({"to": 0})).unwrap();
assert_eq!(super::fixed_more(&mut app, key, Modifiers::NONE, false), handled);
assert_eq!(app.engine.session().edit.zoom.scroll > 0, handled);
}
});
output.textures_delta.clear();
}
}
}
+72
View File
@@ -840,6 +840,78 @@ fn a_held_key_does_not_turn_a_scripted_press_into_a_repeat() {
assert!(ctx.input(|i| i.key_down(egui::Key::Z)), "the host still holds Z after the repeat");
}
/// Runs one input pass of `app` carrying host key `events` (no scripted input).
fn key_pass(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0));
let mut input = egui::RawInput { screen_rect: Some(rect), events, ..Default::default() };
app.raw_input_hook(ctx, &mut input);
let mut out = ctx.run_ui(input, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
/// The demo app after a few idle passes, ready for keyboard input.
fn app_for_keys() -> (egui::Context, SoundApp) {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
for _ in 0..4 {
key_pass(&ctx, &mut app, Vec::new());
}
(ctx, app)
}
/// A host press of `key` and then two host auto-repeats of it, each in its own pass, as holding the key gives.
fn hold_key(ctx: &egui::Context, app: &mut SoundApp, key: egui::Key, modifiers: egui::Modifiers) {
for repeat in [false, true, true] {
key_pass(ctx, app, vec![egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat, modifiers }]);
}
key_pass(ctx, app, vec![egui::Event::Key { key, physical_key: Some(key), pressed: false, repeat: false, modifiers }]);
}
/// #1377: holding a zoom key keeps zooming, one step per auto-repeat.
#[test]
fn holding_t_or_r_keeps_zooming_the_timeline() {
let (ctx, mut app) = app_for_keys();
let start = app.engine.session().edit.zoom.samples_per_px;
hold_key(&ctx, &mut app, egui::Key::T, egui::Modifiers::NONE);
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start / 8.0, "T and two repeats zoom in three steps");
hold_key(&ctx, &mut app, egui::Key::R, egui::Modifiers::NONE);
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start, "R and two repeats zoom back out three steps");
}
/// #1377: the registry's Cmd+[ / Cmd+] and the Cmd+Alt+[ / ] waveform zoom repeat too.
#[test]
fn holding_a_zoom_chord_keeps_zooming() {
let (ctx, mut app) = app_for_keys();
let cmd = egui::Modifiers { command: true, ..egui::Modifiers::NONE };
let start = app.engine.session().edit.zoom.samples_per_px;
hold_key(&ctx, &mut app, egui::Key::OpenBracket, cmd);
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start * 8.0, "Cmd+[ and two repeats zoom out three steps");
hold_key(&ctx, &mut app, egui::Key::CloseBracket, cmd);
assert_eq!(app.engine.session().edit.zoom.samples_per_px, start, "Cmd+] and two repeats zoom in three steps");
let waveform = app.engine.session().edit.zoom.waveform_zoom;
hold_key(&ctx, &mut app, egui::Key::CloseBracket, egui::Modifiers { alt: true, ..cmd });
assert_eq!(app.engine.session().edit.zoom.waveform_zoom, waveform * 8.0, "Cmd+Alt+] and two repeats raise the waveform three steps");
}
/// Only the zoom commands repeat: a held registry chord (Cmd+Z) still undoes once and a held
/// `fixed` key (Enter) still adds one marker.
#[test]
fn holding_cmd_z_or_enter_still_runs_once() {
let (ctx, mut app) = app_for_keys();
for _ in 0..3 {
app.run("track.new", json!({"count": 1, "format": "stereo"})).unwrap();
}
let depth = app.engine.undo_history().len();
hold_key(&ctx, &mut app, egui::Key::Z, egui::Modifiers { command: true, ..egui::Modifiers::NONE });
assert_eq!(app.engine.undo_history().len(), depth - 1, "auto-repeats of Cmd+Z are ignored");
let markers = app.engine.session().markers.len();
hold_key(&ctx, &mut app, egui::Key::Enter, egui::Modifiers::NONE);
assert_eq!(app.engine.session().markers.len(), markers + 1, "auto-repeats of Enter are ignored");
}
/// A fresh physical Cmd+Z and an identical scripted Cmd+Z in one frame are distinct actions.
/// Matching them by value would swallow the scripted press; provenance keeps both.
#[test]
+18 -8
View File
@@ -237,7 +237,7 @@ fn fmt_pos(app: &SoundApp, at: i64, f: TimeFormat) -> String {
s.format_position(at, f)
}
/// Main and sub position counters, a main-counter format menu, selection readouts and dirty/device status.
/// Main and sub position counters with their format menus, selection readouts and dirty/device status.
fn counters(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
let (main_fmt, sub_fmt, sel) = {
@@ -252,13 +252,7 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().text(pos2(main_r.max.x - 18.0, main_r.center().y), Align2::RIGHT_CENTER, txt, mono(26.0), t.counter_text);
let resp = ui.interact(main_r, ui.scope_id().with("main_counter"), Sense::click());
icons::draw(ui.painter(), Rect::from_center_size(pos2(main_r.max.x - 9.0, main_r.center().y), vec2(10.0, 10.0)), "triangle_down", t.text_dim);
egui::Popup::menu(&resp).show(|ui| {
for f in TimeFormat::ALL {
if ui.selectable_label(main_fmt == f, f.label()).clicked() {
let _ = app.run("view.main_counter", json!({"format": f.id()}));
}
}
});
format_menu(app, &resp, main_fmt, "view.main_counter");
// Start / End / Length.
let s = app.engine.session();
let rows = [
@@ -276,11 +270,27 @@ fn counters(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().line_segment([pos2(r.min.x + 6.0, r.max.y - 21.0), pos2(r.max.x - 6.0, r.max.y - 21.0)], Stroke::new(1.0, t.counter_rule));
ui.painter().text(pos2(r.min.x + 10.0, y), Align2::LEFT_CENTER, "Sub", regular(11.0), t.counter_label);
ui.painter().text(pos2(r.min.x + 150.0, y), Align2::RIGHT_CENTER, fmt_pos(app, pos, sub_fmt), mono(11.0), t.counter_text);
let sub_r = Rect::from_min_max(pos2(r.min.x + 6.0, r.max.y - 20.0), pos2(r.min.x + 164.0, r.max.y - 2.0));
let sub_resp = ui.interact(sub_r, ui.scope_id().with("sub_counter"), Sense::click());
icons::draw(ui.painter(), Rect::from_center_size(pos2(r.min.x + 158.0, y), vec2(8.0, 8.0)), "triangle_down", t.text_dim);
format_menu(app, &sub_resp, sub_fmt, "view.sub_counter");
let st = if app.engine.is_dirty() { "modified" } else { "saved" };
let dev = app.player.as_ref().map_or("no audio engine", |p| if p.silent { "silent clock" } else { "audio on" });
ui.painter().text(pos2(r.max.x - 8.0, y), Align2::RIGHT_CENTER, format!("{st} · {dev}"), regular(10.0), t.text_dim);
}
/// A counter's time-format menu: picking a format runs `command` (`view.main_counter` or
/// `view.sub_counter`) with it.
fn format_menu(app: &mut SoundApp, resp: &egui::Response, current: TimeFormat, command: &str) {
egui::Popup::menu(resp).show(|ui| {
for f in TimeFormat::ALL {
if ui.selectable_label(current == f, f.label()).clicked() {
let _ = app.run(command, json!({"format": f.id()}));
}
}
});
}
/// Draws the compact Grid and Nudge readouts and their scale menus.
fn grid_nudge(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
+79 -7
View File
@@ -43,6 +43,19 @@ impl SoundApp {
pub fn set_startup_update_check(&mut self, on: bool) {
self.updates_rt.startup_check = on;
}
/// Starts the helper of an update that waits for the app to close. Call it when the exit is certain.
#[cfg(not(target_arch = "wasm32"))]
pub fn start_pending_update(&mut self) {
self.updates_rt.updater.start_pending_update();
}
}
/// Whether the result dialog shows: while the updater reports something (a requested check, or what the last update
/// came to, which the check at start reads), and not while the startup prompt is open.
#[cfg(not(target_arch = "wasm32"))]
fn shows_result(status: &Status, prompt_open: bool) -> bool {
!matches!(status, Status::Idle) && !prompt_open
}
pub(crate) fn check(app: &mut SoundApp) {
@@ -62,7 +75,6 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
rt.manual_window_open = true;
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
@@ -82,20 +94,19 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it.
// Help ▸ Check for Updates, and the first start after an update, show their outcome in the shared result dialog
// until the user closes it. A found update goes to the startup prompt, which takes over from the dialog.
let rt = &mut app.updates_rt;
rt.manual_window_open = shows_result(rt.updater.status(), rt.updater.prompt_open());
if rt.manual_window_open {
match rt.result_dialog.show(ctx, rt.updater.status()) {
CheckResultResponse::Close => rt.manual_window_open = false,
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
CheckResultResponse::None => {}
}
// A found update goes to the startup prompt, which takes over from the dialog.
if matches!(rt.updater.status(), Status::Idle) {
rt.manual_window_open = false;
}
}
// Closing the dialog dismisses the outcome it showed.
// Closing the dialog, or the prompt taking over, dismisses the outcome it showed.
if !rt.manual_window_open {
rt.updater.dismiss_status();
}
@@ -115,3 +126,64 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
app.updates_rt.manual_window_open = open;
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::Services;
use craft_update::Paths;
use std::sync::Arc;
/// One pass of the update check and its windows, with `events` as the frame's input, dropping texture deltas the
/// tests do not inspect.
fn frame(app: &mut SoundApp, ctx: &egui::Context, events: Vec<egui::Event>) {
let raw = egui::RawInput { events, ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| {
tick(app, ui.ctx());
show(app, ui.ctx());
});
out.textures_delta.clear();
}
#[test]
fn the_result_dialog_shows_every_reported_status_until_the_prompt_opens() {
for status in [
Status::Checking,
Status::UpToDate,
Status::Failed("offline".into()),
Status::Installing,
Status::Ready,
Status::Managed,
Status::Updated("0.2.0".into()),
Status::InstallFailed("disk full".into()),
] {
assert!(shows_result(&status, false), "{status:?}");
assert!(!shows_result(&status, true), "{status:?} with the prompt open");
}
assert!(!shows_result(&Status::Idle, false));
}
/// The first start after an update says so in the result dialog, which stays until the user closes it.
#[test]
fn the_first_start_after_an_update_reports_it_until_closed() {
let dir = std::env::temp_dir().join(format!("soundcraft-update-result-{}", std::process::id()));
let paths = Paths::under(&dir);
std::fs::create_dir_all(&paths.data).unwrap();
std::fs::write(paths.data.join("self-update-done.txt"), "v0.2.0").unwrap();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.updates_rt.updater =
Updater::new(&crate::UPDATE_APP, "0.2.0").with_source(Arc::new(|| Ok(None))).with_pins_path(dir.join("pins.json")).with_paths(paths);
app.set_startup_update_check(true);
let ctx = egui::Context::default();
for _ in 0..3 {
frame(&mut app, &ctx, Vec::new());
}
assert_eq!(app.updates_rt.updater.status(), &Status::Updated("0.2.0".into()));
assert!(app.updates_rt.manual_window_open);
let escape = egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
frame(&mut app, &ctx, vec![escape]);
assert_eq!(app.updates_rt.updater.status(), &Status::Idle);
assert!(!app.updates_rt.manual_window_open);
let _ = std::fs::remove_dir_all(dir);
}
}
+28
View File
@@ -90,6 +90,13 @@ impl Worker {
}
fn open_movie(path: &str) -> Result<Movie, String> {
// The last check before the file system: the path came from a session file, so the read
// roots apply, and only a regular file is opened (a pipe would hold the video worker
// forever).
soundcraft_engine::file_access::check_read(path)?;
if !std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?.is_file() {
return Err(format!("{path}: not a regular file"));
}
#[cfg(not(target_arch = "wasm32"))]
{
let f = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
@@ -491,3 +498,24 @@ pub fn draw_lane(app: &mut SoundApp, painter: &Painter, s: &Session, track: &Tra
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::open_movie;
#[test]
fn only_a_regular_movie_file_is_opened() {
let dir = std::env::temp_dir().join(format!("soundcraft-open-movie-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let (folder, missing, junk, movie) = (dir.to_string_lossy().to_string(), dir.join("none.mp4"), dir.join("junk.mp4"), dir.join("a.mp4"));
assert!(open_movie(&folder).err().unwrap_or_default().contains("not a regular file"));
assert!(open_movie(&missing.to_string_lossy()).is_err());
std::fs::write(&junk, b"not a movie").unwrap();
let err = open_movie(&junk.to_string_lossy()).err().unwrap_or_default();
assert!(!err.is_empty() && !err.contains("not a regular file"), "{err}");
std::fs::write(&movie, soundcraft_video::synth::h264_mp4(2, 2).unwrap()).unwrap();
assert!(open_movie(&movie.to_string_lossy()).is_ok());
let _ = std::fs::remove_dir_all(&dir);
}
}
+27
View File
@@ -0,0 +1,27 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.soundcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
@@ -33,7 +33,6 @@ const KNOWN_UNREACHED: &[&str] = &[
"transport.pause",
"transport.status",
"video.offset",
"view.sub_counter",
];
fn ui_source() -> String {
+18
View File
@@ -281,6 +281,24 @@ fn shuffle_drags_leave_locked_clips_in_the_selection_out_of_it() {
assert_eq!(starts(&app, &ids), vec![0, SEC]);
}
#[test]
fn f6_f7_and_f7_f8_in_either_order_choose_the_smart_tool() {
let (mut app, ctx, _, _) = app("slip", "grabber", &[]);
let tool = |app: &SoundApp| app.engine.session().edit.tool.id();
for (first, second) in [(Key::F6, Key::F7), (Key::F7, Key::F6), (Key::F7, Key::F8), (Key::F8, Key::F7)] {
frame(&ctx, &mut app, key(Key::F5));
frame(&ctx, &mut app, vec![key_event(first, true, Modifiers::NONE)]);
frame(&ctx, &mut app, key(second));
frame(&ctx, &mut app, vec![key_event(first, false, Modifiers::NONE)]);
assert_eq!(tool(&app), "smart", "{first:?}+{second:?}");
}
let alone = [(Key::F5, "zoom"), (Key::F6, "trim"), (Key::F7, "selector"), (Key::F8, "grabber"), (Key::F9, "scrubber"), (Key::F10, "pencil")];
for (k, want) in alone {
frame(&ctx, &mut app, key(k));
assert_eq!(tool(&app), want, "{k:?} alone");
}
}
#[test]
fn f4_pressed_before_f1_leaves_the_kind_of_grid_alone() {
let (mut app, ctx, _, _) = app("grid", "grabber", &[]);
@@ -151,19 +151,19 @@ fn the_views_menu_stays_open_until_a_click_elsewhere() {
}
#[test]
fn the_io_columns_route_menus_stay_open_while_searching_buses() {
fn the_io_columns_route_menus_stay_open_while_searching() {
let (mut h, _, row) = harness("Kick", "medium", "view.edit_io");
click(&mut h, pos2(row[0] + HEADER_W + 40.0, row[1] + 29.5));
let bus = h.get_by_label("Bus").rect();
let first = h.get_by_label("no output").rect();
let search = h
.query_all_by_role(egui::accesskit::Role::TextInput)
.map(|node| node.rect())
.filter(|r| r.min.y >= bus.max.y)
.min_by(|a, b| a.min.y.total_cmp(&b.min.y))
.filter(|r| r.max.y <= first.min.y && r.x_range().contains(first.center().x))
.max_by(|a, b| a.min.y.total_cmp(&b.min.y))
.unwrap()
.center();
click(&mut h, search);
assert!(h.query_by_label("no output").is_some(), "a click in the bus search closed the Output menu");
assert!(h.query_by_label("no output").is_some(), "a click in the menu's search closed the Output menu");
}
#[test]
@@ -0,0 +1,64 @@
//! A main menu taller than the window scrolls, so its last items can be reached (#1482).
use egui::{Event, Modifiers, MouseWheelUnit, PointerButton, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use soundcraft_ui_egui::{Services, SoundApp, menus};
const SIZE: egui::Vec2 = vec2(1200.0, 420.0);
fn step(h: &mut Harness<'_, SoundApp>) {
let mut raw = std::mem::take(h.input_mut());
let ctx = h.ctx.clone();
h.state_mut().raw_input_hook(&ctx, &mut raw);
*h.input_mut() = raw;
h.step();
}
#[test]
fn a_long_main_menu_scrolls_to_its_last_item() {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let mut h = Harness::builder().with_size(SIZE).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..4 {
step(&mut h);
}
let options = menus::tree().into_iter().find(|r| r.path == "Options").unwrap();
// The menu must scroll all the way to its last entry, a leaf like "Low Latency Monitoring".
let last = options.children.last().unwrap().label.clone();
let at = h.get_by_label("Options").rect().center();
let s = &mut h.state_mut().synthetic;
s.push(Event::PointerMoved(at));
s.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
s.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
for _ in 0..6 {
step(&mut h);
}
let bottom = |h: &Harness<'_, SoundApp>| h.get_by_label_contains(&last).rect().bottom();
assert!(bottom(&h) > SIZE.y, "the Options menu is taller than the window, so `{last}` starts out of view");
// Over the top of the open menu, which drops down below its title.
h.state_mut().synthetic.push(Event::PointerMoved(at + vec2(80.0, 120.0)));
step(&mut h);
for _ in 0..40 {
h.state_mut().synthetic.push(Event::MouseWheel {
unit: MouseWheelUnit::Line,
delta: vec2(0.0, -3.0),
modifiers: Modifiers::NONE,
phase: egui::TouchPhase::Move,
source: egui::MouseWheelSource::Wheel,
});
step(&mut h);
}
for _ in 0..10 {
step(&mut h);
}
assert!(bottom(&h) <= SIZE.y, "scrolling the menu brings `{last}` into view (bottom at {})", bottom(&h));
}
+2
View File
@@ -22,6 +22,7 @@ mod group_dialog;
mod hide_floating;
mod idle;
mod key_families;
mod long_menu_scroll;
mod marker_rename;
mod menu_search;
mod midi_double_click;
@@ -51,6 +52,7 @@ mod side_lists_text;
mod strip_silence_dialog;
mod strip_silence_rectangles;
mod strip_silence_rename;
mod sub_counter;
mod toolbar_scroll;
mod toolbar_tempo;
mod track_height;
+22
View File
@@ -169,6 +169,28 @@ fn send_and_output_menus_search_buses_as_soon_as_they_open() {
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Bus(bus(&h, "Verb")));
}
/// The I/O menus' search finds the hardware inputs, the main output and "no input/output", not just buses.
#[test]
fn io_menus_search_every_route_not_just_buses() {
let mut h = harness(vox());
open(&mut h, "Input for Vox");
type_text(&mut h, "in 3");
assert!(h.query_by_label("In 4").is_none(), "only the matches are listed");
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.input, Route::Hardware("In 3".into()));
open(&mut h, "Output for Vox");
type_text(&mut h, "no out");
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::None);
let main = h.state().engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
open(&mut h, "Output for Vox");
type_text(&mut h, &main.to_lowercase());
press(&mut h, Key::Enter);
assert_eq!(h.state().engine.session().tracks[0].mixer.output, Route::Main);
}
/// Scripted typing and Enter reach a menu search even when the host window is unfocused.
#[test]
fn scripted_keys_reach_the_search_with_the_window_in_the_background() {
+17 -1
View File
@@ -1,5 +1,5 @@
//! Pro Tools keys for commands that had none: Grid and Nudge values, Commands Focus nudging and selection
//! keys, playlists, F12, Ctrl+Tab off macOS and the window keys.
//! keys, playlists, F12, Ctrl+Tab off macOS, the window keys and the Cmd+6/7 tool keys.
use egui::os::OperatingSystem;
use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2};
@@ -299,3 +299,19 @@ fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() {
assert_eq!(start(&app), clip.start, "{os:?}");
}
}
#[test]
fn cmd_6_and_7_choose_the_pencil_and_the_smart_tool() {
for (os, cmd) in OSES {
let (ctx, mut app) = rig(os);
app.run("edit.tool", json!({"tool": "grabber"})).unwrap();
press(&ctx, &mut app, Key::Num6, cmd);
assert_eq!(app.engine.session().edit.tool.id(), "pencil", "{os:?}");
if os == OperatingSystem::Mac {
press(&ctx, &mut app, Key::Num7, Modifiers::CTRL);
assert_eq!(app.engine.session().edit.tool.id(), "pencil", "the Mac's Control+7 is not Cmd+7");
}
press(&ctx, &mut app, Key::Num7, cmd);
assert_eq!(app.engine.session().edit.tool.id(), "smart", "{os:?}");
}
}
+106
View File
@@ -113,3 +113,109 @@ fn cmd_equals_toggles_mix_and_edit_without_zooming() {
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
}
}
/// One full app frame on a 1600×1000 screen.
fn app_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 1000.0)));
ctx.run_ui(egui::RawInput { screen_rect, events, ..Default::default() }, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
}
fn arrow(key: Key) -> Vec<egui::Event> {
[true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }).to_vec()
}
/// Where `at` is drawn in the Edit window's timeline, and the timeline's middle (#1376).
fn x_and_middle(app: &SoundApp, at: i64) -> (f32, f32) {
let tl = soundcraft_ui_egui::edit_window::rect(app.edit_layout.timeline);
(soundcraft_ui_egui::edit_window::x_of(app.engine.session(), tl, at), tl.center().x)
}
#[test]
fn left_and_right_arrows_centre_the_edit_window_on_the_selected_clip() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
let (clip_id, clip) = app.engine.session().tracks.iter().flat_map(|t| t.clips()).find(|c| c.start > 100_000).map(|c| (c.id, c.range())).unwrap();
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
app.run("view.scroll", json!({"to": 0})).unwrap();
for _ in 0..3 {
app_frame(&ctx, &mut app, vec![]);
}
app.run("edit.select", json!({"clips": [clip_id.0]})).unwrap();
assert!(x_and_middle(&app, clip.start).0 > app.edit_layout.timeline[2], "the clip starts off-screen");
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
let (x, middle) = x_and_middle(&app, clip.start);
assert!((x - middle).abs() < 1.0, "Left: the clip start at {x}, the middle at {middle}");
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
let (x, middle) = x_and_middle(&app, clip.end);
assert!((x - middle).abs() < 1.0, "Right: the clip end at {x}, the middle at {middle}");
// An insertion point alone leaves the view where it is.
app.run("edit.select", json!({"start": clip.start})).unwrap();
let scroll = app.engine.session().edit.zoom.scroll;
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
assert_eq!(app.engine.session().edit.zoom.scroll, scroll);
}
/// Over the piano roll the arrows move the selected note and leave the Edit window's view alone.
#[test]
fn the_piano_roll_keeps_the_plain_arrows() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
app.run("edit.select", json!({"tracks": ["Keys"], "start": 0, "end": 480_000})).unwrap();
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
app.run("view.scroll", json!({"to": 1_000_000})).unwrap();
let ctx = egui::Context::default();
for _ in 0..6 {
app_frame(&ctx, &mut app, vec![]);
}
let body = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
app_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(body.center())]);
app.midi.selected = vec![0];
let note_start = |app: &SoundApp| {
let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap();
match &app.engine.session().find_clip(clip).unwrap().1.content {
soundcraft_model::ClipContent::Midi { sequence } => sequence.notes[app.midi.selected[0]].start,
_ => panic!("not a MIDI clip"),
}
};
let (before, scroll) = (note_start(&app), app.engine.session().edit.zoom.scroll);
app_frame(&ctx, &mut app, arrow(Key::ArrowRight));
assert!(note_start(&app) > before, "the note moved right");
assert_eq!(app.engine.session().edit.zoom.scroll, scroll, "the Edit window kept its view");
}
/// The MIDI editor open behind another tab has no piano roll on screen: the panel in front of it takes no
/// arrows, so they centre the Edit window there as anywhere else.
#[test]
fn a_midi_editor_behind_another_tab_leaves_the_arrows_to_the_edit_window() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
let (clip_id, clip) = app.engine.session().tracks.iter().flat_map(|t| t.clips()).find(|c| c.start > 100_000).map(|c| (c.id, c.range())).unwrap();
app.run("view.zoom_set", json!({"samples_per_px": 10.0})).unwrap();
app.run("view.scroll", json!({"to": 0})).unwrap();
let ctx = egui::Context::default();
for _ in 0..6 {
app_frame(&ctx, &mut app, vec![]);
}
let body = ctx.data(|d| d.get_temp::<egui::Rect>(egui::Id::unique("soundcraft-midi-body"))).unwrap();
app.run("ui.dock", json!({"operation": "open", "panel": "history"})).unwrap();
app.run("ui.dock", json!({"operation": "move", "panel": "history", "target": "midi", "zone": "tab"})).unwrap();
for _ in 0..3 {
app_frame(&ctx, &mut app, vec![]);
}
assert!(app.ui.show_midi_editor, "the MIDI editor is still open");
assert_ne!(app.ui.docking.layout.panel_state(&soundcraft_ui_egui::docking::Panel::Midi), craft_ui::docking::PanelState::Front);
app_frame(&ctx, &mut app, vec![egui::Event::PointerMoved(body.center())]);
app.run("edit.select", json!({"clips": [clip_id.0]})).unwrap();
app_frame(&ctx, &mut app, arrow(Key::ArrowLeft));
let (x, middle) = x_and_middle(&app, clip.start);
assert!((x - middle).abs() < 1.0, "Left: the clip start at {x}, the middle at {middle}");
}
+76
View File
@@ -0,0 +1,76 @@
//! Clicking the toolbar's Sub counter offers the time formats, as the Main counter does (#1374).
use egui::{Event, Modifiers, PointerButton, Pos2, RawInput, Rect, Shape, epaint::ClippedShape, vec2};
use soundcraft_time::TimeFormat;
use soundcraft_ui_egui::{Services, SoundApp};
struct Rig {
ctx: egui::Context,
app: SoundApp,
shapes: Vec<ClippedShape>,
}
impl Rig {
fn new() -> Rig {
let mut r = Rig {
ctx: egui::Context::default(),
app: SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default()),
shapes: Vec::new(),
};
r.frames(4);
r
}
fn frame(&mut self, events: Vec<Event>) {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
let mut out = self.ctx.run_ui(raw, |ui| {
self.app.logic(ui.ctx());
self.app.ui(ui);
});
out.textures_delta.clear();
self.shapes = out.shapes;
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
self.frame(vec![]);
}
}
/// Centre of the last text drawn as `text` (a popup's items are painted over the window).
fn text(&self, text: &str) -> Option<Pos2> {
self.shapes.iter().rev().find_map(|c| match &c.shape {
Shape::Text(t) if t.galley.text() == text => Some(t.galley.rect.translate(t.pos.to_vec2()).center()),
_ => None,
})
}
fn count(&self, text: &str) -> usize {
self.shapes.iter().filter(|c| matches!(&c.shape, Shape::Text(t) if t.galley.text() == text)).count()
}
fn click(&mut self, at: Pos2) {
for pressed in [true, false] {
self.frame(vec![
Event::PointerMoved(at),
Event::PointerButton { pos: at, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE },
]);
}
self.frames(2);
}
}
#[test]
fn the_sub_counter_menu_changes_its_format() {
let mut r = Rig::new();
let main = r.app.engine.session().edit.main_counter;
assert_eq!(r.app.engine.session().edit.sub_counter, Some(TimeFormat::Samples));
let sub = r.text("Sub").expect("the Sub counter is in the toolbar");
let shown = r.count(TimeFormat::MinSecs.label());
r.click(sub);
assert_eq!(r.count(TimeFormat::MinSecs.label()), shown + 1, "clicking the Sub counter opens its format menu");
let item = r.text(TimeFormat::MinSecs.label()).expect("clicking the Sub counter lists the formats");
r.click(item);
assert_eq!(r.app.engine.session().edit.sub_counter, Some(TimeFormat::MinSecs));
assert_eq!(r.app.engine.session().edit.main_counter, main, "the Main counter keeps its format");
}
+41
View File
@@ -380,6 +380,11 @@ impl Movie {
.collect()
}
/// Size the sample table declares for `sample` (0 when there is none).
fn declared_size(&self, sample: usize) -> usize {
self.mp4.tracks.get(self.track).and_then(|t| t.samples.get(sample)).map_or(0, |s| s.size as usize)
}
fn read(&self, sample: usize) -> Result<Vec<u8>> {
let t = self.mp4.tracks.get(self.track).ok_or(VideoError::NoVideoTrack)?;
let size = t.samples.get(sample).map_or(0, |s| s.size);
@@ -415,6 +420,13 @@ impl Movie {
Ok(Frame { width: f.width, height: f.height, rgba, index, time })
}
Codec::Jpeg => {
// The frame's own size, from its header, before a pixel is decoded: a frame may differ from
// the track's size (as before), but not exceed `MAX_PIXELS`.
let reader = image::ImageReader::with_format(std::io::Cursor::new(&data), image::ImageFormat::Jpeg);
let (w, h) = reader.into_dimensions().map_err(|e| VideoError::Decode(e.to_string()))?;
if u64::from(w) * u64::from(h) > MAX_PIXELS {
return Err(VideoError::Decode("picture too large".into()));
}
let img = image::load_from_memory_with_format(&data, image::ImageFormat::Jpeg).map_err(|e| VideoError::Decode(e.to_string()))?;
let rgba = img.to_rgba8();
let (width, height) = rgba.dimensions();
@@ -457,6 +469,14 @@ impl Movie {
Ok(self.h264_to_frame(p, index))
}
/// Most sample bytes one frame request feeds the decoder (eight samples at the 256 MiB cap):
/// a request decodes from the previous keyframe, and a sample table whose entries all name
/// one large payload would otherwise read and parse it once per sample.
const MAX_REQUEST_BYTES: usize = 2 << 30;
/// Most samples one frame request feeds without a picture coming out: the reorder depth is
/// at most 16, so a stream that produces nothing for this long will produce nothing.
const MAX_SAMPLES_WITHOUT_PICTURE: usize = 256;
fn decode_h264(&mut self, index: usize) -> Result<Arc<Frame>> {
let Codec::H264 { avcc } = &self.codec else { return Err(VideoError::Unsupported("not H.264".into())) };
let target = self.order.get(index).copied().ok_or(VideoError::OutOfRange)?;
@@ -471,6 +491,7 @@ impl Movie {
}
let total = self.order.len();
let mut last_err: Option<String> = None;
let (mut fed, mut without_picture) = (0usize, 0usize);
// Bounded: each iteration feeds one sample, or flushes once at the end.
for _ in 0..=total.saturating_add(1) {
let Some(st) = self.h264.as_mut() else { break };
@@ -485,13 +506,29 @@ impl Movie {
let s = st.next;
st.next += 1;
let pts = self.rank.get(s).copied().unwrap_or(0) as i64;
// The declared size is checked before the read allocates it. An error leaves no decoder
// behind that has stepped past this sample: the next request starts from its keyframe.
if fed.saturating_add(self.declared_size(s)) > Self::MAX_REQUEST_BYTES {
self.h264 = None;
return Err(VideoError::Decode(format!("frame {index} needs more than {} MiB of samples", Self::MAX_REQUEST_BYTES >> 20)));
}
let data = match self.read(s) {
Ok(d) => d,
// an unreadable sample is skipped, as before, and counts as a sample without a picture: the
// request's byte and no-picture budgets bound the walk
Err(e) => {
last_err = Some(e.to_string());
without_picture += 1;
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
return Err(VideoError::Decode(format!(
"no picture in {} samples before frame {index}",
Self::MAX_SAMPLES_WITHOUT_PICTURE
)));
}
continue;
}
};
fed = fed.saturating_add(data.len());
let Some(st) = self.h264.as_mut() else { break };
match st.dec.decode(&data, pts) {
Ok(p) => p,
@@ -506,6 +543,10 @@ impl Movie {
st.dec.flush()
};
let Some(st) = self.h264.as_mut() else { break };
without_picture = if pics.is_empty() { without_picture + 1 } else { 0 };
if without_picture > Self::MAX_SAMPLES_WITHOUT_PICTURE {
return Err(VideoError::Decode(format!("no picture in {} samples before frame {index}", Self::MAX_SAMPLES_WITHOUT_PICTURE)));
}
for p in pics {
let q = usize::try_from(p.pts).unwrap_or(usize::MAX);
st.last_out = Some(q);
+76 -2
View File
@@ -121,7 +121,7 @@ fn prores_frame_size_is_checked_before_decoding() {
}
/// 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).
/// largest read asked of it and refuses reads over 32 MiB (the test movies are much smaller).
struct PaddedSource {
bytes: Vec<u8>,
len: u64,
@@ -134,7 +134,7 @@ impl ByteSource for PaddedSource {
}
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 {
if buf.len() > 32 << 20 {
return Err(std::io::Error::other("read too large for the test source"));
}
let start = usize::try_from(offset).unwrap_or(usize::MAX);
@@ -161,6 +161,80 @@ fn h264_sample_reads_are_capped() {
assert!(largest <= 256 << 20, "a {largest}-byte read reached the source");
}
#[test]
fn one_frame_request_reads_a_bounded_amount_of_samples() {
// 65 frames whose `stsz` entries each claim 32 MiB: under the per-sample cap, but together
// past the 2 GiB one request may feed the decoder.
let mut bytes = h264_mp4(65, 65).unwrap();
let at = bytes.windows(4).position(|w| w == b"stsz").unwrap();
if bytes[at + 8..at + 12] == [0; 4] {
// one size per sample
for i in 0..65 {
let field = at + 16 + 4 * i;
bytes[field..field + 4].copy_from_slice(&(32u32 << 20).to_be_bytes());
}
} else {
// the writer stored one size for all the (equally large) samples
bytes[at + 8..at + 12].copy_from_slice(&(32u32 << 20).to_be_bytes());
}
let src = Arc::new(PaddedSource { bytes, len: 3 << 30, largest: AtomicUsize::new(0) });
let mut m = Movie::open_source(src).unwrap();
let r = m.frame(64);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("needs more than 2048 MiB of samples")), "{r:?}");
// the sample that was refused is not skipped: asking again fails the same way instead of decoding past it
let again = m.frame(64);
assert!(matches!(&again, Err(VideoError::Decode(e)) if e.contains("needs more than 2048 MiB of samples")), "{again:?}");
}
/// A source that, once `failing` is set, cannot be read at all.
struct UnreadableSamples {
bytes: Vec<u8>,
failing: std::sync::atomic::AtomicBool,
}
impl ByteSource for UnreadableSamples {
fn len(&self) -> u64 {
self.bytes.len() as u64
}
fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> {
let start = usize::try_from(offset).unwrap_or(usize::MAX);
if self.failing.load(Ordering::Relaxed) || start.saturating_add(buf.len()) > self.bytes.len() {
return Err(std::io::Error::other("unreadable"));
}
buf.copy_from_slice(&self.bytes[start..start + buf.len()]);
Ok(())
}
}
#[test]
fn a_request_over_unreadable_samples_stops_at_the_no_picture_budget() {
// 400 frames of one GOP, none of whose samples can be read once the movie is open: the walk gives up after 256 skipped samples
let bytes = h264_mp4(400, 400).unwrap();
let src = Arc::new(UnreadableSamples { bytes, failing: false.into() });
let mut m = Movie::open_source(src.clone()).unwrap();
src.failing.store(true, Ordering::Relaxed);
let r = m.frame(399);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("no picture in 256 samples")), "{r:?}");
}
#[test]
fn mjpeg_frame_size_is_capped_from_its_header_but_may_differ_from_the_track() {
// A frame whose header differs from the track's size still decodes (within the dimension cap, as before)...
let mut bytes = mjpeg_mov(2).unwrap();
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
bytes[sof + 5..sof + 9].copy_from_slice(&[0, 8, 0, 8]);
let mut m = Movie::open(bytes).unwrap();
assert!(m.frame(0).is_ok());
// ...but one claiming 16 000 x 16 000 is refused from the header, not decoded.
let mut bytes = mjpeg_mov(2).unwrap();
let sof = bytes.windows(2).position(|w| w == [0xFF, 0xC0]).unwrap();
bytes[sof + 5..sof + 9].copy_from_slice(&[0x3E, 0x80, 0x3E, 0x80]);
let mut m = Movie::open(bytes).unwrap();
let r = m.frame(0);
assert!(matches!(&r, Err(VideoError::Decode(e)) if e.contains("picture too large")), "{r:?}");
assert!(m.frame(1).is_ok(), "the untouched frame still decodes");
}
#[test]
fn unsupported_codec_opens_but_does_not_decode() {
let mut mw = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
+2 -1
View File
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. Never hand-edit.
username. Never hand-edit. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
+72
View File
@@ -0,0 +1,72 @@
# Feature naming migration
Status: draft, 2026-11-08, revised 2026-11-09. This is an engineering naming policy and rollout plan, not trademark clearance.
## Scheme
Use short, descriptive names for our own features. Built-in processing menus use the names in our processor registry, rather than names of another vendor's processors. Keep comparison catalogs and compatibility keys separate from display text. Labels must not determine dispatch, storage or processor selection.
| Previous visible name | Display name | Compatibility retained |
| --- | --- | --- |
| Beat Detective | Beat Analysis | `event.beat_detective`, `window.beat_detective` |
| x-form | Rendered (High Quality) | serialized `x-form` algorithm value |
| QuickPunch | Punch Recording | option id and stored field |
| Ableton Link | Tempo Sync | existing display flag only, no claim of Link interoperability; disabled placeholder |
| Pro Tools Search / Pro Tools Projects | Search / Shared Projects | historical catalog paths |
| Melodyne | No ARA plug-ins | disabled ARA placeholder, no added implementation |
| HEAT / Activate HEAT | Analog Saturation / Activate Analog Saturation | disabled catalog entries |
| Sibelius export/send | MusicXML | command ids; actual export format unchanged |
The algorithm labels describe the existing behavior. This change does not add a distinct third-party algorithm, synchronization protocol, ARA integration or hosted service.
## Deferred to RFC-0029
Some names are still being decided, so they keep the incumbent's label and nothing here renames them: AudioSuite (the menu, its process window title, `AudioSuite Process`, the task-manager note and the README wording), Elastic Audio, Elastic Properties, "Enable Elastic Audio on New Tracks" and the Preferences section that holds it, TrackPunch, Pitch Shift Legacy and Time Compression Expansion. RFC-0029 settles whether first-party feature names change at all; renaming ahead of it produced labels that had to be withdrawn, so the mapping waits for the RFC instead.
Their ids, catalog paths, menu rows and saved values were never renamed, so re-applying a name later stays a display-map change. Two of them, Pitch Shift Legacy and Time Compression Expansion, sit in menus whose other entries show our processor name (Pitch Shift, Time Stretch). They are listed beside that processor under their own catalog label rather than folded into it, because they wait on the RFC and because a menu item is never dropped.
## Disabled placeholders
Four features we do not have keep their five catalog leaves: Tempo Sync (the incumbent's Ableton Link item), No ARA plug-ins (Track and Clip › ARA Plugins › Melodyne) and Analog Saturation (View › Mix Window Views › HEAT and Options › Activate HEAT). They keep the incumbent's place in the menu, and therefore the parity floor, but they are never interactive: the menu draws them disabled with a "Not available yet." hover, `item_state` refuses them whatever their command would allow, and no label claims the protocol, plug-in format or processor behind them. "No ARA plug-ins" says what the item is: a place where plug-ins of that kind would be listed once a host exists. Preferences already has the same rule for settings without a backend.
Adding a placeholder means adding its catalog path to `PLACEHOLDERS` in `crates/engine/src/catalog.rs`; an engine test checks that each one is still a catalog entry with a descriptive label, and a UI test opens each one and checks that it is still in the menu and stays disabled. A placeholder becomes a feature by wiring a command and removing its path from that list.
## Offline processor names
| Previous catalog item | Our processor name / id |
| --- | --- |
| EQ3 1-Band / EQ3 7-Band | EQ 1-Band / `eq_1band`; EQ 7-Band / `eq_7band` |
| BF-76 / Dyn3 Compressor/Limiter | Compressor/Limiter / `compressor` |
| Dyn3 De-Esser | De-Esser / `de_esser` |
| Dyn3 Expander/Gate | Expander/Gate / `expander_gate` |
| Maxim | Maximizer / `maximizer` |
| Pitch II | Pitch Shift / `pitch_shift` |
| Time Shift | Time Stretch / `time_stretch` |
| Vari-Fi | Speed Ramp / `speed_ramp` |
| D-Verb | Room Reverb / `room_reverb` |
| Mod Delay III | Mod Delay / `mod_delay` |
| Sci-Fi | Ring Modulator / `ring_mod` |
| Eleven Lite / SansAmp PSA-1 | Saturator / `saturator` |
| Lo-Fi | Bit Crusher / `lofi` |
| Recti-Fi | Rectifier / `rectifier` |
Time Shift here is the incumbent's offline pitch-and-time process, which our `time_stretch` implements. It is not our own Time Shift plug-in (`time_shift`), a millisecond or sample delay that stays under that name; the two share a name and nothing else.
Other offline items already have descriptive names. Multiple legacy entries mapping to the same processor appear once per menu category. Every historical catalog path remains invokable. The catalog remains an observed interoperability/parity record; it is not used verbatim for rendered labels. Changing `lofi`'s display name does not change its id, parameters or audio implementation.
## Product-name decision
Deferred by the owner. The SoundCraft product name remains unchanged; this work only changes feature labels.
The SoundCraft app name has a separate clearance concern involving the established audio brand Soundcraft. A candidate for review is **ArtCraft Audio**, a descriptive extension of the existing suite identity. It is only a proposal; no availability or legal clearance is claimed. Avoid adopting another audio product name simply because it ends in “Craft.”
A product rename needs an owner decision and a separate migration PR covering app/window titles, website and release copy, package/binary names, bundle/application ids, installers, desktop files, update feeds, settings locations, file associations, shortcuts, URLs and external integrations. Preserve old command/package aliases and discover existing user data rather than resetting it. Verify the existing ArtCraft mark license applies to the new context before changing any brand assets. This feature-label draft makes none of those product-identity changes.
## Validation and release gates
- Regression tests cover old catalog-to-command mappings, own processor names, deduplication, the deferred leaves that keep their catalog label, and continued opening of the Beat Analysis dialog through its legacy menu path. Guards scan the rendered menu tree, UI/engine command labels and processor display names for retired labels; compatibility paths, ids and the deferred names are excluded, and unknown display text still falls back unchanged.
- Menu tree identity uses the stable path, not the rendered name.
- This main branch currently ships an English-only interface. Carry the mapping into pending localization PRs as they land; do not reintroduce vendor marks in translated labels.
- Run `cargo xtask ci` on the final head. Exercise offline processing from the AudioSuite menus, old automation paths, opening saved sessions containing `lofi` and `x-form`, Undo/Redo, and preference search.
- Recapture the UI with the supported `ui_shot` example. `docs/images/edit.png` has been visually inspected and still shows the old processing-menu title. Inspect and refresh all affected README images and attribution records from the running app; unchanged screenshots are not claimed to be refreshed by this draft.
- Review current help/marketing copy and localization work after integration. Historical parity documents, API examples and factual interoperability references may retain old names with their context. Preserve license/attribution records.
+5 -2
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **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
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-11-04 · **Change:** trivial (G-04: volume automation read at every sample, #1354; previously 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**
@@ -92,7 +92,9 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
render block, matching track faders. A breakpoint at the next block no longer ramps the preceding
block, and ramps reach their last-sample value. Regression tests cover nonzero render starts,
one-sample callbacks and several block sizes. Interior automation breakpoints remain block-dependent.
one-sample callbacks and several block sizes. Since #1354, track and master volume automation is read at
every sample, so its interior breakpoints no longer depend on the block; send levels and plugin parameters
are still read once per block.
disabled for producing NaN or infinity. Subsequent blocks compensate only for healthy inserts;
regression tests verify rendered impulse timing, retained healthy latency and reset/retry at
@@ -230,6 +232,7 @@ missing. Out of scope unless the owner decides otherwise.
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | G-04: track and master volume automation read at every sample, so breakpoints act on their own sample in any block size (#1354); estimates unchanged |
| 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 |
+4 -4
View File
@@ -22,7 +22,7 @@ FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
OCI=0
TARBALL=""
while [ $# -gt 0 ]; do
while [[ $# -gt 0 ]]; do
case "$1" in
--no-test) TEST=0; shift ;;
--oci) OCI=1; shift ;;
@@ -40,7 +40,7 @@ case "$ARCH" in
esac
BASENAME="soundcraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -51,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -70,7 +70,7 @@ if [[ "$OCI" = 1 ]]; then
echo "wrote OCI layout to $OUT_OCI"
fi
if [ "$TEST" = 1 ]; then
if [[ "$TEST" = 1 ]]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=soundcraft-cli "$APP_ID" --version
@@ -18,6 +18,8 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: soundcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -23,6 +23,8 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: soundcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland