Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 0a166a5d00 ci: verify the actions lockfile in CI
Fails the build when .github/workflows/actions.lock stops matching the
workflows, or when a locked pin no longer verifies upstream, so a stale or
tampered lockfile cannot go unnoticed between dependency updates.
2026-11-12 07:23:19 -05:00
Dylan M. Taylor 220b42773d ci: lock workflow actions through an actions.lock lockfile
Mutable action tags are a supply-chain risk: a moved tag runs different code
without a diff. Every workflow action is now pinned through
.github/workflows/actions.lock, and CI verifies the lockfile so an out-of-sync
or tampered one fails instead of running.
2026-11-12 07:18:10 -05:00
Dylan M. Taylor 6a0949d9d0 Review fixes after the craft-audio switch; MIDI files from craft-midi (#1513)
* Review fixes after the craft-audio switch; pin craft-audio at craft-libs main

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

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

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

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

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

The SMF and Sequence tests are deleted here; they run in craft-midi, which
also adds one for the track limit. The serde names do not change (the same
derives behind craft-midi's serde feature), so sessions read as before.
2026-11-11 19:51:41 -05:00
Dylan M. Taylor 4782cfa600 chore: bump to 0.20.1 for the Flatpak updater patch release (#1512) 2026-11-11 19:16:01 -05:00
Dylan M. Taylor 91952090ec Take audio file formats from craft-audio; remove crates/audio-io (#1506)
craft-audio in craft-libs was built from audio-io and FilmCraft's codecs, so
each format has one implementation that every Craft app shares. Call sites use
its API directly: EncodeOptions::new(format).with_*(), EncodeFormat,
DECODE_EXTENSIONS, ENCODE_EXTENSIONS, encode_format_for, AudioInfo's container,
codec and layout.

soundcraft_model::FileFormat stays as the kind of file a session records for
each source: its variant names are part of the session format. It maps from
craft-audio's AudioInfo.

craft-audio and craft-media are pinned to the craft-libs branch of
storytold/craft-libs#824, which adds the extension tables SoundCraft had in
FileFormat and fixes the quadratic WAV chunk scan (#893).

Behaviour that changes: AAC, ALAC, Opus, AC-3/E-AC-3 and MP2 decode with
craft-audio's own decoders; MP4 and Matroska tracks import at their
presentation length; .opus, .ac3, .eac3, .ec3, .mov, .mka and .mpa are
importable names; .aifc is a writable name; FLAC files come from craft-audio's
encoder.
2026-11-11 18:46:56 -05:00
FredAppsandFredApps 55c89b364d Release: SoundCraft v0.20.0 on main (#1511)
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
2026-11-11 23:20:08 +01:00
FredApps eeb5d653a5 CI: nightly release dry run (#1508)
release.yml also runs nightly on main with craft-common-actions' dry-run: every release build
except macOS, with nothing signed, pushed or drafted. Breakage that only the release build hits
(riscv64, Flatpak, FreeBSD, packaging) then shows up the next morning instead of on release
day, as it did on 11 Nov 2026.
2026-11-11 23:04:40 +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 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
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
FredApps 7bf178835f Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:02 +01:00
SlyLaM e66a5e7c67 feat(ui): add complete Preferences window and live settings (#819)
* feat(ui): add complete Preferences window shell

* fix(ui): refine Preferences layout and live settings

* fix(ui): remove Preferences page scrolling

* fix(ui): keep Preferences window size constant

* feat(ui): add Preferences to the SoundCraft menu

* fix(ui): address Preferences review feedback

* docs(ui): document Preferences behavior

* fix(macOS): add Preferences to the application menu

* fix(ui): make Preferences menu behavior modal

* fix(ui): preserve live Preferences updates

* fix(ui): address latest Preferences review findings

* fix(ui): preserve Preferences button actions
2026-11-06 11:34:43 +01:00
FredApps 87f46cf23b Merge pull request #782 from SlyLaM/fix/transient-record-monitor-states
fix(engine): keep live input states transient
2026-11-06 07:26:56 +01:00
Zinadin ZidanandClaude Opus 5.5 54cb247e12 fix(ui): Strip Silence opens with the values it last had (#1382) (#1396)
Strip Silence always opened at -48 dB / 50 / 5 / 20 ms. Its four values are
now kept for the session next to the strip names and written back each frame
the window is shown, so Escape, Cancel, Strip, Extract, Separate and the
Rename round trip all keep them.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 00:20:57 +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
FredApps 73eeb692fe Merge pull request #1370 from ZIDAN44/fix/892-session-json-cap
fix(engine): cap sessions at 256 MiB and parse them without a Value tree (#892)
2026-11-05 15:01:35 +01:00
FredApps bd75fe691b Merge pull request #1371 from ZIDAN44/fix/901-clap-main-thread
fix(clap-host): check for the main thread off macOS too (#901)
2026-11-05 15:00:52 +01:00
FredApps 6c8a9dd300 Merge pull request #1372 from ZIDAN44/fix/744-commands-json
feat(cli): commands --json lists every command's details (#744)
2026-11-05 14:57:21 +01:00
Sly LaMenace d393730b02 Merge upstream/main and adapt transient live input states 2026-11-05 07:51:42 +00:00
Zinadin ZidanandClaude Opus 5.5 4240ffda42 fix(engine): cap sessions at 256 MiB and parse them without a Value tree (#892)
A session was parsed into a serde_json Value and then converted, which takes
many times the file's size in memory, and nothing limited how big a session
file could be.

Session::from_json now reads only format_version first (every other field is
skipped) and then parses the text straight into the session. A missing
format_version and a newer version give the errors they gave before. Other
type errors now end with the place they were found ("at line L column C").
A field of the document given twice in one object, which used to load with
the last value winning, is now refused with "duplicate field `key`" (a
twice-given format_version too, not "missing format_version"): keeping the
old leniency would need the tree this change removes, and no writer repeats
a field. Keys of maps (plugin params, edit values and the like) and unknown
keys still keep the last value given, as before.

A track's height was read through an untagged enum, which buffers the whole
value before it looks at it; a huge array there took about 27 times its size.
It now has its own reader that takes exactly the forms that loaded before
("Micro" to "Extreme", also as {"Micro": null}, {"Custom": n}, {"Fit": n},
and the old bare "Fit" or {"Fit": null}) and refuses anything else at its
first token.

Opening a session (a .scraft file, the browser's bytes, the session in a
bundle) and Import Session Data refuse one over 256 MiB by its size, before
reading it. Save, Save Copy In, Save As Template, autosave and the browser's
bundle refuse to write a session over that size, so SoundCraft never saves
one it then won't open. Save and autosave check before they write any audio,
so a refused save leaves nothing behind and a refused autosave keeps the last
one and its audio copies as they were; the refusal is reported like any
other autosave failure. Save checks against the longest name each new audio
file can get, so it may refuse a session within a few bytes per new file of
the limit. Crash recovery reads only the name of a leftover autosave, and
none from one over the cap. Audio files are read without that cap, as
before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:47:13 +06:00
Zinadin ZidanandClaude Opus 5.5 c046c3bb1b feat(cli): commands --json lists every command's details (#744)
soundcraft-cli commands printed only a text table, so a program that wanted
the parameters had to call describe once per command. With --json it prints a
JSON array of each command's details, the same object describe prints, as
PhotoCraft and FilmCraft do; --compact puts it on one line. The filter
applies, and no match prints []. As in describe, enabled and disabled_reason
are worked out against an empty session; docs/cli.md says so, and the README
mentions the flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:20:58 +06:00
Zinadin ZidanandClaude Opus 5.5 e6cbf76148 fix(clap-host): check for the main thread off macOS too (#901)
On Windows and Linux on_main_thread() always said yes, so any thread could
open, idle or close a CLAP editor, and an instance dropped on another thread
destroyed its editor there. CLAP allows clap.gui calls on the main thread
only.

It now uses the rule the CLAP host's Windows window code and the VST3 host's
module loading already use: the thread Rust names "main", where the app runs
its UI. The editor tests check that another thread, named or not, is refused
on every platform, and that a plugin dropped on another thread with its
editor open warns and leaves the editor instead of destroying it there (the
test plugin now allows that, and only that, at destroy).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:17:50 +06:00
FredAppsandClaude Opus 5.5 1db9563379 fix(ui): About credits tables fit without side-scrolling and their columns resize (#1342)
* fix(ui): About credits tables fit without side-scrolling and their columns resize

The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let every credits column be dragged, and refit changed tables

The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refit credits tables on what they measure, not the fitted widths

Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 04:33:36 +01:00
FredApps 341a112757 Merge pull request #589 from SlyLaM/feat/grid-nudge-time-scales
Complete Grid and Nudge time scales
2026-11-05 02:45:03 +01: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
Sly LaMenace 594d0ade4f Merge upstream/main into feat/grid-nudge-time-scales 2026-11-04 22:55:33 +00:00
Sly LaMenace 92e6973640 Merge upstream/main and fix frame-origin grid snapping 2026-11-04 22:54:15 +00:00
Dylan M. Taylor 2b03df3ad4 Pass the Linux signing key to the release workflow (#1363) 2026-11-04 17:45:35 -05: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
Dylan M. Taylor 5fabf28c31 test: create scratch folders exclusively (#1334) 2026-11-03 23:03:28 -05:00
Dylan M. Taylor fa34e0c182 fix(engine): dedupe clip ids, clamp score bars per system, share autosave copies (#1333) 2026-11-03 23:02:33 -05:00
Dylan M. Taylor ccebb8ff93 fix(video): bound H.264 gap frames, long-term references and coded area (#1325) 2026-11-03 23:01:59 -05:00
Dylan M. Taylor b7d78c7e9e fix(time): edit meters in linear time (#1324) 2026-11-03 17:49:25 -05:00
FredApps b26ab2b25c Merge pull request #576 from colliderman/fix/edit-track-header-alignments
fix(ui): center track names in edit window and remove unnecessary spacing for Solo and Mute buttons in Master tracks
2026-11-03 21:01:19 +01:00
Cody Gilchrist 76f58fcb81 fix(ui): add hover tooltip for overflowing track names and assert exact center position in test 2026-11-03 12:29:37 -05:00
Cody Gilchrist 6a4c055094 fix(ui): preserve start of long track names in edit headers 2026-11-03 11:47:04 -05:00
CodyG_NeuralDSP d5564bce05 fix(ui): position master track view selector directly under track name 2026-11-03 11:00:53 -05:00
CodyG_NeuralDSP 87ad11cccc fix(ui): center track names in edit window track headers 2026-11-03 10:58:53 -05:00
Brandon Thomas b11ffe73ae Merge pull request #1280 from TheGalitube/fix/1252-tracks-nested-in-a-folder-are-no
Indent tracks nested in folders
2026-11-03 10:05:11 -05:00
Brandon Thomas f91b4173b6 Merge pull request #1281 from rahul05ranjan/toolbar-link-icons
Toolbar: distinct link icons (#1262) and an Insertion Follows Playback toggle with its own icon (#1263)
2026-11-03 10:05:06 -05:00
Brandon Thomas 98c3a307c3 Merge pull request #1316 from JonRC/fix/plugin-load-error-reason
Plugin hosts: keep the system's reason when a plugin binary fails to load
2026-11-03 10:05:01 -05:00
Brandon Thomas 6492b5f27e Merge pull request #1286 from Silvertree2010/fix/1260-zoom-in-t-doesn-t-use-the-edit-c
Keep the edit cursor as the zoom reference when it is off-screen
2026-11-03 10:04:56 -05:00
Brandon Thomas 9c202d6f17 Merge pull request #1290 from JonRC/fix/1289-offline-render-mode
Tell hosted plugins when they render offline (VST3 kOffline, clap.render)
2026-11-03 10:04:51 -05:00
Brandon Thomas 44b864ffaf Merge pull request #1305 from JonRC/fix/1304-cli-logger
soundcraft-cli: print logged warnings and errors to stderr
2026-11-03 10:04:47 -05:00
Brandon Thomas 035de82c84 Merge pull request #1278 from TheGalitube/fix/1257-alt-click-doesn-t-delete-a-marke
Delete markers on Alt-click
2026-11-03 10:04:42 -05:00
Brandon Thomas 181f329ec8 Merge pull request #1279 from TheGalitube/fix/1255-stereo-track-input-shows-a-mono
Show stereo input pairs for stereo tracks
2026-11-03 10:04:37 -05:00
Brandon Thomas 5ba9f9d7b8 Merge pull request #1291 from ljkx/new-send-opens-send-window
A new send opens its Send window, so its fader is on screen right away
2026-11-03 10:04:32 -05:00
Brandon Thomas c15682a088 Merge pull request #1302 from JonRC/fix/1301-audiosuite-hosted-ids
audiosuite.process: say it takes built-in plugin ids, and why hosted ones are refused
2026-11-03 10:04:28 -05:00
Brandon Thomas 0d253b6d8c Merge pull request #1300 from JonRC/fix/1296-bounce-bext
Stamp WAV bounces with their timecode position (BWF bext TimeReference)
2026-11-03 10:04:22 -05:00
Brandon Thomas c0f2f7dc87 Merge pull request #1314 from JonRC/fix/convert-keeps-bext
convert: keep a BWF source's bext chunk
2026-11-03 10:04:16 -05:00
Brandon ThomasandClaude Opus 5.5 f99407fb1f Leave Insertion Follows Playback's N to the Edit window's shortcut handler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:42 -05:00
Brandon ThomasandClaude Opus 5.5 636454e639 Name the missing entry point in the not-a-plugin errors on every platform
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:35 -05:00
Brandon ThomasandClaude Opus 5.5 7546ab992b Cap the folder indent at eight levels and test against the track's own folder
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:56:05 -05:00
Brandon ThomasandClaude Opus 5.5 f86b077cdf Test that an off-screen edit cursor lands on its fixed column
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:53:19 -05:00
Dylan M. Taylor aa250cc1d7 Clip gain to volume: index envelope lookups (#1309)
Converting clip gain to volume (and baking volume into clip gain)
evaluated the clip's gain envelope by walking it from the start for
every breakpoint being converted, so a large envelope in an opened or
imported session cost envelope points squared and blocked the engine.

Build a running-maximum index over the envelope once per clip and
binary-search it. It returns exactly what the walk returned, including
for envelopes stored out of order.
2026-11-03 07:44:31 -05:00
Sly LaMenace dbcbcbac00 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-03 11:52:00 +00:00
Dylan M. Taylor 351b9d3a55 Tempo map: binary-search tempo segments in tick/sample conversions (#1297)
* Tempo map: index tempo segments so tick/sample conversions are O(log n)

Every tick<->sample conversion walked all earlier tempo changes, so code
that converts many positions (MIDI import/export, the tempo ruler, score
layout, MIDI scheduling and controller chasing on the audio thread)
cost positions x tempo changes. A session or MIDI file with many tempo
changes and many notes or tracks could stall the engine or the audio
callback.

TempoMap now keeps the elapsed time at each tempo change (rebuilt on
every edit and on deserialize, never serialized) and binary-searches
the segment. MIDI scheduling also skips notes whose on and off both
fall outside the block before converting them.

* Tempo map: index a file's ticks with saturating subtraction
2026-11-03 06:18:44 -05:00
Dylan M. Taylor da02c19fcc Bundles: refuse entries that share their data (#1303)
Bundle entries are stored, so the files read out of one can only
outgrow the bundle when several entries point at the same bytes. A
small bundle naming one payload thousands of times was copied out once
per name, up to the 1 GiB total limit, in the browser. Check the
archive for overlapping entry data before reading any entry.
2026-11-02 23:33:19 -05:00
Dylan M. Taylor 64540f109b Mixer: compute cyclic aux depths in one pass per hop (#1306)
When the aux/folder routing has a cycle, processing_order sorted the
auxes by a bounded feeder depth recomputed inside the sort key: each
evaluation re-searched every aux's output and sends against a growing
list of inputs, for up to eight hops, on every comparison. A session
with many auxes sharing buses made that cubic during audio sync.

Compute the same capped depth for every track at once: per hop, collect
the buses written by auxes reached so far, then mark the tracks reading
them. The result matches the old search (tested against it on a mixed
graph with cycles) and the sort uses it by lookup.
2026-11-02 23:27:09 -05:00
Dylan M. Taylor 0af1ad909e Mixer: match automation lanes to insert parameters in one merge (#1299)
apply_params searched all of the track's automation lanes for every
stored insert parameter on every block, so a session with many
parameters and many lanes multiplied the two on the audio thread.
Collect the slot's lanes once per block, order them by parameter name
(keeping the first lane per name, the one the old search found) and
walk them alongside the sorted parameter map. The lane list reuses a
scratch buffer kept on the plugin slot.
2026-11-02 23:25:34 -05:00
JonRC 7587130d59 convert: keep a BWF source's bext chunk
convert wrote every file with bwf: None, so converting a Broadcast Wave file
dropped its bext: description, originator, dates and the time reference that
places it on a timeline. Pass the decoded bext on. The time reference counts
samples, so with --rate it moves to the output rate.
2026-11-03 01:19:13 -03:00
JonRC e4f0599a2a Plugin hosts: keep the system's reason when a plugin binary fails to load
libloading 0.9 prints only "dlopen failed" ("dlsym failed", "LoadLibraryExW
failed") and keeps the dlerror text or the Windows error as the error's source.
The CLAP and VST3 loaders formatted the error alone, so a plugin built for a
newer glibc was reported as "dlopen failed" with no reason. Append the source.
2026-11-03 01:19:13 -03:00
Dylan M. Taylor 17d3f00700 MIDI: index controller dedup by channel and kind (#1298)
* MIDI: index controller dedup by channel and kind

Remove Duplicate Notes rescanned every controller already kept at the
same tick for each incoming one. A session whose sequence holds one tick
with many distinct channel/controller pairs made that quadratic and
blocked editing. Track where each channel+kind sits at the current tick
in a map instead; the last event per key still wins.

* MIDI: tag dedup index entries with their tick instead of clearing per tick

Clearing the map on every tick change cost O(capacity) per tick after a
dense tick. Store the tick with each entry and treat entries from an
earlier tick as absent. Add an oracle test comparing against the
previous per-tick rescan on mixed-tick input.
2026-11-02 22:51:02 -05:00
JonRC f46ea7d70c soundcraft-cli: print logged warnings and errors to stderr
The CLI installed no logger, so every log::warn! and log::error! was
dropped: a plugin that failed to load just vanished from
engine.clap_plugins with no reason. A small logger now writes warnings
and errors to standard error as `warning: <message>` and
`error: <message>`, like the CLI's own errors. The level is fixed:
RUST_LOG stays a desktop app variable.

Fixes #1304.
2026-11-02 23:48:01 -03:00
JonRC 1403bbc4d3 audiosuite.process: say it takes built-in plugin ids
Its help said `<plugin id>`, but it only runs built-in plugins, and a
hosted id (clap:, vst3:, au:) failed as "unknown process". The help now
says built-in plugin id, and a hosted id gets an error saying AudioSuite
runs built-in plugins only.

Fixes #1301.
2026-11-02 23:42:08 -03:00
JonRC b0793ca875 Stamp WAV bounces with their timecode position (BWF bext)
Bounces passed no bwf to the encoder, so mix and stem WAVs had no bext
chunk and post tools placed them at zero. Bounce::new now stamps them
with TimeReference = the session's timecode start plus the bounce start,
in samples since midnight, as other DAWs do. A caller's own bwf wins;
AIFF and FLAC have no bext and are unchanged.

Fixes #1296.
2026-11-02 23:36:15 -03:00
Dylan M. Taylor 1c350101bc clap-host tests: load plugins only from a freshly created private temp directory (#1295)
* clap-host tests: scan a freshly created private directory for the garbage-file test

The test reused a PID-named directory under the shared temp dir after ignoring a
failed cleanup, then loaded every .clap candidate in it. Another local account
could pre-create that directory and plant a native library that the test runner
would load. Create the directory exclusively (mode 0700 on Unix) and fail
instead of reusing one that already exists.

* clap-host tests: create the garbage-file test directory beside the test binary, not under TMPDIR
2026-11-02 21:30:40 -05:00
ljkx 36d42e0136 Test a new send's Send window end to end, and tidy send_route_menu
An integration test creates send C while send A's window is open: the window moves to the new send
with its fader on screen, and undoing the send closes it. send_route_menu now opens the window only
after the send command succeeds, without the extra bool, and its comment says reassignment leaves
the window alone.
2026-11-03 01:55:52 +01:00
Sly LaMenace c692648200 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-03 00:36:13 +00:00
Sly LaMenace bf274560ae fix(grid): share visible lines and match folder visibility 2026-11-03 00:26:25 +00:00
Brandon ThomasandClaude Opus 5.5 b35dd0f9eb Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:25:27 -05:00
Sly LaMenace 087978a4a0 fix(edit): preserve overlaps in stationary nudge and quantize 2026-11-02 23:42:44 +00:00
Sly LaMenace 9b5554e312 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-02 23:42:44 +00:00
ljkx 04dab602c7 A new send opens its Send window, so its fader is on screen right away (#1269)
Picking a destination for an empty send slot, or making one with new bus..., now opens that send's Send window, as in Pro Tools. Moving an existing send to another bus leaves the window as it was.
2026-11-02 19:38:53 +01:00
FredApps e5d3174929 Merge pull request #637 from danielmigueltejedor/fix/gnome-window-decorations
Fix GNOME/Wayland window controls
2026-11-02 19:32:54 +01:00
FredApps 27329587c6 Merge current main into fix/gnome-window-decorations 2026-11-02 19:10:30 +01:00
FredApps 1dbc185e01 Merge pull request #559 from SlyLaM/fix/new-tracks-keyboard-ux
feat(ui): improve the New Tracks creation workflow
2026-11-02 18:23:48 +01:00
JonRC 576ff36f61 Tell hosted plugins when they render offline
Bounces ran VST3 plugins with processMode kRealtime and never set
clap.render, so plugins that work differently offline never could.
Offline renders now create their plugins with Plugin::set_offline(true):
VST3 plugins get kOffline in setupProcessing and in every process call,
and CLAP plugins with clap.render are set to CLAP_RENDER_OFFLINE.
Playback stays realtime.

Fixes #1289.
2026-11-02 12:34:32 -03:00
NetterSilverandClaude Haiku 5.5 4b06407dae Keep the edit cursor as the zoom reference when it is off-screen
Fixes #1260

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:57:40 +01:00
rahul05ranjan 1a95692f50 Toolbar: distinct link icons, an Insertion Follows Playback toggle, and its N shortcut in the menu
The two link toggles shared the "link" icon (#1262), and Insertion Follows
Playback, although it works with N, had no toolbar button of its own and no
shortcut in the Options menu (#1263).

- icons: "link_track" draws the chain under two track rows so the track link
  reads apart from the timeline link; "insertion_follows" draws the insertion
  line and the playhead arrow it follows.
- toolbar: the option toggles move into option_toggles, which gains the
  insertion-follows entry between the links and Mirrored MIDI, as in Pro Tools.
- options: the Insertion Follows Playback menu item shows N, the key it uses.
- test: option_toggles_have_distinct_icons fails when any two option toggles
  share an icon (it fails on the old "link" pair).

Fixes #1262, fixes #1263

Co-authored-by: rahul05ranjan <11793455+rahul05ranjan@users.noreply.github.com>
2026-11-02 12:15:55 +00:00
Max MittelstädtandGPT-5.6-Luna 7dfa7e470f Indent tracks nested in folders
Fixes #1252

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:25:43 +00:00
Max MittelstädtandGPT-5.6-Luna 8f7eb4d777 Show stereo input pairs for stereo tracks
Fixes #1255

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:23:34 +00:00
Max MittelstädtandGPT-5.6-Luna ffc035ba29 Delete markers on Alt-click
Fixes #1257

Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
2026-11-02 08:23:18 +00:00
Brandon Thomas 8444edf075 Merge pull request #1248 from Silvertree2010/fix/1247-vst3-multi-out-instruments-e-g-s
Accept VST3 multi-out instruments with up to 64 audio buses
2026-11-02 00:30:05 -05:00
NetterSilverandClaude Opus 5.5 6c3a0dfc50 Accept VST3 multi-out instruments with up to 64 audio buses
Fixes #1247

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 06:19:39 +01:00
Brandon Thomas d557d18118 Merge pull request #1223 from TheGalitube/fix/h7732-aiff-parsing-rejects-valid-chann
AIFF: test that a negative channel count is rejected
2026-11-01 23:06:45 -05:00
Brandon Thomas 98cda6ecaf Merge pull request #1032 from Silvertree2010/fix/h2086-midi-export-writes-notes-that-a
Export only the MIDI notes a trimmed clip still plays
2026-11-01 23:06:40 -05:00
Brandon Thomas 920ec2c3f3 Merge pull request #1234 from Silvertree2010/fix/h5095-score-export-drops-every-note-wh
Keep score export notes when bars are shorter than a sixteenth
2026-11-01 23:06:37 -05:00
Brandon ThomasandClaude Opus 5.5 5e8898976e Test a grid of 1/16, 1/32 and 1/64 bars in a row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:02 -05:00
Brandon ThomasandClaude Opus 5.5 c426b889bb Test MIDI export of a clip trimmed at both ends
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:05:08 -05:00
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
Sly LaMenace e1f10ccdc8 fix(edit): preserve cross-track grid snapping 2026-11-01 23:03:35 +00:00
Sly LaMenace 627bbbf9cc fix(edit): keep zero-distance moves lossless 2026-11-01 22:49:21 +00:00
Sly LaMenace 84baa5debc fix(edit): preserve drag origins while snapping 2026-11-01 22:39:15 +00:00
Sly LaMenace 468bec487c fix(ui): exclude trimmed clip from grid targets 2026-11-01 22:01:09 +00:00
Brandon ThomasandClaude Opus 5.5 db51dd1739 Release 0.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:15 -05:00
Dylan M. Taylor d588e74667 docs: sync Craft App Suite list with ArtCraft Launcher 2026-11-01 12:30:07 -05:00
Sly LaMenace 71f899baac fix(edit): align grid snapping across time scales 2026-11-01 14:47:41 +00:00
Sly LaMenace ccbeec6680 Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-11-01 14:34:56 +00:00
Sly LaMenace bdb73895a5 fix(ui): make dialog cancellation atomic 2026-11-01 13:55:09 +00:00
Sly LaMenace 1a4a88fb0d fix(ui): cancel pending dialog actions on Escape 2026-11-01 13:40:45 +00:00
Sly LaMenace 9e902b48fe fix(ui): let dialog cancellation win over Enter 2026-11-01 12:30:51 +00:00
Sly LaMenace 265e526f6a fix(ui): keep row actions from submitting New Tracks 2026-11-01 11:46:36 +00:00
Sly LaMenace d2298ff974 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
#	crates/ui-egui/src/lib.rs
2026-11-01 11:32:53 +00:00
Max MittelstädtandGPT-5.6-Luna cf40296888 fix: reject unsigned AIFF channel counts
Co-Authored-By: GPT-5.6-Luna <noreply@openai.com>
Co-Authored-By: GPT-5.6-Terra <noreply@openai.com>
2026-11-01 09:13:52 +00:00
Dylan M. Taylor 7169dc492e ci: grant packages write permission to release workflow 2026-10-31 23:43:12 -04:00
Sly LaMenace 381832447c Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-11-01 00:09:45 +00:00
NetterSilverandClaude Opus 5.5 b32065a109 Keep score export notes when bars are shorter than a sixteenth
Found while testing crates/engine/src/score.rs:~150 (measure_grid, `if ub <= ua { break; }`), used by layout()

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 00:34:16 +01:00
Dylan M. Taylor 85ffa1f53e packaging(flatpak): add OCI image build and GHCR publishing helper (#1233) 2026-10-31 18:52:51 -04:00
NetterSilverandClaude Sonnet 5.5 97e2603683 fix: release a held sustain at the end of a trimmed MIDI clip on export
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 16:48:51 +00:00
Brandon Thomas 444a716396 Merge pull request #1227 from Silvertree2010/fix/h4683-autoparam-parse-lowercases-plugi
Keep plugin parameter id case in AutoParam::parse
2026-10-31 10:55:15 -04:00
Brandon Thomas ba86e7eae7 Merge remote-tracking branch 'origin/main' into fix1227
# Conflicts:
#	crates/model/src/automation.rs
2026-10-31 10:54:32 -04:00
Brandon Thomas 54d36c2fcc Merge pull request #1075 from Silvertree2010/fix/1069-transport-play-from-is-ignored-i
Play transport.play {from} at the requested position, not the selection
2026-10-31 10:54:18 -04:00
Brandon Thomas 382965153a Merge pull request #1214 from Silvertree2010/fix/h4142-range-param-ignores-end-length-w
Default range start to the selection start in range_param
2026-10-31 10:54:15 -04:00
Brandon Thomas 2de7846d4b Merge pull request #1229 from Silvertree2010/fix/h4952-h-264-mmco-ref-list-modification
Fix H.264 DPB integer overflow on huge pic num differences
2026-10-31 10:54:12 -04:00
Brandon Thomas eb095cbe81 Merge pull request #1226 from Silvertree2010/fix/h4700-h-264-picture-order-count-type-0
Saturate H.264 POC type 0 bottom field order count
2026-10-31 10:54:09 -04:00
NetterSilverandClaude Haiku 5.5 ef2f70eb35 Return app errors from Remote::call unchanged instead of reconnecting (#1128)
Found while testing crates/automation/src/lib.rs:~160 (Remote::call, matching on e.contains("closed") / "Broken pipe" / "reset")

Co-authored-by: Claude Haiku 5.5 <noreply@anthropic.com>
2026-10-31 10:54:06 -04:00
Brandon Thomas e60a559596 Merge pull request #1212 from kk376/fix/890-smf-tempo-track-floods
fix(engine): bound MIDI import tempo and track floods (#890)
2026-10-31 10:54:02 -04:00
Brandon Thomas 4716141401 Merge pull request #1211 from kk376/fix/reject-overflowing-flac-vendor-length
Reject overflowing FLAC vendor string lengths
2026-10-31 10:53:59 -04:00
Brandon Thomas 314be8740e Merge pull request #1222 from TheGalitube/fix/h7696-rms-over-finite-high-amplitude-a
Keep RMS finite for large samples
2026-10-31 10:53:56 -04:00
Brandon Thomas 675673346b Merge pull request #1202 from TheGalitube/fix/h1902-wav-writer-silently-saturates-an
Reject overflowing WAV byte rates
2026-10-31 10:53:53 -04:00
Brandon Thomas ba3b191440 Merge pull request #1201 from TheGalitube/fix/h1888-drop-frame-timecode-parsing-acce
Reject skipped drop-frame timecode labels
2026-10-31 10:53:49 -04:00
Brandon Thomas 60cd0622f2 Merge pull request #1200 from TheGalitube/fix/h1919-caf-size-check-bypassed-by-lengt
Reject oversized CAF cookie lengths
2026-10-31 10:53:46 -04:00
Brandon Thomas 87836c3ec2 Merge pull request #1221 from Silvertree2010/fix/h4306-remove-dc-leaves-a-dc-offset-whe
Fix remove_dc mean when a channel has non-finite samples
2026-10-31 10:53:42 -04:00
Brandon Thomas c896668d00 Merge pull request #1220 from Silvertree2010/fix/h4422-tempomap-bar-beat-at-tick-panics
Fix overflow in TempoMap bar math with a meter near i64::MAX
2026-10-31 10:53:39 -04:00
Brandon Thomas deceb0cd3a Merge pull request #1216 from Silvertree2010/fix/h4203-gridvalue-step-samples-panics-wi
Fix overflow in GridValue::step_samples at extreme positions
2026-10-31 10:53:35 -04:00
Brandon Thomas 6446eb5598 Merge pull request #1215 from Silvertree2010/fix/h4180-automationlane-value-at-panics-w
Fix integer overflow in AutomationLane::value_at for distant breakpoints
2026-10-31 10:53:31 -04:00
Brandon Thomas a4e4a44bab Merge pull request #1126 from SusieeTheLinuxUser/fix/clamp-clip-fx-gain-send-level
Mix: clamp clip-FX gain and send-level automation (fixes #884)
2026-10-31 10:53:27 -04:00
Brandon Thomas 99895de2c7 Merge pull request #1101 from dotCooCoo/feat/zoom-toggle-cancel
Zoom Toggle: Alt+Shift+E and Alt-click cancel it and keep the current view
2026-10-31 10:53:23 -04:00
Brandon Thomas 48f61d297e Merge pull request #1196 from ydking0911/fix/clip-menu-acts-on-selection
Clip menu: act on the whole edit selection when the right-clicked clip is part of it
2026-10-31 10:53:19 -04:00
Brandon ThomasandClaude Opus 5.5 6e2da18e9e Drop the old selection's end and loop on transport.play {from}
While playing, locate kept the earlier end and loop range, so playing
from past a looped selection jumped back into the loop. CodeRabbit
flagged this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:46:57 -04:00
Brandon ThomasandClaude Opus 5.5 13b2488e95 Set an empty-range constant tempo at the range start
With start now optional, {end} alone resolves the start from the
selection; the empty-range fallback still went through event.tempo,
which put the tempo at tick 0. CodeRabbit flagged this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:42:37 -04:00
Brandon ThomasandClaude Opus 5.5 6ef3300aba Assert that out-of-range pic num differences are refused
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:41:20 -04:00
Brandon ThomasandClaude Opus 5.5 ad3147388f Saturate the POC type 0 MSB and top field sum too
The MSB accumulates across reference pictures, so a hostile stream can
still overflow prev_msb ± max_lsb and msb + lsb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 10:40:57 -04:00
NetterSilverandClaude Haiku 5.5 a13a4f5bd2 Fix H.264 DPB integer overflow on huge pic num differences
Found while testing crates/video/src/h264/dpb.rs:265 (Dpb::apply_mmcos, MMCO 1 and 3); same pattern at :280 and :203 (build_ref_lists, `m.value as i32 + 1`)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 15:30:11 +01:00
NetterSilverandClaude Haiku 5.5 2d88f46f02 Keep plugin parameter id case in AutoParam::parse
Found while testing crates/model/src/automation.rs:86-103 (AutoParam::parse)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 10:16:46 +00:00
NetterSilverandClaude Haiku 5.5 f28a10a5dd Saturate H.264 POC type 0 bottom field order count
Found while testing crates/video/src/h264/slice.rs:477 (PocState::compute, pic_order_cnt_type 0)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 11:13:22 +01:00
Max MittelstädtandGPT-6-Luna 73e3478ae3 Accept unsigned AIFF channel counts
Found while testing crates/audio-io/src/aiff.rs:84

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-31 07:06:09 +00:00
Max MittelstädtandGPT-6-Luna 53bb43db46 Keep RMS finite for large samples
Found while testing crates/audio-io/src/peaks.rs:34

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-31 06:46:05 +00:00
NetterSilverandClaude Haiku 5.5 b9fee48a75 Fix remove_dc mean when a channel has non-finite samples
Found while testing crates/dsp/src/offline.rs:125-134 (remove_dc)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 06:07:06 +00:00
NetterSilverandClaude Haiku 5.5 948460a953 Fix overflow in TempoMap bar math with a meter near i64::MAX
Found while testing crates/time/src/tempo.rs:287 (bar_beat_at_tick); same pattern in bar_start_tick

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 06:53:02 +01:00
Dylan M. Taylor 4179cf88e6 Merge pull request #1218 from aiongg/docs-tidy
docs: tidy agent, contributor and release docs
2026-10-31 01:06:45 -04:00
Dylan M. Taylor ca6271b082 Merge pull request #1219 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:06:15 -04:00
aiongg 4c31f2884b updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:39 +00:00
aiongg 1964662a71 docs: tidy wording 2026-10-31 04:25:42 +00:00
aiongg 5288e42bc9 docs: tidy agent, contributor and release docs 2026-10-31 04:17:26 +00:00
Brandon ThomasandClaude Opus 5.5 a97ce7556c Release 0.15.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:18 -04:00
NetterSilverandClaude Haiku 5.5 13fe1df4b8 Fix overflow in GridValue::step_samples at extreme positions
Found while testing crates/time/src/grid.rs:125

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 02:38:12 +00:00
NetterSilverandClaude Haiku 5.5 b2a386bc7a Fix integer overflow in AutomationLane::value_at for distant breakpoints
Found while testing crates/model/src/automation.rs:161

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 02:31:18 +00:00
NetterSilverandClaude Haiku 5.5 331b1820db Default range start to the selection start in range_param
Found while testing crates/engine/src/cmd.rs:326-336 (range_param), used by crates/engine/src/cmd/event.rs:148 (insert_time) and :172 (cut_time)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 02:29:44 +00:00
Kushagra Kumar 966e253873 fix(engine): skip duplicate MIDI tempos only when session tempo matches
Collapse consecutive duplicate SMF tempos only if the session tempo at
the event tick already matches, so existing session tempo markers are
overridden as expected.
2026-10-31 05:50:50 +05:30
Dylan M. Taylor e8bc4758cc fix(flatpak): allow reading launcher version pins (#1210)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:19 -04:00
Kushagra Kumar af4dd7ce4c Contributors: add kk376 to people.toml 2026-10-31 05:35:13 +05:30
Kushagra Kumar f7b0805de6 fix(engine): bound MIDI import tempo and track floods (#890)
import_midi_bytes capped meter changes at MAX_MIDI_METERS (10,000), but
left imported tempo events and synth tracks unbounded. Dense MIDI files
inserted unlimited tempo markers into the session tempo map, degrading
linear tick-to-sample conversions across playback and audio processing,
and instantiated up to 4,096 synth tracks for controller-only tracks.

Add MAX_MIDI_TEMPOS (10,000) and MAX_MIDI_TRACKS (512) caps checked
before session mutation, and collapse consecutive equal tempo events.
2026-10-31 05:35:09 +05:30
Kushagra Kumar cc02045a2d Contributors: add kk376 to people.toml 2026-10-31 05:02:24 +05:30
Kushagra Kumar ddfe991a87 Reject overflowing FLAC vendor string lengths
Vorbis comment pre-flight validation skipped the vendor string unread
without checking whether its declared length fit inside the metadata
block. An overflowing vendor length caused the comment count lookup to
evaluate to None and return false from comments_overrun, allowing
malformed buffers to bypass pre-flight rejection and reach Symphonia.

Validate the vendor string length against the remaining bytes in the
block before advancing, and add a regression test for Vorbis comment
blocks with overflowing vendor lengths.
2026-10-31 05:02:20 +05:30
Max MittelstädtandGPT-6-Luna 8da40bef96 Reject overflowing WAV byte rates
Found while testing crates/audio-io/src/wav.rs:514

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-30 08:58:10 +00:00
Max MittelstädtandGPT-6-Luna 90e8f60bad Reject skipped drop-frame timecode labels
Found while testing crates/time/src/format.rs:120

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-30 07:51:31 +00:00
Max MittelstädtandGPT-6-Luna 3bd3965c51 Reject oversized CAF cookie lengths
Found while testing crates/audio-io/src/caf.rs:23

Co-Authored-By: GPT-6-Luna <noreply@openai.com>
2026-10-30 07:29:26 +00:00
ydking0911 5a1324b8bc Clear: lift clips removed by id in Shuffle so crossfade partners don't overlap 2026-10-30 16:13:42 +09:00
Dylan M. Taylor 7902d59b47 UPD.1: honor app pretend versions in update checks (#1199) 2026-10-30 02:31:43 -04:00
SusieeTheFemboyandClaude Opus 5.5 552607cbee Merge main into fix/clamp-clip-fx-gain-send-level
main split crates/mix/src/lib.rs: the clip-FX gain clamp now lives in
clips.rs and the send-level automation clamp in automation.rs (via
crate::SEND_LEVEL_MAX_DB). Adds tests for the send-automation clamp and
the load-time sanitize clamp (#884).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:40:00 +01:00
ydking0911 9f5f312051 Clear: close the gaps of clips removed by id in Shuffle 2026-10-30 14:30:58 +09:00
Dylan M. Taylor 92e74f6e00 Update prompt shows the app icon and accent color instead of a letter (#1198)
UPDATE_APP (native builds; craft-update is a native-only dependency) carries our 128 px icon and accent (#1fa5c9, its colour in ArtCraft Launcher). The updater, the self-update
helper and craft-update's startup prompt all get them through crate::UPDATE_APP; the
prompt draws the icon, the accent and "by ArtCraft" (craft-update's default developer).

craft-update is pinned to storytold/craft-libs main at 1065653e7 (#434).
2026-10-30 01:28:31 -04:00
ydking0911 ffb95ebcf3 Clip menu: pass the clicked clip's track when Delete removes selected clips by id 2026-10-30 13:47:53 +09:00
NetterSilverandClaude Haiku 5.5 440999a51f Play transport.play {from} at the requested position, not the selection
Fixes #1069

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:29:09 +01:00
NetterSilverandClaude Haiku 5.5 4740e8d4c4 Export only the MIDI notes a trimmed clip still plays
Found while testing crates/engine/src/io.rs:350-362 (export_midi)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:24:39 +01:00
ydking0911 14a081304b Clip menu: act on the whole edit selection when the right-clicked clip is part of it 2026-10-30 11:35:49 +09:00
Dylan M. Taylor 072e548ec8 deps: bump tar, zip, roxmltree, rustls-webpki and symphonia to their latest versions (#1192)
- `zip`: 9.1.1 → 9.1.3

Lockfile only.
2026-10-29 22:21:27 -04:00
Dylan M. Taylor 98fae00688 CI: workflows only call craft-common-actions at @main (#1190)
AGENTS.md (and CLAUDE.md where present) gets a CI workflows section: workflows only call
craft-common-actions at @main, with no other jobs; feature and fix PRs don't touch CI and
link or open a craft-common-actions issue (a PR only for a small fix that is safe for
every app and does not increase CI time); legacy jobs
aren't added to and move only as their own task, staying until a shared workflow runs them.

windows-arm64.yml moves from its SHA pin to @main. The pinned revision is on
craft-common-actions main and the workflow (and the actions it calls) is identical
there, so nothing changes in behaviour.
2026-10-29 22:11:16 -04:00
Brandon Thomas d2079bf09d Merge pull request #1194 from storytold/m/land-995
Bound the command journal (#995, rebased)
2026-10-29 22:04:22 -04:00
Brandon Thomas 5447c94cb6 Merge pull request #1193 from storytold/m/land-993
Bound Print Score and MusicXML output size (#993, rebased)
2026-10-29 22:04:18 -04:00
Brandon Thomas 3276358bef Merge pull request #1150 from ayozetr/fix-1146-send-fader-automation
Write send-level automation from the send faders (fixes #1146)
2026-10-29 22:03:28 -04:00
Brandon Thomas 081f43b90a Merge pull request #1180 from Silvertree2010/fix/h3951-h-264-picture-order-count-type-1
Use saturating arithmetic in H.264 POC type 1/2 computation
2026-10-29 22:03:24 -04:00
Brandon Thomas b4412929a7 Merge pull request #1109 from Silvertree2010/fix/h2757-audiosuite-processes-that-shorte
Clamp clip fades after AudioSuite processes that shorten a clip
2026-10-29 22:03:21 -04:00
Brandon Thomas ca27f0f8b4 Merge pull request #1057 from Silvertree2010/fix/h2373-trim-tool-lets-a-stretched-clip
Scale trim_clip file bounds by clip stretch
2026-10-29 22:03:18 -04:00
Brandon Thomas 5b69a11d89 Merge pull request #1020 from Silvertree2010/fix/h1998-clip-group-import-rejects-its-ow
Fix clip group import rejecting audio names containing ".."
2026-10-29 22:03:14 -04:00
Brandon ThomasandClaude Opus 5.5 81ab012724 Take the journal as a VecDeque in assert_history_unchanged
#995 makes the command journal a bounded VecDeque; the helper #1129 added
still took a slice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 22:00:11 -04:00
Brandon Thomas a62d955a39 Merge remote-tracking branch 'origin/main' into land-995 2026-10-29 21:59:42 -04:00
Brandon ThomasandClaude Opus 5.5 af4970e388 Saturate the reference POC LSB after a memory management op 5
A saturated top POC with a negative bottom field could still overflow
here, as CodeRabbit noted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:58:53 -04:00
Brandon Thomas 9e768d659b Merge remote-tracking branch 'origin/main' into fix1180 2026-10-29 21:58:16 -04:00
Brandon ThomasandClaude Opus 5.5 4e9d778734 Make the fade clamp test reach the fade-out bound too
A 10-sample fade-out was already shorter than the processed clip, so the
fade-out clamp went untested. Use 40, as CodeRabbit suggested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:58:10 -04:00
Brandon Thomas c2f14dff49 Merge remote-tracking branch 'origin/main' into fix1109 2026-10-29 21:57:47 -04:00
Brandon ThomasandClaude Opus 5.5 f39413033e Cover the start bound of trim_clip on a stretched clip
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 19f4b695e2 Merge origin/main into #1057; move its test to edit/tests.rs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:57:36 -04:00
Brandon ThomasandClaude Opus 5.5 17d553a7a7 Refuse absolute audio paths in clip group files
A bare group path leaves the folder empty, so an absolute audio path
passed the starts_with check. Reject root and prefix components too,
as CodeRabbit noted, and cover it in the regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:56:21 -04:00
Brandon Thomas 57ae0a8b5d Merge remote-tracking branch 'origin/main' into fix1020
# Conflicts:
#	crates/engine/src/clip_group_file.rs
2026-10-29 21:56:21 -04:00
NetterSilverandClaude Haiku 5.5 d9c293a92b Use saturating arithmetic in H.264 POC type 1/2 computation
Found while testing crates/video/src/h264/slice.rs:485-490 (PocState::compute, pic_order_cnt_type 1)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 12:05:31 +00:00
dotCooCoo dcdcca4704 Zoom Toggle: a cancel with Zoom Toggle off keeps the scroll to restore
Cancel Zoom Toggle clears the saved vertical scroll only when it turns
Zoom Toggle off, so a cancel run before the next frame after turning it
off no longer drops the scroll position the toggle-off restores.
2026-10-28 03:31:06 -07:00
Daniel fd9c673694 Merge upstream main and reconcile Linux Wayland CSD dependencies
Preserve Unix X11 support, add Linux Adwaita no-title controls,
refresh Cargo.lock and keep MIT notice in existing packaging paths.
2026-10-28 10:48:57 +01:00
dotCooCoo 1ffc164082 Merge commit 'refs/check/mw-soundcraft-main' into feat/zoom-toggle-cancel
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-28 02:03:56 -07:00
Ayoze Torres 7c029c40ce Write send-level automation from the send faders (#1146)
Moving a send's level with automation enabled wrote nothing: the live
pass in write_automation only recorded each track's main fader
(`volume`). Send levels have their own lanes (`send_a_level` …), which
playback reads, but nothing wrote them while playing.

The send's level control in the strip slot and its floating send window
now start a SendFader gesture, released like the main fader's. The pass
writes the send's level with the track's mode, the same rules as the
volume: Write throughout, Touch while the send is dragged, Latch and
Touch/Latch from the first drag until stop (touched sends are kept per
track and slot).
2026-10-27 22:14:20 +00:00
Sly LaMenace d7c194d728 Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-10-27 15:54:41 +00:00
Sly LaMenace f92150c64e Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales
# Conflicts:
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/toolbar.rs
2026-10-27 13:25:40 +00:00
afa1532aba Mix: clamp clip-FX gain and send-level automation (fixes #884)
A shared session or an MCP clip.effects_set could store clip-FX gain or a
send-level automation point far above any fader (up to the +200 dB cap in
db_to_gain). Clamp clip-FX gain to the slider's +24 dB, send-level lanes to
+12 dB, and every loaded automation point to its AutoParam range on sanitize.

Co-Authored-By: opencode beam-free <noreply@opencode.ai>
Co-Authored-By: MiniMax M3 <noreply@minimax.io>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:53:47 +01:00
NetterSilverandClaude Haiku 5.5 75e5b90a86 Clamp clip fades after AudioSuite processes that shorten a clip
Found while testing crates/engine/src/cmd/audiosuite.rs:132-137

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 05:37:43 +00:00
dotCooCoo 84e591e1ca Zoom Toggle: Alt+Shift+E and Alt-click cancel it and keep the current view
Cancel Zoom Toggle (view.zoom_toggle_cancel, Alt+Shift+E, Option+Shift+E
on a Mac) turns Zoom Toggle off without going back to the view it
stored: the zoom, the horizontal and vertical scroll and the track
heights stay as they are. An Alt-click (Option-click) on the Zoom Toggle
button runs the same command. The next Zoom Toggle zooms in from the
current view and goes back to it.

When the command runs, from a key, the button or the control channel,
the Edit window clears the vertical scroll position that Zoom Toggle
would restore.
2026-10-25 17:17:19 -07:00
Daniel 1f8b267722 Merge upstream main and resolve GNOME Wayland controls conflicts 2026-10-25 23:57:47 +01:00
Sly LaMenace 66f305b747 fix(ui): handle Grid and Nudge edge cases 2026-10-25 12:30:31 +00:00
Sly LaMenace b9accf92a8 fix(ui): address Grid and Nudge review feedback 2026-10-25 11:13:22 +00:00
Sly LaMenace d33cac774a Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales
# Conflicts:
#	crates/engine/src/cmd/view.rs
#	crates/model/src/session.rs
#	crates/time/src/grid.rs
2026-10-25 07:29:27 +00:00
NetterSilverandClaude Haiku 5.5 dab64379cb Scale trim_clip file bounds by clip stretch
Found while testing crates/engine/src/edit.rs:756-758,770 (trim_clip, file closure)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 02:58:32 +00:00
Sly LaMenace 95f25979eb docs(ui): document New Tracks changes 2026-10-24 21:57:11 +00:00
Sly LaMenace 929a7c5307 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-24 21:50:52 +00:00
Sly LaMenace ee63b09cbe fix(ui): omit hidden New Tracks row fields 2026-10-24 21:45:19 +00:00
Samuel Bacaro 4f771268b0 journal: measure params size via a bounded writer before cloning
Addresses the review on #995: the previous fix serialized the full
params clone (up to a multi-MiB line from MCP or the control channel)
before applying the 64 KiB cap, so one oversized request still made
several large allocations.

- journal_params serializes through a bounded std::io::Write sink that
  fails with WriteZero the moment the cap is passed, so an oversized
  value costs at most ~1x the cap and is never fully serialized.
- Only when serialization fits is the value cloned for the journal.
- Tests exercise the journal helper directly (no 10,500 track.new
  calls), check ring-buffer eviction (oldest dropped, newest kept) and
  the cap boundary.

Refines the fix for #926.
2026-10-24 17:25:53 -03:00
NetterSilverandClaude Haiku 5.5 9d5f5784d5 Fix clip group import rejecting audio names containing ".."
Found while testing crates/engine/src/clip_group_file.rs:101

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 13:02:39 +00:00
Daniel 4071975aa4 Merge upstream main; retain GNOME window controls
Integrate updated SoundCraft dependencies and packaging while preserving Linux winit CSD, required lockfile crates and license notices.
2026-10-24 10:22:45 +02:00
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
Samuel Bacaro 2e057309b9 Bound the command journal
The command journal was a Vec that was never trimmed, so large params
(clips/indices arrays, junk fields from MCP or the control channel) were
kept for the life of the process.

- Store the journal as a VecDeque ring buffer capped at 10,000 entries
  (oldest are dropped).
- Cap a single entry's params at 64 KiB (serialized): a larger value is
  replaced by a {"__truncated__": true} marker before journaling.

Fixes #926
2026-10-23 18:59:00 -03:00
Sly LaMenace 9e9177f1af fix(ui): hide inapplicable New Tracks fields 2026-10-20 14:33:48 +00:00
Sly LaMenace 3ea1f34a1d feat(ui): add multiple New Tracks rows with fixed field widths 2026-10-20 14:33:48 +00:00
Sly LaMenace 6e8c039097 fix(ui): improve New Tracks keyboard navigation and field sizing 2026-10-20 14:33:15 +00:00
Daniel 5d7197a2d0 fix(linux): keep GNOME Wayland controls without redundant NOTICE copy 2026-10-20 03:53:35 +02:00
Sly LaMenace 93ac4f2913 test(engine): cover enabled live states across history 2026-10-19 10:35:53 +00:00
Sly LaMenace 352d90a24a fix(engine): keep live input states transient 2026-10-19 09:42:51 +00:00
Sly LaMenace b48a939d64 fix(ui): format Grid and Nudge values as counters 2026-10-14 21:15:07 +00:00
Sly LaMenace 7849e8cf51 feat(ui): complete Grid and Nudge time scales 2026-10-14 20:41:55 +00:00
273 changed files with 12951 additions and 7676 deletions
+32
View File
@@ -0,0 +1,32 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/ast-grep.yml': []
'.github/workflows/ci.yml': []
'.github/workflows/freebsd.yml': []
'.github/workflows/house-rules.yml': []
'.github/workflows/lockfile.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/nix.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/packaging-lint.yml':
- 'actions/checkout@v7.0.1'
- 'step-security/harden-runner@v2.22.4'
'.github/workflows/release.yml': []
'.github/workflows/sonarcloud.yml': []
'.github/workflows/windows-arm64.yml': []
dependencies:
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'step-security/harden-runner@v2.22.4':
ref: 'v2.22.4'
commit: 'sha1-1d88384fd173af6ecb012dfd0264a306b711241a'
owner_id: 88700172
repo_id: 422287306
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+34
View File
@@ -0,0 +1,34 @@
name: Lockfile
# Fails when .github/workflows/actions.lock is out of sync with the workflows, or when a locked pin
# no longer verifies upstream, so a moved tag cannot slip a different commit into a run.
# Docs: https://gh.io/actions-lockfile
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
env:
GH_TOKEN: ${{ github.token }}
jobs:
lockfile:
name: Lockfile is in sync
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install gh-actions-lock
run: gh extension install github/gh-actions-lock --pin v0.4.1
- name: Verify the lockfile
run: gh actions-lock --verify
+5 -1
View File
@@ -27,7 +27,11 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
+5 -1
View File
@@ -27,7 +27,11 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Harden runner
uses: step-security/harden-runner@v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- name: Install the pinned packaging toolchain
+15
View File
@@ -1,6 +1,10 @@
name: Release
on:
# A nightly dry run on main: every release build except macOS, nothing signed or released
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
schedule:
- cron: "36 2 * * *"
push:
branches: [release]
workflow_dispatch:
@@ -15,16 +19,26 @@ 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
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
dry-run: ${{ github.event_name == 'schedule' }}
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 }}
@@ -40,3 +54,4 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
+2 -2
View File
@@ -26,8 +26,8 @@ permissions:
jobs:
windows-arm64:
# Immutable supporting workflow revision until the new install contract lands upstream.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
with:
app-name: soundcraft
app-dir: SoundCraft
+67 -13
View File
@@ -1,13 +1,62 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# SoundCraft — instructions for agents
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
Read `ROADMAP.md` for stage, status and the parity numbers, and pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them. `plan/` is an optional gitignored notebook (`plan/protools/` holds an observed menu tree and screenshots, never committed). Do not ask for it and do not stop when it is absent.
## ⚠️ Assets: the absolute rule
**No Avid, Pro Tools, Adobe or other proprietary iconography, images, sounds, presets or artwork — ever.** This is not negotiable.
@@ -22,7 +71,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
## Never crash
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
@@ -32,13 +81,12 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
- The audio callback never blocks, allocates in steady state, or panics.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## Architecture (layers enforced by `cargo xtask layers`)
| Layer | Crate | Role |
|---|---|---|
| L0 | `soundcraft-time`, `soundcraft-audio-io`, `soundcraft-midi` | Timebases/tempo/grid; audio file formats + peaks; Standard MIDI Files + MIDI ops |
| L0 | `soundcraft-time`, `soundcraft-midi` | Timebases/tempo/grid; Standard MIDI Files + MIDI ops |
| L1 | `soundcraft-dsp` | Plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utility, instruments), offline processing, meters, FFT |
| L2 | `soundcraft-model` | The session document (tracks, playlists, clips, mixer, routing, automation, markers, groups) |
| L3 | `soundcraft-mix` | Mix engine shared by realtime playback and offline bounce |
@@ -47,6 +95,8 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
| L6 | `soundcraft-ui-egui` | The swappable UI (egui) |
| apps | `soundcraft`, `soundcraft-cli`, `soundcraft-web` | Entry points |
Audio file formats, codecs and waveform peaks are not a SoundCraft crate: they come from `craft-audio` in `storytold/craft-libs`, which every Craft app shares. Fix or extend a format there, never in a local copy; SoundCraft keeps only the mapping onto its session model (`soundcraft_model::FileFormat`).
Nothing below L6 depends on egui/eframe/winit/rfd. The UI is thin: panels read engine state and act through `engine.execute(id, params)`.
## Everything is a command
@@ -67,7 +117,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
## Release
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
@@ -81,11 +131,15 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
New workspace crates include `[lints] workspace = true` in `Cargo.toml`. Production crate roots declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
+8 -9
View File
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
| Path | Author | Source | License |
|---|---|---|---|
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
@@ -55,4 +55,3 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
| `assets/app-icon/soundcraft.ico` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
| `assets/app-icon/hicolor/` | SoundCraft contributors | original, rendered from `icon.svg` (16–512 px PNGs and the scalable SVG) | MIT OR Apache-2.0 |
| `docs/images/` | SoundCraft contributors | screenshots of SoundCraft rendered from the synthesised demo session | MIT OR Apache-2.0 |
| `crates/audio-io/tests/fixtures/` | SoundCraft contributors | original test tones and silence generated with ffmpeg (sine) or by hand; commands in `crates/audio-io/tests/fixtures/README.md` | MIT OR Apache-2.0 |
+1 -5
View File
@@ -1,5 +1 @@
Read `AGENTS.md`: it is the entry point for agents in this repo.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
@AGENTS.md
Generated
+144 -67
View File
@@ -277,6 +277,12 @@ dependencies = [
"x11rb",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -1055,20 +1061,32 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-audio"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
dependencies = [
"craft-media",
"log",
"serde",
"symphonia",
"thiserror 2.0.21",
]
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
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=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1091,15 +1109,25 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "craft-midi"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=7db4645d1f1673df3b064de32faa3335bcdd7aed#7db4645d1f1673df3b064de32faa3335bcdd7aed"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1120,15 +1148,17 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
"egui",
"flate2",
"getrandom 0.3.4",
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1415,6 +1445,19 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools 0.15.0",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1468,6 +1511,26 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -4005,6 +4068,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4279,10 +4353,11 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"chrono",
"clap",
"craft-audio",
"craft-update",
"eframe",
"egui",
@@ -4292,7 +4367,6 @@ dependencies = [
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"soundcraft-audio-io",
"soundcraft-automation",
"soundcraft-engine",
"soundcraft-playback",
@@ -4303,7 +4377,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"log",
"serde",
@@ -4311,26 +4385,14 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-audio-io"
version = "0.14.0"
dependencies = [
"log",
"proptest",
"serde",
"serde_json",
"symphonia",
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-automation"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"clap",
"craft-audio",
"log",
"serde_json",
"soundcraft-audio-io",
"soundcraft-engine",
"soundcraft-time",
"thiserror 2.0.21",
@@ -4338,7 +4400,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4351,15 +4413,16 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"craft-audio",
"log",
"same-file",
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-automation",
"soundcraft-dsp",
"soundcraft-engine",
@@ -4368,7 +4431,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"log",
"proptest",
@@ -4379,15 +4442,15 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"craft-docpath",
"log",
"proptest",
"serde",
"serde_json",
"soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-midi",
@@ -4397,27 +4460,26 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-midi",
"proptest",
"serde",
"serde_json",
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-mix"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"log",
"rayon",
"soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-midi",
@@ -4428,12 +4490,12 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"proptest",
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-midi",
"soundcraft-time",
"thiserror 2.0.21",
@@ -4441,13 +4503,13 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"cpal",
"craft-audio",
"log",
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-dsp",
"soundcraft-mix",
"soundcraft-model",
@@ -4456,7 +4518,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"proptest",
"serde",
@@ -4466,18 +4528,19 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"craft-ui",
"craft-update",
"egui",
"egui_extras",
"egui_kittest",
"image",
"log",
"serde",
"serde_json",
"soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-engine",
@@ -4495,7 +4558,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-media",
"image",
@@ -4507,7 +4570,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4523,15 +4586,15 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"soundcraft-audio-io",
"soundcraft-engine",
"soundcraft-model",
"soundcraft-playback",
@@ -4568,6 +4631,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4938,6 +5007,31 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.5"
@@ -5982,6 +6076,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -6139,7 +6234,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.14.0"
version = "0.20.1"
dependencies = [
"serde_json",
]
@@ -6359,9 +6454,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.1.1"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "518b847fd864440c379748ad684c449f293af68978a485a4ff16f7c901c1f7c4"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"crc32fast",
"flate2",
@@ -6371,7 +6466,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6386,18 +6480,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"
@@ -6459,11 +6541,6 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
+19 -8
View File
@@ -4,16 +4,15 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.14.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
rust-version = "1.95"
repository = "https://github.com/storytold/soundcraft"
[workspace.dependencies]
# internal
soundcraft-time = { path = "crates/time" }
soundcraft-audio-io = { path = "crates/audio-io" }
soundcraft-midi = { path = "crates/midi" }
soundcraft-video = { path = "crates/video" }
soundcraft-dsp = { path = "crates/dsp" }
@@ -43,22 +42,29 @@ same-file = "1"
proptest = "1"
# audio
symphonia = { version = "0.6", default-features = false, features = ["mp1", "mp2", "mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed" }
# Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares
# types with craft-media, so both stay on one rev.
# Standard MIDI Files and the note/controller model, shared with the other craft apps.
craft-midi = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["serde"] }
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "7db4645d1f1673df3b064de32faa3335bcdd7aed", features = ["decode", "lossless-encode", "peaks", "serde"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", 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 }
eframe = { version = "0.37", default-features = false }
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
web-time = "1.1"
@@ -85,6 +91,11 @@ debug = false
opt-level = 1
debug = "line-tables-only"
# symphonia's FLAC decoder overflows an i32 add on 32-bit LPC FLAC; the wrapped value is correct
# (craft-audio README, "symphonia").
[profile.dev.package.symphonia-bundle-flac]
overflow-checks = false
[profile.release]
opt-level = 3
lto = "thin"
+31
View File
@@ -286,3 +286,34 @@ The Font Software may be sold as part of a larger software package but no copy o
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24 -7
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
@@ -146,8 +146,7 @@ where things are.
## Getting started
Download a build from the [releases page](https://github.com/storytold/soundcraft/releases), or build
from source with Rust 1.95 or later for the desktop/web UI (the core workspace's declared
minimum remains Rust 1.90):
from source with Rust 1.95 or later:
```sh
git clone https://github.com/storytold/soundcraft
@@ -168,6 +167,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
new tracks. **Setup › Keyboard Shortcuts** lists them all.
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
fit their longest option and remain fixed when the selection changes.
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
### Logs
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
@@ -218,7 +227,8 @@ soundcraft-cli mcp --automation-read-root DIR --automation-write-root DIR
See [`docs/cli.md`](docs/cli.md) for the command-line tool (subcommands, exit codes, environment
variables, man pages and completions), [`docs/control-protocol.md`](docs/control-protocol.md) and
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command.
[`docs/mcp.md`](docs/mcp.md). `soundcraft-cli commands` lists every command (`--json` for each
one's parameters and details, as JSON).
Open **SoundCraft › Session Audio Health** to check loaded audio availability, sample-rate
mismatches and clip source bounds across all playlists, including alternate takes. The report
@@ -234,7 +244,6 @@ about egui, so the interface could be swapped for another one.
| Crate | What it does |
|---|---|
| `soundcraft-time` | Timebases, tempo and meter maps, timecode, grid |
| `soundcraft-audio-io` | Audio file formats and waveform overviews |
| `soundcraft-midi` | Standard MIDI Files and MIDI operations |
| `soundcraft-dsp` | Plugins, offline processing, meters, loudness, FFT |
| `soundcraft-model` | The session document |
@@ -340,7 +349,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+6 -4
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
@@ -156,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs
set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
@@ -204,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
| 2026-10-06 | minor | Alpha and parity estimates |
+2 -1
View File
@@ -20,11 +20,12 @@ workspace = true
[dependencies]
soundcraft-engine.workspace = true
soundcraft-automation.workspace = true
soundcraft-audio-io.workspace = true
craft-audio.workspace = true
soundcraft-dsp.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
clap.workspace = true
same-file.workspace = true
+15 -10
View File
@@ -11,31 +11,30 @@ use std::sync::OnceLock;
use clap::builder::NonEmptyStringValueParser;
use clap::error::ErrorKind;
use clap::{ArgAction, Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use craft_audio::{DECODE_EXTENSIONS, ENCODE_EXTENSIONS};
use serde_json::{Value, json};
use soundcraft_audio_io::FileFormat;
use soundcraft_automation::ControlTarget;
use soundcraft_automation::args::{AutomationArgs, ControlClientArgs, blank_env_is_unset, control_target, long_version, name_env_vars};
/// Help text that names the extensions `list` gives, built once from
/// [`FileFormat::readable_extensions`] or [`FileFormat::writable_extensions`] so it can't drift
/// from what the code accepts.
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: fn() -> Vec<&'static str>, after: &str) -> &'static str {
cell.get_or_init(|| format!("{before}({}){after}", list().join(", ")))
/// Help text that names the extensions in `list`, built once from craft-audio's
/// [`DECODE_EXTENSIONS`] or [`ENCODE_EXTENSIONS`] so it can't drift from what the code accepts.
fn with_formats(cell: &'static OnceLock<String>, before: &str, list: &[&str], after: &str) -> &'static str {
cell.get_or_init(|| format!("{before}({}){after}", list.join(", ")))
}
fn info_file_help() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
with_formats(&C, "An audio file ", FileFormat::readable_extensions, " or a .scraft session")
with_formats(&C, "An audio file ", DECODE_EXTENSIONS, " or a .scraft session")
}
fn read_help() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
with_formats(&C, "The audio file to read ", FileFormat::readable_extensions, "")
with_formats(&C, "The audio file to read ", DECODE_EXTENSIONS, "")
}
fn write_help() -> &'static str {
static C: OnceLock<String> = OnceLock::new();
with_formats(&C, "The audio file to write; its extension picks the format ", FileFormat::writable_extensions, "")
with_formats(&C, "The audio file to write; its extension picks the format ", ENCODE_EXTENSIONS, "")
}
/// soundcraft-cli: SoundCraft without a window
@@ -168,6 +167,12 @@ pub(crate) struct CommandsArgs {
/// Only the commands whose id, or label in any case, contains this text
#[arg(value_parser = NonEmptyStringValueParser::new())]
pub(crate) filter: Option<String>,
/// Print a JSON array of each command's details, as describe prints them
#[arg(long)]
pub(crate) json: bool,
/// One line of JSON instead of pretty-printed
#[arg(long, requires = "json")]
pub(crate) compact: bool,
}
/// Print one engine command's details as JSON
@@ -355,7 +360,7 @@ pub(crate) enum BitDepthArg {
Float32,
}
impl From<BitDepthArg> for soundcraft_audio_io::BitDepth {
impl From<BitDepthArg> for craft_audio::BitDepth {
fn from(depth: BitDepthArg) -> Self {
match depth {
BitDepthArg::Int16 => Self::Int16,
+9 -5
View File
@@ -7,11 +7,15 @@ use std::io::Write;
pub(crate) fn commands(args: &CommandsArgs, out: &mut dyn Write) {
let filter = args.filter.clone().unwrap_or_default();
let e = Engine::default();
for c in soundcraft_engine::command_specs() {
if !filter.is_empty() && !c.id.contains(&filter) && !c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()) {
continue;
}
let i = c.info(&e);
let infos = soundcraft_engine::command_specs()
.iter()
.filter(|c| filter.is_empty() || c.id.contains(&filter) || c.label.to_ascii_lowercase().contains(&filter.to_ascii_lowercase()))
.map(|c| c.info(&e));
if args.json {
print_json(out, &infos.collect::<Vec<_>>(), args.compact);
return;
}
for i in infos {
outln!(out, "{:<36} {:<34} {}", i.id, i.label, i.menu.join(" > "));
}
}
+41 -9
View File
@@ -14,7 +14,17 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
};
let bytes = std::fs::read(input).map_err(|source| CliError::File { path: input.clone(), source })?;
let ext = input.extension().and_then(std::ffi::OsStr::to_str);
let (info, mut buf) = soundcraft_audio_io::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
let (info, mut buf) = craft_audio::decode(&bytes, ext).map_err(|source| CliError::Audio { path: input.clone(), source })?;
// The decoder replaces packets it cannot decode with silence of their length and counts them.
// Such a file is not a copy of what was recorded: never let it replace its source, and say so
// when it is written elsewhere.
if info.damaged > 0 {
if args.in_place {
return Err(ConvertError::Damaged { input: input.clone(), packets: info.damaged }.into());
}
log::warn!("{}: {} damaged packet(s) decoded as silence", input.display(), info.damaged);
}
let source_rate = buf.sample_rate;
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
@@ -27,16 +37,36 @@ pub(crate) fn convert(args: &ConvertArgs, out: &mut dyn Write) -> Result<(), Cli
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
// Lossy only for the message: an extension that is not valid Unicode names no format.
let out_ext = output.extension().map_or_else(|| "wav".into(), |e| e.to_string_lossy().to_ascii_lowercase());
let Some(format) = soundcraft_audio_io::FileFormat::from_write_extension(&out_ext) else {
let Some(format) = craft_audio::encode_format_for(&out_ext) else {
return Err(ConvertError::OutputFormat { output: output.clone(), ext: out_ext }.into());
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
// samples, so it moves to the output rate.
let mut opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither);
// The source's speakers, not the usual ones for its channel count (5.1 with side surrounds
// stays that). Channels with no speaker assignment get the format's default.
if info.layout.is_ordered() {
opts = opts.with_layout(info.layout);
}
opts.bwf = info.bwf.map(|mut b| {
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
b
});
let bytes = craft_audio::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
write_replacing(&args.write_path(), &bytes).map_err(|source| CliError::File { path: output.clone(), source })?;
outln!(out, "wrote {} ({} ch, {} Hz, {:.2} s)", output.display(), buf.num_channels(), buf.sample_rate, buf.duration_secs());
Ok(())
}
/// The sample position `pos` at `from` Hz as the nearest sample at `to` Hz.
fn at_rate(pos: u64, from: u32, to: u32) -> u64 {
if from == 0 || from == to {
return pos;
}
let scaled = (u128::from(pos) * u128::from(to) + u128::from(from) / 2) / u128::from(from);
u64::try_from(scaled).unwrap_or(u64::MAX)
}
/// Writes `bytes` to a new file next to `target`, flushes it and renames it over `target`, so `target`
/// (the input, for --in-place) holds either its old or its new contents. The new file is removed when
/// writing or renaming fails.
@@ -59,13 +89,15 @@ fn write_replacing(target: &std::path::Path, bytes: &[u8]) -> std::io::Result<()
/// source exactly, i.e. the output has fewer bits than the source, the source is float or lossy, or
/// resampling/normalizing produced new sample values. A 16-bit file written as 16 or 24-bit, or
/// anything written as 32-bit float, is copied sample for sample (digital silence stays silent).
fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_audio_io::BitDepth, processed: bool) -> bool {
use soundcraft_audio_io::{BitDepth, SampleFormat};
fn auto_dither(source: craft_audio::SampleFormat, target: craft_audio::BitDepth, processed: bool) -> bool {
use craft_audio::{BitDepth, SampleFormat};
let target_bits = match target {
BitDepth::Float32 => return false,
BitDepth::Int16 => 16,
BitDepth::Int24 => 24,
BitDepth::Int32 => 32,
// A depth added to craft-audio later: dither, as for any step that may lose resolution.
_ => return true,
};
if processed {
return true;
@@ -75,8 +107,8 @@ fn auto_dither(source: soundcraft_audio_io::SampleFormat, target: soundcraft_aud
SampleFormat::Int16 => target_bits < 16,
SampleFormat::Int24 => target_bits < 24,
// Decoded through f32, which holds 24 bits: a 32-bit integer source has already lost
// resolution, so treat it like float.
SampleFormat::Int32 | SampleFormat::Float32 | SampleFormat::Float64 | SampleFormat::Compressed => true,
// resolution, so treat it like float (as is any format added to craft-audio later).
_ => true,
}
}
@@ -86,7 +118,7 @@ mod tests {
#[test]
fn convert_dithers_only_when_resolution_is_lost() {
use soundcraft_audio_io::{BitDepth, SampleFormat};
use craft_audio::{BitDepth, SampleFormat};
// Same depth or wider: the samples fit exactly.
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int16, false));
assert!(!auto_dither(SampleFormat::Int16, BitDepth::Int24, false));
+4 -2
View File
@@ -1,6 +1,6 @@
use std::path::{Path, PathBuf};
use soundcraft_audio_io::AudioError;
use craft_audio::AudioError;
use soundcraft_automation::RemoteError;
use soundcraft_engine::EngineError;
use soundcraft_engine::file_access::RootError;
@@ -46,6 +46,8 @@ pub(crate) fn unicode(path: &Path) -> Result<&str, CliError> {
/// A `convert` option or output the tool refuses.
#[derive(Debug, PartialEq, thiserror::Error)]
pub(crate) enum ConvertError {
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), soundcraft_audio_io::FileFormat::writable_extensions().join(", "))]
#[error("{}: cannot write `.{ext}` files (supported: {})", output.display(), craft_audio::ENCODE_EXTENSIONS.join(", "))]
OutputFormat { output: PathBuf, ext: String },
#[error("{}: {packets} damaged packet(s) would be written as silence; not replacing the file (convert to another file to keep what decodes)", input.display())]
Damaged { input: PathBuf, packets: u64 },
}
+2 -2
View File
@@ -17,10 +17,10 @@ pub(crate) fn info(args: &InfoArgs, out: &mut dyn Write, err: &mut dyn Write) ->
}
let bytes = std::fs::read(path).map_err(|source| CliError::File { path: path.clone(), source })?;
let ext = path.extension().and_then(std::ffi::OsStr::to_str);
let i = soundcraft_audio_io::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
let i = craft_audio::probe(&bytes, ext).map_err(|source| CliError::Audio { path: path.clone(), source })?;
print_json(
out,
&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
&json!({"format": soundcraft_engine::model::FileFormat::of(&i), "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf}),
args.compact,
);
Ok(())
+1
View File
@@ -11,6 +11,7 @@ mod catalog;
mod convert;
mod error;
mod info;
pub mod logger;
mod mcp;
mod session;
+49
View File
@@ -0,0 +1,49 @@
//! The command line's logger. Without one, every `log::warn!` and `log::error!` in the workspace
//! was dropped: a plugin that failed to load just vanished from `engine.clap_plugins`, and nothing
//! said why. Warnings and errors now go to standard error as `warning: <message>` and
//! `error: <message>`, like the CLI's own errors; standard output keeps only the results.
//! `RUST_LOG` stays a desktop app variable (docs/cli.md), so the level is fixed.
use std::io::Write;
struct StderrLogger;
impl log::Log for StderrLogger {
fn enabled(&self, metadata: &log::Metadata<'_>) -> bool {
metadata.level() <= log::Level::Warn
}
fn log(&self, record: &log::Record<'_>) {
if self.enabled(record.metadata()) {
// A closed standard error is not worth failing a command over.
let _ = std::io::stderr().write_all(line(record.level(), &record.args().to_string()).as_bytes());
}
}
fn flush(&self) {}
}
/// One record as the CLI prints it.
fn line(level: log::Level, message: &str) -> String {
let label = if level == log::Level::Error { "error" } else { "warning" };
format!("{label}: {}\n", message.strip_suffix('\n').unwrap_or(message))
}
/// Installs the logger. Once per process: `main` calls it, not `run`, which tests call many times.
pub fn install() {
static LOGGER: StderrLogger = StderrLogger;
if log::set_logger(&LOGGER).is_ok() {
log::set_max_level(log::LevelFilter::Warn);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_print_like_the_cli_errors() {
assert_eq!(line(log::Level::Warn, "cannot load CLAP plugin x.clap: bad file\n"), "warning: cannot load CLAP plugin x.clap: bad file\n");
assert_eq!(line(log::Level::Error, "panic: boom"), "error: panic: boom\n");
}
}
+1
View File
@@ -3,6 +3,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
fn main() {
soundcraft_cli::logger::install();
// `args_os`, not `args`: a non-Unicode argument must reach clap, not panic before anything is printed.
let code = soundcraft_cli::run(std::env::args_os().skip(1), &mut std::io::stdout(), &mut std::io::stderr());
// The subcommand's engine and its plugin instances are gone by now. This is process teardown,
+83 -6
View File
@@ -5,7 +5,7 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use craft_audio::{AudioBuffer, BitDepth, BwfInfo, EncodeOptions};
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
@@ -20,8 +20,8 @@ fn write_wav16(path: &Path, samples: &[i16]) {
for (d, s) in buf.channels[0].iter_mut().zip(samples) {
*d = f32::from(*s) / 32768.0;
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
std::fs::write(path, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
}
fn convert(input: &Path, output: &Path, extra: &[&str]) {
@@ -38,7 +38,7 @@ fn convert(input: &Path, output: &Path, extra: &[&str]) {
/// The decoded samples of `path`, scaled to 16-bit steps.
fn steps16(path: &Path) -> Vec<f64> {
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap();
let (_, buf) = craft_audio::decode(&std::fs::read(path).unwrap(), None).unwrap();
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
}
@@ -81,8 +81,8 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() {
for (i, s) in buf.channels[0].iter_mut().enumerate() {
*s = 0.3 / 32768.0 * (i as f32 * 0.01).sin();
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int24);
std::fs::write(&src, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
let dithered = dir.join("d16.wav");
convert(&src, &dithered, &["--bit-depth", "16"]);
@@ -93,3 +93,80 @@ fn convert_dithers_when_reducing_depth_unless_told_not_to() {
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_keeps_the_bwf_time_stamp() {
let dir = tmpdir("bext");
let src = dir.join("take.wav");
// 01:00:00:00 at 48 kHz.
let bwf = BwfInfo::default()
.with_description("take 3")
.with_originator("recorder")
.with_origination("2026-11-03", "10:00:00")
.with_time_reference(172_800_000);
let buf = AudioBuffer::new(48_000, 1, 4800);
let opts = EncodeOptions::default().with_bwf(bwf.clone());
std::fs::write(&src, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
let bext = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
let out = dir.join("16.wav");
convert(&src, &out, &["--bit-depth", "16"]);
assert_eq!(bext(&out), Some(bwf.clone()));
// Resampled, the stamp still points at 01:00:00:00: 158,760,000 samples at 44.1 kHz.
let out = dir.join("44k.wav");
convert(&src, &out, &["--rate", "44100"]);
assert_eq!(bext(&out), Some(bwf.with_time_reference(158_760_000)));
let _ = std::fs::remove_dir_all(&dir);
}
/// Twelve MPEG-1 Layer I frames (mono, 32 kHz, 48 bytes each) of silence. The seventh has the
/// forbidden bit allocation 15 in every subband: the decoder writes silence for it and counts it.
fn mp1_with_a_damaged_frame() -> Vec<u8> {
let header = [0xFF, 0xFF, 0x18, 0xC0];
let good = [&header[..], &[0; 44]].concat();
let bad = [&header[..], &[0xFF; 16], &[0; 28]].concat();
(0..12).flat_map(|i| if i == 6 { bad.clone() } else { good.clone() }).collect()
}
#[test]
fn convert_warns_about_damaged_packets_and_never_replaces_the_source_with_them() {
let dir = tmpdir("damaged");
let src = dir.join("take.mp1");
let bytes = mp1_with_a_damaged_frame();
std::fs::write(&src, &bytes).unwrap();
let run = |args: &[&std::ffi::OsStr]| {
Command::new(env!("CARGO_BIN_EXE_soundcraft-cli")).env("SOUNDCRAFT_NO_PREFS", "1").arg("convert").args(args).output().unwrap()
};
// To another file: written, with a warning that says how much was lost.
let out = dir.join("take.wav");
let o = run(&[src.as_os_str(), out.as_os_str()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let warning = format!("warning: {}: 1 damaged packet(s) decoded as silence\n", src.display());
assert_eq!(String::from_utf8_lossy(&o.stderr), warning);
assert_eq!(steps16(&out).len(), 12 * 384);
// Over the source: refused, and the source is untouched.
let o = run(&[src.as_os_str(), src.as_os_str(), "--in-place".as_ref()]);
assert_eq!(o.status.code(), Some(1));
let stderr = String::from_utf8_lossy(&o.stderr);
assert!(stderr.starts_with(&format!("error: {}: 1 damaged packet(s) would be written as silence", src.display())), "{stderr}");
assert_eq!(std::fs::read(&src).unwrap(), bytes);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_keeps_the_speaker_layout() {
use craft_audio::ChannelLayout;
let dir = tmpdir("layout");
let (src, out) = (dir.join("side.wav"), dir.join("out.wav"));
// 5.1 with side surrounds: not the layout a reader assumes for six channels.
let opts = EncodeOptions::default().with_layout(ChannelLayout::SURROUND_5_1_SIDE);
std::fs::write(&src, craft_audio::encode(&AudioBuffer::new(48_000, 6, 480), &opts).unwrap()).unwrap();
let layout = |p: &Path| craft_audio::probe(&std::fs::read(p).unwrap(), None).unwrap().layout;
assert_eq!(layout(&src), ChannelLayout::SURROUND_5_1_SIDE);
convert(&src, &out, &[]);
assert_eq!(layout(&out), ChannelLayout::SURROUND_5_1_SIDE);
let flac = dir.join("out.flac");
convert(&src, &flac, &[]);
assert_eq!(layout(&flac), ChannelLayout::SURROUND_5_1_SIDE);
let _ = std::fs::remove_dir_all(&dir);
}
+28 -6
View File
@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use craft_audio::{AudioBuffer, BitDepth, EncodeOptions};
/// What one run of the binary produced.
struct Invoke {
@@ -37,20 +37,20 @@ fn sandbox(tag: &str) -> PathBuf {
for (i, d) in buf.channels[0].iter_mut().enumerate() {
*d = i as f32 / 400.0;
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(dir.join("in.wav"), soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
std::fs::write(dir.join("in.wav"), craft_audio::encode(&buf, &opts).unwrap()).unwrap();
std::fs::write(dir.join("s.txt"), "track.new {}\n").unwrap();
dir
}
/// The sample rate of the WAV `name` in `dir`.
fn rate_of(dir: &Path, name: &str) -> u32 {
soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
craft_audio::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_rate
}
/// Bit depth of the WAV `name` in `dir`, as the probe reports the sample format.
fn format_of(dir: &Path, name: &str) -> String {
format!("{:?}", soundcraft_audio_io::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
format!("{:?}", craft_audio::probe(&std::fs::read(dir.join(name)).unwrap(), None).unwrap().sample_format)
}
/// Exit 1, nothing on stdout, and exactly `error: {message}` on stderr.
@@ -88,6 +88,25 @@ fn assert_refused(got: &Invoke, port: u16) {
assert!(got.stderr.starts_with(&prefix) && got.stderr.contains(" (os error ") && got.stderr.ends_with(")\n"), "{}", got.stderr);
}
// ---- logging ----
/// Warnings SoundCraft logs (a plugin that fails to load, a backup it can't write) go to standard
/// error. Without a logger they were dropped.
#[test]
fn logged_warnings_reach_standard_error() {
let dir = sandbox("log");
let first = invoke(&dir, &["run", "--save", "s.scraft"]);
assert_eq!(first.code, 0, "{}", first.stderr);
// A file where the backups folder should go: the next save that changes the session can't
// back the old one up.
std::fs::write(dir.join("Backups"), b"not a folder").unwrap();
let again = invoke(&dir, &["run", "--in", "s.scraft", "--cmd", r#"track.new={"count":1}"#, "--save", "s.scraft", "--in-place"]);
assert_eq!(again.code, 0, "{}", again.stderr);
assert!(again.stderr.contains("warning: no backup of"), "{}", again.stderr);
assert!(!again.stdout.contains("no backup"), "results only on standard output: {}", again.stdout);
let _ = std::fs::remove_dir_all(&dir);
}
// ---- unknown flags ----
#[test]
@@ -167,7 +186,10 @@ fn repeated_flags_are_usage_errors_in_either_spelling() {
fn convert_input_and_output_errors() {
let dir = sandbox("convert-io");
assert_fails(&invoke(&dir, &["convert", "nope.wav", "o.wav"]), &format!("nope.wav: {}", not_found()));
assert_fails(&invoke(&dir, &["convert", "in.wav", "o.xyz"]), "o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, flac)");
assert_fails(
&invoke(&dir, &["convert", "in.wav", "o.xyz"]),
"o.xyz: cannot write `.xyz` files (supported: wav, wave, bwf, aif, aiff, aifc, flac)",
);
}
#[test]
+28
View File
@@ -253,3 +253,31 @@ fn script_bounce_without_range_covers_the_session() {
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
let _ = std::fs::remove_dir_all(base);
}
/// `commands --json` lists every command with the details `describe` prints for one (#744).
#[test]
fn commands_json_lists_what_describe_prints() {
let json = |o: &Output| -> Value {
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
serde_json::from_slice(&o.stdout).unwrap()
};
let all = json(&run(&["commands", "--json"]));
let all = all.as_array().unwrap();
let table = run(&["commands"]);
assert_eq!(all.len(), String::from_utf8_lossy(&table.stdout).lines().count());
let bounce = json(&run(&["describe", "file.bounce_mix"]));
assert!(all.contains(&bounce), "{bounce}");
assert!(all.iter().all(|c| c["id"].is_string() && c["params"].is_string() && c["menu"].is_array()));
// The filter applies, and --compact prints one line.
let compact = run(&["commands", "bounce", "--json", "--compact"]);
assert_eq!(String::from_utf8_lossy(&compact.stdout).lines().count(), 1);
let some = json(&compact);
let some = some.as_array().unwrap();
assert!(some.contains(&bounce) && some.len() < all.len(), "{some:?}");
// No match is an empty list, still JSON.
assert_eq!(json(&run(&["commands", "zzzzqq", "--json"])), Value::Array(Vec::new()));
// --compact shapes JSON output only.
let bad = run(&["commands", "--compact"]);
assert_eq!(bad.status.code(), Some(2));
assert!(String::from_utf8_lossy(&bad.stderr).contains("--json"), "{}", String::from_utf8_lossy(&bad.stderr));
}
+3 -4
View File
@@ -100,9 +100,8 @@ fn help_subcommand_shows_that_subcommands_help() {
#[test]
fn help_names_every_format_the_command_reads_and_writes() {
use soundcraft_audio_io::FileFormat;
let read = format!("({})", FileFormat::readable_extensions().join(", "));
let write = format!("its extension picks the format ({})", FileFormat::writable_extensions().join(", "));
let read = format!("({})", craft_audio::DECODE_EXTENSIONS.join(", "));
let write = format!("its extension picks the format ({})", craft_audio::ENCODE_EXTENSIONS.join(", "));
let convert = invoke(&["convert", "--help"]);
let convert = assert_help(&convert);
assert!(convert.contains(&read) && convert.contains(&write), "{convert}");
@@ -151,7 +150,7 @@ fn every_subcommand_has_help_listing_its_arguments() {
),
("run", "run [OPTIONS]", &["--cmd <ID[=JSON]>"]),
("script", "script [OPTIONS] <FILE>", &["<FILE>", "--keep-going"]),
("commands", "commands [FILTER]", &["[FILTER]"]),
("commands", "commands [OPTIONS] [FILTER]", &["[FILTER]", "--json", "--compact"]),
("describe", "describe [OPTIONS] <ID>", &["<ID>", "--compact"]),
(
"app",
+1 -1
View File
@@ -15,7 +15,7 @@ path = "src/main.rs"
[target.'cfg(target_arch = "wasm32")'.dependencies]
soundcraft-engine = { workspace = true }
soundcraft-audio-io.workspace = true
craft-audio.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
+2 -3
View File
@@ -1,6 +1,5 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use soundcraft_audio_io::FileFormat;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::sampler_window::SampleRead;
@@ -96,7 +95,7 @@ impl Purpose {
fn filter(self) -> (&'static str, Vec<&'static str>) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS.to_vec()),
Self::Audio => ("Audio", FileFormat::readable_extensions()),
Self::Audio => ("Audio", craft_audio::DECODE_EXTENSIONS.to_vec()),
Self::Midi => ("MIDI", MIDI_EXTS.to_vec()),
}
}
@@ -235,7 +234,7 @@ fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
Some("txt") => "text/plain",
Some("json" | "scraft") => "application/json",
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
ext => ext.and_then(FileFormat::from_extension).map_or("application/octet-stream", FileFormat::mime_type),
ext => ext.and_then(craft_audio::encode_format_for).map_or("application/octet-stream", craft_audio::EncodeFormat::mime_type),
});
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
+6 -2
View File
@@ -16,7 +16,7 @@ asio = ["soundcraft-playback/asio"]
[dependencies]
soundcraft-engine.workspace = true
soundcraft-audio-io.workspace = true
craft-audio.workspace = true
soundcraft-ui-egui.workspace = true
soundcraft-playback.workspace = true
soundcraft-automation.workspace = true
@@ -27,9 +27,13 @@ serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
+6 -5
View File
@@ -23,7 +23,6 @@ mod native_menu;
mod switch;
use env::Env;
use soundcraft_audio_io::FileFormat;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
use std::sync::Arc;
@@ -108,9 +107,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();
}
}
@@ -151,13 +152,13 @@ fn services() -> Services {
let d = match purpose {
"session.open" => d.add_filter("SoundCraft Session", &["scraft"]),
"file.import_midi" => d.add_filter("MIDI", &["mid", "midi", "smf"]),
_ => d.add_filter("Audio", &FileFormat::readable_extensions()),
_ => d.add_filter("Audio", craft_audio::DECODE_EXTENSIONS),
};
d.pick_file()
})),
pick_save: Some(Box::new(|purpose: &str, path: &std::path::Path| {
let mut d = match purpose {
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", &FileFormat::writable_extensions()),
"file.bounce_mix" => rfd::FileDialog::new().add_filter("Audio", craft_audio::ENCODE_EXTENSIONS),
_ => rfd::FileDialog::new().add_filter("SoundCraft Session", &["scraft"]),
};
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
@@ -185,7 +186,7 @@ fn app_icon() -> Option<egui::IconData> {
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::SOUNDCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
if let Some(result) = craft_update::self_update::finish(&soundcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so every start-up record (and the engine's panic hook, installed with the first
+3 -2
View File
@@ -33,6 +33,7 @@ impl NativeMenu {
let mut native = Self { menu, entries: Vec::new(), events, last_sync: None };
let app_menu = Submenu::new("SoundCraft", true);
native.add_item(app, &app_menu, "About SoundCraft", "", Some("window.about".into()))?;
native.add_item(app, &app_menu, "Preferences...", "", Some("setup.preferences".into()))?;
native.add_item(app, &app_menu, "Session Info", "", Some("window.session_info".into()))?;
native.add_item(app, &app_menu, "Session Audio Health", "", Some("window.audio_health".into()))?;
app_menu.append(&PredefinedMenuItem::separator())?;
@@ -94,8 +95,8 @@ impl NativeMenu {
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
if entry.path.is_empty() && command.starts_with("window.") {
// The application menu's About / Session Info open their window (never close it).
if entry.path.is_empty() && (command.starts_with("window.") || command == "setup.preferences") {
// Application-menu window entries open their window rather than toggling it.
let _ = app.run(command, serde_json::json!({"value": true}));
} else {
menus::invoke_menu(app, command, &entry.path);
+1 -1
View File
@@ -1,7 +1,7 @@
# SoundCraft app icon (placeholder)
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
owl-template framing: a full-bleed colour field, the bird
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
the bottom and right edges, three song arcs leaving the bill.
+9 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -65,3 +62,10 @@ sync_github_name = true
[people.luigiPinna]
real_name = "Luigi Pinna"
sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.kk376]
real_name = "Kushagra Kumar"
sync_github_name = true
+1 -1
View File
@@ -13,7 +13,7 @@ serde.workspace = true
thiserror.workspace = true
log.workspace = true
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+1 -1
View File
@@ -9,7 +9,7 @@
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
//!
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
-21
View File
@@ -1,21 +0,0 @@
[package]
name = "soundcraft-audio-io"
description = "SoundCraft audio file I/O: WAV/BWF/RF64, AIFF/AIFF-C native readers and writers, FLAC encoder, symphonia-backed decode of compressed formats, waveform peaks"
version.workspace = true
edition.workspace = true
license.workspace = true
rust-version.workspace = true
repository.workspace = true
[dependencies]
serde.workspace = true
thiserror.workspace = true
log.workspace = true
symphonia.workspace = true
[dev-dependencies]
proptest.workspace = true
serde_json.workspace = true
[lints]
workspace = true
-283
View File
@@ -1,283 +0,0 @@
//! Native AIFF / AIFF-C reader and writer (Apple "Audio Interchange File Format" 1.3 and the
//! AIFF-C draft: big-endian IFF chunks, 80-bit IEEE extended sample rate).
use crate::pcm::{PcmKind, Quantizer, be_u16, be_u32, deinterleave, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
/// AIFF-C version 1 timestamp for the FVER chunk.
const AIFC_VERSION_1: u32 = 0xA280_5140;
const MAX_SAMPLE_RATE: f64 = 10_000_000.0;
pub(crate) struct AiffLayout {
pub info: AudioInfo,
pub kind: PcmKind,
pub data_start: usize,
pub data_len: usize,
}
pub(crate) enum AiffParse {
Native(AiffLayout),
/// Compressed AIFF-C (ulaw, alaw, ima4, ...): handed to symphonia.
Foreign,
}
/// True when the bytes start like an AIFF or AIFF-C file.
pub(crate) fn is_aiff(b: &[u8]) -> bool {
tag(b, 0).is_some_and(|t| &t == b"FORM") && tag(b, 8).is_some_and(|t| &t == b"AIFF" || &t == b"AIFC")
}
/// Decode an 80-bit IEEE 754 extended-precision float.
pub(crate) fn extended_to_f64(b: &[u8]) -> Option<f64> {
let se = be_u16(b, 0)?;
let hi = u64::from(be_u32(b, 2)?);
let lo = u64::from(be_u32(b, 6)?);
let mant = (hi << 32) | lo;
if mant == 0 {
return Some(0.0);
}
let exp = i32::from(se & 0x7FFF);
if exp == 0x7FFF {
return None; // inf / NaN
}
let v = mant as f64 * 2f64.powi(exp - 16383 - 63);
Some(if se & 0x8000 != 0 { -v } else { v })
}
/// Encode a positive integer as an 80-bit IEEE 754 extended float (exact).
pub(crate) fn u32_to_extended(r: u32) -> [u8; 10] {
let mut out = [0u8; 10];
if r == 0 {
return out;
}
let e = 31 - r.leading_zeros();
let exp = (16383 + e) as u16;
let mant = u64::from(r) << (63 - e);
out[..2].copy_from_slice(&exp.to_be_bytes());
out[2..].copy_from_slice(&mant.to_be_bytes());
out
}
pub(crate) fn parse(b: &[u8]) -> Result<AiffParse> {
if !is_aiff(b) {
return Err(AudioError::Malformed("not an AIFF/AIFF-C file".into()));
}
let is_aifc = tag(b, 8).is_some_and(|t| &t == b"AIFC");
let mut comm: Option<(i16, u32, i16, f64, [u8; 4])> = None;
let mut ssnd: Option<(usize, usize)> = None;
let mut pos: usize = 12;
let mut chunks = 0usize;
while let (Some(id), Some(size32)) = (tag(b, pos), be_u32(b, pos + 4)) {
chunks += 1;
if chunks > 100_000 {
break;
}
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
let remaining = b.len().saturating_sub(body);
let declared = usize::try_from(size32).unwrap_or(usize::MAX);
let len = declared.min(remaining);
let c = b.get(body..body + len).unwrap_or_default();
match &id {
b"COMM" => {
let short = || AudioError::Malformed("COMM chunk too short".into());
let channels = be_u16(c, 0).ok_or_else(short)? as i16;
let frames = be_u32(c, 2).ok_or_else(short)?;
let bits = be_u16(c, 6).ok_or_else(short)? as i16;
let rate = extended_to_f64(c.get(8..18).ok_or_else(short)?).ok_or_else(|| AudioError::Malformed("invalid sample rate".into()))?;
let compression = if is_aifc { tag(c, 18).ok_or_else(short)? } else { *b"NONE" };
comm = Some((channels, frames, bits, rate, compression));
}
b"SSND" if ssnd.is_none() => {
let offset = usize::try_from(be_u32(c, 0).unwrap_or(0)).unwrap_or(usize::MAX);
let start = body.saturating_add(8).saturating_add(offset).min(b.len());
let data_len = len.saturating_sub(8).saturating_sub(offset);
ssnd = Some((start, data_len));
}
_ => {}
}
if declared > remaining {
break;
}
pos = match body.checked_add(declared).and_then(|p| p.checked_add(declared & 1)) {
Some(p) => p,
None => break,
};
}
let (channels, frames, bits, rate, compression) = comm.ok_or_else(|| AudioError::Malformed("AIFF has no COMM chunk".into()))?;
if channels <= 0 {
return Err(AudioError::Malformed("AIFF declares zero or negative channels".into()));
}
if !(1.0..=MAX_SAMPLE_RATE).contains(&rate) {
return Err(AudioError::Malformed(format!("AIFF sample rate {rate} out of range")));
}
let sample_rate = rate.round() as u32;
let int_kind = |big_endian: bool| -> Result<(PcmKind, SampleFormat)> {
if !(1..=32).contains(&bits) {
return Err(AudioError::Unsupported(format!("AIFF sample size {bits}")));
}
let bytes = (bits as usize).div_ceil(8);
let sf = match bits {
1..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
Ok((PcmKind::Int { bytes, big_endian, unsigned: false }, sf))
};
let (kind, sample_format) = match &compression {
b"NONE" | b"twos" => int_kind(true)?,
b"sowt" => int_kind(false)?,
b"fl32" | b"FL32" => (PcmKind::Float { bytes: 4, big_endian: true }, SampleFormat::Float32),
b"fl64" | b"FL64" => (PcmKind::Float { bytes: 8, big_endian: true }, SampleFormat::Float64),
_ => return Ok(AiffParse::Foreign),
};
let (data_start, data_len) = ssnd.unwrap_or((b.len(), 0));
let block = kind.bytes().saturating_mul(channels as usize).max(1);
let frames = u64::from(frames).min((data_len / block) as u64);
Ok(AiffParse::Native(AiffLayout {
info: AudioInfo { format: FileFormat::Aiff, sample_format, sample_rate, channels: channels as u16, frames, bwf: None },
kind,
data_start,
data_len,
}))
}
pub(crate) fn decode(b: &[u8], layout: &AiffLayout) -> Result<AudioBuffer> {
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
let data = b.get(layout.data_start..end).unwrap_or_default();
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
}
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
out.extend_from_slice(id);
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_be_bytes());
out.extend_from_slice(body);
if body.len() % 2 == 1 {
out.push(0);
}
}
/// Encode AIFF (integer depths) or AIFF-C `fl32` (Float32).
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let frames = buf.frames();
let head_len = header(buf.channels.len(), buf.sample_rate, depth, frames as u64)?.len();
let data_len = usize::from(sample_format(depth).0 / 8).saturating_mul(buf.channels.len()).saturating_mul(frames);
let total = head_len.saturating_add(data_len).saturating_add(1);
let mut out = Vec::new();
out.try_reserve_exact(total).map_err(|_| AudioError::TooLarge(format!("cannot allocate {total} bytes for AIFF output")))?;
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// An AIFF written block by block: a placeholder header first, then the real one (the same
/// length) written over it by [`Writer::finish`].
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
depth: BitDepth,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
/// Set when a block write has failed: the sink may hold a prefix of that block while
/// `frames` does not count it, so the writer refuses every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
validate_layout(channels, sample_rate)?;
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, depth, 0)?)?;
let quantizers = Quantizer::per_channel(u32::from(sample_format(depth).0), dither, channels);
Ok(Writer { out, start, channels, sample_rate, depth, quantizers, frames: 0, bytes: Vec::new(), failed: false })
}
/// The frame count after `frames` more, or an error when AIFF can't hold that many (4 GiB).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
header(self.channels, self.sample_rate, self.depth, total)?;
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, sample_format(self.depth), true) {
self.failed = true;
return Err(AudioError::from(e));
}
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
let data_len = u64::from(sample_format(self.depth).0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
}
let head = header(self.channels, self.sample_rate, self.depth, self.frames)?;
self.out.seek(SeekFrom::Start(self.start))?;
self.out.write_all(&head)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
/// Everything before the sample data of an AIFF holding `frames` frames; its length doesn't
/// depend on `frames`.
fn header(channels: usize, sample_rate: u32, depth: BitDepth, frames: u64) -> Result<Vec<u8>> {
let channels_i16 = i16::try_from(channels).map_err(|_| AudioError::Encode("too many channels for AIFF".into()))?;
let frames_u32 = u32::try_from(frames).map_err(|_| AudioError::TooLarge("too many frames for AIFF".into()))?;
let (bits, is_float) = sample_format(depth);
let data_len = u64::from(bits / 8).saturating_mul(channels as u64).saturating_mul(frames);
let mut comm = Vec::with_capacity(40);
comm.extend_from_slice(&channels_i16.to_be_bytes());
comm.extend_from_slice(&frames_u32.to_be_bytes());
comm.extend_from_slice(&bits.to_be_bytes());
comm.extend_from_slice(&u32_to_extended(sample_rate));
if is_float {
comm.extend_from_slice(b"fl32");
let name = b"32-bit float";
comm.push(name.len() as u8);
comm.extend_from_slice(name);
if (name.len() + 1) % 2 == 1 {
comm.push(0);
}
}
let mut head = Vec::with_capacity(128);
head.extend_from_slice(b"FORM");
head.extend_from_slice(&[0, 0, 0, 0]); // patched below
head.extend_from_slice(if is_float { b"AIFC" } else { b"AIFF" });
if is_float {
push_chunk(&mut head, b"FVER", &AIFC_VERSION_1.to_be_bytes());
}
push_chunk(&mut head, b"COMM", &comm);
head.extend_from_slice(b"SSND");
let ssnd_len = u32::try_from(data_len.saturating_add(8)).map_err(|_| AudioError::TooLarge("AIFF data exceeds 4 GiB".into()))?;
head.extend_from_slice(&ssnd_len.to_be_bytes());
head.extend_from_slice(&[0u8; 8]); // offset, blockSize
let total = (head.len() as u64).saturating_add(data_len).saturating_add(data_len & 1);
let form_len = u32::try_from(total - 8).map_err(|_| AudioError::TooLarge("AIFF file exceeds 4 GiB".into()))?;
if let Some(slot) = head.get_mut(4..8) {
slot.copy_from_slice(&form_len.to_be_bytes());
}
Ok(head)
}
-71
View File
@@ -1,71 +0,0 @@
//! Planar `f32` audio buffer.
/// Planar f32 audio.
///
/// Every channel is expected to hold the same number of frames; [`AudioBuffer::frames`] reports the
/// shortest channel so a ragged buffer never causes out-of-bounds access.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct AudioBuffer {
/// Sample rate in Hz.
pub sample_rate: u32,
/// One `Vec` of samples per channel.
pub channels: Vec<Vec<f32>>,
}
impl AudioBuffer {
/// A silent buffer with `channels` channels of `frames` frames each.
pub fn new(sample_rate: u32, channels: usize, frames: usize) -> Self {
Self { sample_rate, channels: vec![vec![0.0; frames]; channels] }
}
/// Number of frames (the length of the shortest channel; 0 when there are no channels).
pub fn frames(&self) -> usize {
self.channels.iter().map(Vec::len).min().unwrap_or(0)
}
/// Number of channels.
pub fn num_channels(&self) -> usize {
self.channels.len()
}
/// Duration in seconds (0 when the sample rate is 0).
pub fn duration_secs(&self) -> f64 {
if self.sample_rate == 0 {
return 0.0;
}
self.frames() as f64 / f64::from(self.sample_rate)
}
/// Interleaved copy of the samples (frame-major: `L0 R0 L1 R1 ...`).
pub fn interleaved(&self) -> Vec<f32> {
let frames = self.frames();
let mut out = Vec::with_capacity(frames.saturating_mul(self.channels.len()));
for f in 0..frames {
for ch in &self.channels {
out.push(ch.get(f).copied().unwrap_or(0.0));
}
}
out
}
/// Build a planar buffer from interleaved samples. A trailing partial frame is dropped;
/// `channels == 0` yields an empty buffer.
pub fn from_interleaved(sr: u32, channels: usize, data: &[f32]) -> Self {
if channels == 0 {
return Self { sample_rate: sr, channels: Vec::new() };
}
let frames = data.len() / channels;
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
for frame in data.chunks_exact(channels) {
for (ch, &s) in out.iter_mut().zip(frame) {
ch.push(s);
}
}
Self { sample_rate: sr, channels: out }
}
/// Absolute peak over all channels (NaN samples are ignored).
pub fn peak(&self) -> f32 {
self.channels.iter().flat_map(|c| c.iter()).fold(0.0f32, |acc, &s| acc.max(s.abs()))
}
}
-87
View File
@@ -1,87 +0,0 @@
//! Size checks on CAF chunks before symphonia reads them: it allocates whatever a magic cookie,
//! a packet table or a packet claims, and a failed allocation aborts the process.
use crate::pcm::{be_u16, be_u32, be_u64};
use crate::{AudioError, Result};
/// Refuse the CAF stream at `at` if its magic cookie, packet table or packets claim more bytes
/// than the file holds.
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
if be_u16(b, at + 4) == Some(1) { chunks(b, at + 8) } else { Ok(()) }
}
fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("CAF {what} is larger than the file"))
}
/// Walk the chunks the way the CAF reader reads them: it advances by the bytes it parses, not by
/// the declared size, and after a `data` chunk of unknown size it reads on into the audio.
fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
// Whether the packet table lists each packet's size and frame count (variable per packet).
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
let (body, size) = (pos + 12, size as i64);
let used = match kind {
b"desc" if size == 32 => {
lists_sizes = be_u32(b, body + 16) == Some(0);
lists_frames = be_u32(b, body + 20) == Some(0);
described = true;
Some(32)
}
b"data" if size == -1 => Some(4),
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"desc" | b"pakt" | b"chan" => None,
_ => u64::try_from(size).ok(),
};
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
Some(next) if next < b.len() => pos = next,
_ => return Ok(()),
}
}
Ok(())
}
/// Check a packet table's count and packet sizes; returns the bytes symphonia reads for it, or
/// None where symphonia stops. It reserves room for every packet before reading any, so a table
/// it would refuse after that, or that runs past the file, is refused first.
fn packet_table(b: &[u8], body: usize, lists_sizes: bool, lists_frames: bool) -> Result<Option<u64>> {
let valid = |at: usize| be_u64(b, at).filter(|&n| n as i64 >= 0);
let (Some(packets), Some(_)) = (valid(body), valid(body + 8)) else { return Ok(None) };
let mut at = body + 24;
if packets > b.len().saturating_sub(at) as u64 {
return Err(malformed("packet table"));
}
if !lists_sizes && !lists_frames {
return if packets == 0 { Ok(Some(24)) } else { Err(AudioError::Malformed("a constant bit rate CAF lists packets".into())) };
}
for _ in 0..packets {
if lists_sizes {
let Some((size, len)) = varint(b, at) else { return Err(malformed("packet table")) };
if size > b.len() as u64 {
return Err(malformed("packet"));
}
at += len;
}
if lists_frames {
let Some((_, len)) = varint(b, at) else { return Err(malformed("packet table")) };
at += len;
}
}
Ok(Some((at - body) as u64))
}
/// A CAF variable-length integer at `at` and its length in bytes.
fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
let mut v = 0u64;
for i in 0..9 {
let x = *b.get(at + i)?;
v |= u64::from(x & 0x7f);
if x & 0x80 == 0 {
return Some((v, i + 1));
}
v <<= 7;
}
None
}
-560
View File
@@ -1,560 +0,0 @@
//! A small pure-Rust FLAC encoder (RFC 9639): fixed blocksize, independent channels, and per
//! subframe the cheapest of CONSTANT, VERBATIM or FIXED (orders 0–4) prediction with
//! partitioned Rice-coded residuals. Decoding is symphonia's, after [`check_metadata`].
use crate::pcm::{Quantizer, be_u32, le_u32, validate_buffer};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
const BLOCK_SIZE: usize = 4096;
const MAX_PARTITION_ORDER: u32 = 6;
struct BitWriter {
out: Vec<u8>,
acc: u64,
nbits: u32,
}
impl BitWriter {
fn new() -> Self {
Self { out: Vec::new(), acc: 0, nbits: 0 }
}
/// Write the low `n` (≤ 32) bits of `v`, MSB first.
fn write(&mut self, v: u64, n: u32) {
if n == 0 {
return;
}
let n = n.min(32);
let mask = (1u64 << n) - 1;
self.acc = (self.acc << n) | (v & mask);
self.nbits += n;
while self.nbits >= 8 {
self.nbits -= 8;
self.out.push((self.acc >> self.nbits) as u8);
}
self.acc &= (1u64 << self.nbits) - 1;
}
fn write_signed(&mut self, v: i64, n: u32) {
self.write(v as u64, n);
}
/// `q` zero bits followed by a one bit.
fn write_unary(&mut self, mut q: u64) {
while q >= 32 {
self.write(0, 32);
q -= 32;
}
self.write(1, (q as u32) + 1);
}
fn align(&mut self) {
if self.nbits > 0 {
let pad = 8 - self.nbits;
self.write(0, pad);
}
}
fn into_bytes(mut self) -> Vec<u8> {
self.align();
self.out
}
}
fn crc8(data: &[u8]) -> u8 {
let mut crc: u8 = 0;
for &b in data {
crc ^= b;
for _ in 0..8 {
crc = if crc & 0x80 != 0 { (crc << 1) ^ 0x07 } else { crc << 1 };
}
}
crc
}
fn crc16(data: &[u8]) -> u16 {
let mut crc: u16 = 0;
for &b in data {
crc ^= u16::from(b) << 8;
for _ in 0..8 {
crc = if crc & 0x8000 != 0 { (crc << 1) ^ 0x8005 } else { crc << 1 };
}
}
crc
}
/// FLAC's UTF-8-like variable-length integer coding (up to 36 bits).
fn write_utf8_number(w: &mut BitWriter, v: u64) {
if v < 0x80 {
w.write(v, 8);
return;
}
// Payload bits available for an n-byte coding: 5n + 1 (n = 2..=6), 36 for n = 7.
let n: u32 = (2..=7).find(|&n| if n == 7 { true } else { v < (1u64 << (5 * n + 1)) }).unwrap_or(7);
let cont_bits = 6 * (n - 1);
let lead_payload = if n == 7 { 0 } else { 7 - n };
let prefix: u64 = (0xFFu64 << (8 - n)) & 0xFF;
let lead = prefix | ((v >> cont_bits) & ((1u64 << lead_payload) - 1));
w.write(lead, 8);
for i in (0..n - 1).rev() {
w.write(0x80 | ((v >> (6 * i)) & 0x3F), 8);
}
}
fn sample_rate_code(r: u32) -> (u64, Option<(u64, u32)>) {
match r {
88_200 => (1, None),
176_400 => (2, None),
192_000 => (3, None),
8_000 => (4, None),
16_000 => (5, None),
22_050 => (6, None),
24_000 => (7, None),
32_000 => (8, None),
44_100 => (9, None),
48_000 => (10, None),
96_000 => (11, None),
r if r % 1000 == 0 && r / 1000 <= 255 => (12, Some((u64::from(r / 1000), 8))),
r if r <= 65_535 => (13, Some((u64::from(r), 16))),
r if r % 10 == 0 && r / 10 <= 65_535 => (14, Some((u64::from(r / 10), 16))),
_ => (0, None),
}
}
fn bps_code(bps: u32) -> u64 {
match bps {
8 => 1,
12 => 2,
16 => 4,
20 => 5,
24 => 6,
_ => 0, // from STREAMINFO
}
}
fn zigzag(r: i64) -> u64 {
((r << 1) ^ (r >> 63)) as u64
}
/// Fixed-predictor residuals for `order`, or `None` when any residual leaves the i32 range.
fn fixed_residual(x: &[i64], order: usize) -> Option<Vec<i64>> {
let mut res = Vec::with_capacity(x.len().saturating_sub(order));
for n in order..x.len() {
let g = |k: usize| x.get(n - k).copied().unwrap_or(0);
let r = match order {
0 => g(0),
1 => g(0) - g(1),
2 => g(0) - 2 * g(1) + g(2),
3 => g(0) - 3 * g(1) + 3 * g(2) - g(3),
_ => g(0) - 4 * g(1) + 6 * g(2) - 4 * g(3) + g(4),
};
if r < i64::from(i32::MIN) || r > i64::from(i32::MAX) {
return None;
}
res.push(r);
}
Some(res)
}
/// Best Rice parameter and its cost in bits for one partition.
fn best_rice(part: &[u64]) -> (u32, u64) {
if part.is_empty() {
return (0, 0);
}
let sum: u64 = part.iter().sum();
let mean = sum / part.len() as u64;
let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() };
let mut best = (0u32, u64::MAX);
for k in guess.saturating_sub(1)..=(guess + 1).min(30) {
let cost = part.len() as u64 * u64::from(k + 1) + part.iter().map(|&u| u >> k).sum::<u64>();
if cost < best.1 {
best = (k, cost);
}
}
best
}
/// Residual coding plan: partition order, per-partition parameters, total bits.
struct RicePlan {
order: u32,
params: Vec<u32>,
bits: u64,
wide: bool,
}
fn plan_residual(u: &[u64], block_len: usize, pred_order: usize) -> RicePlan {
let mut best: Option<RicePlan> = None;
for p in 0..=MAX_PARTITION_ORDER {
let parts = 1usize << p;
if !block_len.is_multiple_of(parts) || block_len / parts <= pred_order {
break;
}
let per = block_len / parts;
let mut params = Vec::with_capacity(parts);
let mut bits = 0u64;
let mut start = 0usize;
for i in 0..parts {
let len = if i == 0 { per - pred_order } else { per };
let part = u.get(start..start + len).unwrap_or_default();
start += len;
let (k, cost) = best_rice(part);
params.push(k);
bits += cost;
}
let wide = params.iter().any(|&k| k > 14);
bits += 6 + parts as u64 * if wide { 5 } else { 4 };
if best.as_ref().is_none_or(|b| bits < b.bits) {
best = Some(RicePlan { order: p, params, bits, wide });
}
}
best.unwrap_or(RicePlan { order: 0, params: vec![0], bits: u64::MAX, wide: false })
}
fn write_residual(w: &mut BitWriter, u: &[u64], block_len: usize, pred_order: usize, plan: &RicePlan) {
w.write(u64::from(plan.wide), 2);
w.write(u64::from(plan.order), 4);
let parts = 1usize << plan.order;
let per = block_len / parts;
let mut start = 0usize;
for (i, &k) in plan.params.iter().enumerate() {
let len = if i == 0 { per - pred_order } else { per };
w.write(u64::from(k), if plan.wide { 5 } else { 4 });
for &v in u.get(start..start + len).unwrap_or_default() {
w.write_unary(v >> k);
w.write(v, k);
}
start += len;
}
}
fn write_subframe(w: &mut BitWriter, x: &[i64], bps: u32) {
let n = x.len();
let first = x.first().copied().unwrap_or(0);
if x.iter().all(|&s| s == first) {
w.write(0, 1);
w.write(0, 6);
w.write(0, 1);
w.write_signed(first, bps);
return;
}
let verbatim_bits = n as u64 * u64::from(bps);
let mut best: Option<(usize, Vec<u64>, RicePlan)> = None;
for order in 0..=4usize.min(n.saturating_sub(1)) {
let Some(res) = fixed_residual(x, order) else { continue };
let u: Vec<u64> = res.into_iter().map(zigzag).collect();
let plan = plan_residual(&u, n, order);
let bits = plan.bits.saturating_add(order as u64 * u64::from(bps));
if bits < verbatim_bits && best.as_ref().is_none_or(|b| bits < b.2.bits.saturating_add(b.0 as u64 * u64::from(bps))) {
best = Some((order, u, plan));
}
}
match best {
Some((order, u, plan)) => {
w.write(0, 1);
w.write(8 + order as u64, 6);
w.write(0, 1);
for &s in x.iter().take(order) {
w.write_signed(s, bps);
}
write_residual(w, &u, n, order, &plan);
}
None => {
w.write(0, 1);
w.write(1, 6);
w.write(0, 1);
for &s in x {
w.write_signed(s, bps);
}
}
}
}
/// Checks that FLAC can hold the layout; returns the bits per sample.
fn check(channels: usize, sample_rate: u32, depth: BitDepth) -> Result<u32> {
crate::pcm::validate_layout(channels, sample_rate)?;
if channels > 8 {
return Err(AudioError::Unsupported(format!("FLAC supports at most 8 channels, got {channels}")));
}
if sample_rate > 655_350 {
return Err(AudioError::Unsupported(format!("FLAC sample rate {sample_rate} exceeds 655350 Hz")));
}
match depth {
BitDepth::Int16 => Ok(16),
BitDepth::Int24 => Ok(24),
BitDepth::Int32 => Ok(32),
BitDepth::Float32 => Err(AudioError::Unsupported("FLAC cannot store 32-bit float samples".into())),
}
}
fn stream_info(channels: usize, sample_rate: u32, bps: u32, total: u64) -> Vec<u8> {
let mut si = BitWriter::new();
si.write(1, 1); // last metadata block
si.write(0, 7); // STREAMINFO
si.write(34, 24);
si.write(BLOCK_SIZE as u64, 16);
si.write(BLOCK_SIZE as u64, 16);
si.write(0, 24);
si.write(0, 24);
si.write(u64::from(sample_rate), 20);
si.write(channels as u64 - 1, 3);
si.write(u64::from(bps - 1), 5);
si.write(total >> 32, 4);
si.write(total & 0xFFFF_FFFF, 32);
for _ in 0..4 {
si.write(0, 32); // MD5 unknown
}
si.into_bytes()
}
/// A FLAC stream written block by block; [`Writer::finish`] fills in the total sample count.
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
bps: u32,
quantizers: Vec<Quantizer>,
pending: Vec<Vec<i64>>,
frames: u64,
frame_no: u64,
/// Set when a frame write has failed: `write_frame` clears the pending samples before
/// writing, so a failed frame is lost while `frames` still counts it; the writer refuses
/// every later call.
failed: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool) -> Result<Self> {
let bps = check(channels, sample_rate, depth)?;
let start = out.stream_position()?;
out.write_all(b"fLaC")?;
out.write_all(&stream_info(channels, sample_rate, bps, 0))?;
Ok(Writer {
out,
start,
channels,
sample_rate,
bps,
quantizers: Quantizer::per_channel(bps, dither, channels),
pending: (0..channels).map(|_| Vec::with_capacity(BLOCK_SIZE)).collect(),
frames: 0,
frame_no: 0,
failed: false,
})
}
/// The frame count after `frames` more, or an error past what FLAC can count (2^36 frames).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
if total >= 1u64 << 36 {
return Err(AudioError::TooLarge("too many frames for FLAC".into()));
}
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence), unless they don't
/// fit (see [`Writer::check_room`]).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
let mut done = 0;
while done < frames {
let held = self.pending.first().map_or(0, Vec::len);
let n = (BLOCK_SIZE - held).min(frames - done);
for (c, (p, q)) in self.pending.iter_mut().zip(self.quantizers.iter_mut()).enumerate() {
let src = block.get(c).and_then(|ch| ch.get(done..)).unwrap_or_default();
p.extend((0..n).map(|i| i64::from(q.quantize(src.get(i).copied().unwrap_or(0.0)))));
}
done += n;
if held + n == BLOCK_SIZE
&& let Err(e) = self.write_frame()
{
self.failed = true;
return Err(e);
}
}
self.frames = total;
Ok(())
}
fn write_frame(&mut self) -> Result<()> {
let len = self.pending.first().map_or(0, Vec::len);
if len == 0 {
return Ok(());
}
let (sr_code, sr_extra) = sample_rate_code(self.sample_rate);
let mut w = BitWriter::new();
w.write(0x3FFE, 14);
w.write(0, 1);
w.write(0, 1); // fixed blocksize stream
w.write(7, 4); // 16-bit (blocksize-1) follows
w.write(sr_code, 4);
w.write(self.channels as u64 - 1, 4);
w.write(bps_code(self.bps), 3);
w.write(0, 1);
write_utf8_number(&mut w, self.frame_no);
w.write(len as u64 - 1, 16);
if let Some((v, n)) = sr_extra {
w.write(v, n);
}
let header = w.into_bytes();
let mut w = BitWriter::new();
for &b in &header {
w.write(u64::from(b), 8);
}
w.write(u64::from(crc8(&header)), 8);
for p in &mut self.pending {
write_subframe(&mut w, p, self.bps);
p.clear();
}
let mut frame = w.into_bytes();
let crc = crc16(&frame);
frame.extend_from_slice(&crc.to_be_bytes());
self.out.write_all(&frame)?;
self.frame_no += 1;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
self.write_frame()?;
let info = stream_info(self.channels, self.sample_rate, self.bps, self.frames);
self.out.seek(SeekFrom::Start(self.start.saturating_add(4)))?;
self.out.write_all(&info)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
pub(crate) fn encode(buf: &AudioBuffer, depth: BitDepth, dither: bool) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let bps = check(buf.channels.len(), buf.sample_rate, depth)?;
let frames = buf.frames();
let mut out = Vec::new();
out.try_reserve(frames.saturating_mul(buf.channels.len()).saturating_mul(bps as usize / 8) / 2)
.map_err(|_| AudioError::TooLarge("cannot allocate FLAC output".into()))?;
let mut w = Writer::new(Cursor::new(out), buf.channels.len(), buf.sample_rate, depth, dither)?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// Refuse the FLAC stream at `at` if a comment or picture field claims more bytes than its
/// metadata block holds: symphonia allocates each field's length before reading it.
pub(crate) fn check_metadata(b: &[u8], at: usize) -> Result<()> {
let mut pos = at + 4;
while let Some(head) = be_u32(b, pos) {
let (body, len) = (pos + 4, (head & 0x00ff_ffff) as usize);
let end = (body + len).min(b.len());
let overruns = match (head >> 24) & 0x7f {
4 => comments_overrun(b, body, end),
// Picture: type, MIME type, description, four size fields, data.
6 => overruns(b, body + 4, end, be_u32, [0, 0, 16]),
_ => false,
};
if overruns {
return Err(AudioError::Malformed("a FLAC metadata field is larger than its block".into()));
}
if head >> 31 == 1 {
break;
}
pos = body + len;
}
Ok(())
}
/// The info of a FLAC stream with no audio frames at all: STREAMINFO declares 0 samples and
/// nothing follows the metadata blocks. Symphonia's reader fails on these, as it looks for a first
/// frame, so the caller answers them itself.
pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
let mut pos = at.checked_add(4)?;
let mut first = true;
loop {
let head = be_u32(b, pos)?;
let (body, len) = (pos.checked_add(4)?, (head & 0x00ff_ffff) as usize);
let end = body.checked_add(len)?;
if end > b.len() {
return None;
}
if first && ((head >> 24) & 0x7f != 0 || len != 34) {
return None;
}
first = false;
if head >> 31 == 1 {
if end != b.len() {
return None;
}
break;
}
pos = end;
}
// STREAMINFO, from the sample rate on: 20 bits rate, 3 channels - 1, 5 bits per sample - 1, 36 bits total.
let si = at.checked_add(4 + 4 + 10)?;
let packed = u64::from_be_bytes(b.get(si..si.checked_add(8)?)?.try_into().ok()?);
let sample_rate = (packed >> 44) as u32;
let channels = ((packed >> 41) & 0x7) as u16 + 1;
let bps = ((packed >> 36) & 0x1f) as u32 + 1;
if packed & 0xf_ffff_ffff != 0 || sample_rate == 0 {
return None;
}
let sample_format = match bps {
1..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
}
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
/// The vendor string is skipped unread.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body) else { return false };
let at = (body + 4).saturating_add(vendor as usize);
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
}
/// Whether one of the length-prefixed fields from `at` claims more than `end` leaves; `gaps` are
/// the bytes skipped before each length, which `read` reads.
fn overruns(b: &[u8], mut at: usize, end: usize, read: fn(&[u8], usize) -> Option<u32>, gaps: impl IntoIterator<Item = usize>) -> bool {
for gap in gaps {
at = at.saturating_add(gap);
let Some(n) = read(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
at += 4;
if n as usize > end - at {
return true;
}
at += n as usize;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn crc_known_values() {
// CRC-8/SMBUS and CRC-16/UMTS (FLAC's polynomials) of "123456789".
assert_eq!(crc8(b"123456789"), 0xF4);
assert_eq!(crc16(b"123456789"), 0xFEE8);
}
#[test]
fn utf8_numbers() {
let enc = |v| {
let mut w = BitWriter::new();
write_utf8_number(&mut w, v);
w.into_bytes()
};
assert_eq!(enc(0x7F), vec![0x7F]);
assert_eq!(enc(0x80), vec![0xC2, 0x80]);
assert_eq!(enc(0x800), vec![0xE0, 0xA0, 0x80]);
}
}
-459
View File
@@ -1,459 +0,0 @@
//! SoundCraft audio file I/O.
//!
//! Bytes in, bytes out (no filesystem access, so the crate also builds for `wasm32`):
//!
//! * [`detect_format`] / [`probe`] / [`decode`] read WAV (PCM 8/16/24/32, float 32/64,
//! `WAVE_FORMAT_EXTENSIBLE`, RF64/BW64, BWF `bext`) and AIFF/AIFF-C (big-endian, `sowt`,
//! `fl32`/`fl64`) with native readers, and everything else (FLAC, MP3, Ogg Vorbis, AAC/ALAC in
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
//! encoder), with optional TPDF dither.
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
//! * [`FileEncoder`] writes the same formats block by block into any `Write + Seek`, so a file
//! of any length never has to fit in memory.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod aiff;
mod buffer;
mod caf;
mod flac;
mod mp4;
mod pcm;
pub mod peaks;
mod symph;
mod wav;
pub use buffer::AudioBuffer;
use std::io::{Seek, Write};
/// Errors from probing, decoding and encoding.
#[derive(Debug, thiserror::Error)]
pub enum AudioError {
/// The format, codec or option is valid but not supported.
#[error("unsupported audio: {0}")]
Unsupported(String),
/// The input is corrupt or not what it claims to be.
#[error("malformed audio: {0}")]
Malformed(String),
/// The input (or requested output) exceeds a size limit.
#[error("audio too large: {0}")]
TooLarge(String),
/// Encoding failed (e.g. an empty buffer).
#[error("audio encode failed: {0}")]
Encode(String),
/// Writing the output failed.
#[error("audio write failed: {0}")]
Io(#[from] std::io::Error),
}
/// Result alias for this crate.
pub type Result<T> = std::result::Result<T, AudioError>;
/// Container / codec family of an audio file.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum FileFormat {
Wav,
Aiff,
Flac,
Mp3,
Ogg,
Aac,
Alac,
Caf,
/// Any other container `symphonia` reads: Matroska and WebM by name.
Other,
}
impl FileFormat {
/// The formats [`decode`] reads, found by extension. Contents decide when they disagree.
pub const READ: &'static [FileFormat] =
&[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac, FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Caf, FileFormat::Other];
/// The formats [`encode`] writes.
pub const WRITE: &'static [FileFormat] = &[FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac];
/// The extensions that name this format, lower case and without the dot; the first is the one
/// a new file gets. ALAC shares AAC's MP4 container, so a `.m4a` name reads as AAC until the
/// contents say otherwise.
pub fn extensions(self) -> &'static [&'static str] {
match self {
FileFormat::Wav => &["wav", "wave", "bwf", "rf64"],
FileFormat::Aiff => &["aif", "aiff", "aifc"],
FileFormat::Flac => &["flac"],
FileFormat::Mp3 => &["mp3", "mp2", "mp1"],
FileFormat::Ogg => &["ogg", "oga"],
FileFormat::Aac => &["m4a", "m4b", "aac", "mp4", "adts"],
FileFormat::Alac => &["m4a"],
FileFormat::Caf => &["caf"],
FileFormat::Other => &["mkv", "webm"],
}
}
/// The name people know the format by, as menus show it.
pub fn name(self) -> &'static str {
match self {
FileFormat::Wav => "WAV",
FileFormat::Aiff => "AIFF",
FileFormat::Flac => "FLAC",
FileFormat::Mp3 => "MP3",
FileFormat::Ogg => "Ogg Vorbis",
FileFormat::Aac => "AAC",
FileFormat::Alac => "ALAC",
FileFormat::Caf => "CAF",
FileFormat::Other => "Other",
}
}
/// The media type a file of this format is served or downloaded as.
pub fn mime_type(self) -> &'static str {
match self {
FileFormat::Wav => "audio/wav",
FileFormat::Aiff => "audio/aiff",
FileFormat::Flac => "audio/flac",
FileFormat::Mp3 => "audio/mpeg",
FileFormat::Ogg => "audio/ogg",
FileFormat::Aac | FileFormat::Alac => "audio/mp4",
FileFormat::Caf => "audio/x-caf",
FileFormat::Other => "application/octet-stream",
}
}
/// The extension a new file of this format gets.
pub fn default_extension(self) -> &'static str {
self.extensions().first().copied().unwrap_or("bin")
}
/// The readable format an extension or format name names (`wav`, `.AIFF`; ASCII
/// case-insensitive), or `None` when nothing reads it.
pub fn from_extension(ext: &str) -> Option<FileFormat> {
let ext = ext.trim_start_matches('.');
Self::READ.iter().copied().find(|f| f.extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
}
/// The readable format `path`'s extension names, or `None` without a known extension.
pub fn from_path(path: impl AsRef<std::path::Path>) -> Option<FileFormat> {
path.as_ref().extension().and_then(std::ffi::OsStr::to_str).and_then(Self::from_extension)
}
/// The extensions [`encode`] writes this format under: the names whose readers expect what it
/// writes. RF64 and AIFF-C names are left out because `encode` writes plain RIFF and AIFF
/// headers for them.
pub fn write_extensions(self) -> &'static [&'static str] {
match self {
FileFormat::Wav => &["wav", "wave", "bwf"],
FileFormat::Aiff => &["aif", "aiff"],
FileFormat::Flac => &["flac"],
_ => &[],
}
}
/// The format [`encode`] writes for an extension or format name (ASCII case-insensitive), or
/// `None` when it writes none.
pub fn from_write_extension(ext: &str) -> Option<FileFormat> {
let ext = ext.trim_start_matches('.');
Self::WRITE.iter().copied().find(|f| f.write_extensions().iter().any(|x| x.eq_ignore_ascii_case(ext)))
}
/// Every extension of [`FileFormat::READ`], in order: what file pickers and browsers filter by.
pub fn readable_extensions() -> Vec<&'static str> {
Self::READ.iter().flat_map(|f| f.extensions().iter().copied()).collect()
}
/// Every extension of [`FileFormat::WRITE`], in order: what save dialogs, help and errors list.
pub fn writable_extensions() -> Vec<&'static str> {
Self::WRITE.iter().flat_map(|f| f.write_extensions().iter().copied()).collect()
}
}
/// Stored sample representation.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum SampleFormat {
Int8,
Int16,
Int24,
Int32,
Float32,
Float64,
/// A lossy codec (MP3, AAC, Vorbis, ...): no fixed bit depth.
Compressed,
}
/// Broadcast Wave (`bext`) metadata.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct BwfInfo {
pub description: String,
pub originator: String,
pub originator_reference: String,
/// `yyyy-mm-dd`.
pub origination_date: String,
/// `hh:mm:ss`.
pub origination_time: String,
/// Sample count since midnight of the first sample (the timestamp Pro Tools spots to).
pub time_reference: u64,
}
/// What [`probe`] learns about a file without decoding all of it.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct AudioInfo {
pub format: FileFormat,
pub sample_format: SampleFormat,
pub sample_rate: u32,
pub channels: u16,
pub frames: u64,
pub bwf: Option<BwfInfo>,
}
impl AudioInfo {
/// Duration in seconds.
pub fn duration_secs(&self) -> f64 {
if self.sample_rate == 0 { 0.0 } else { self.frames as f64 / f64::from(self.sample_rate) }
}
}
/// Bit depth for [`encode`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum BitDepth {
Int16,
Int24,
Int32,
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
Float32,
}
/// Options for [`encode`].
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct EncodeOptions {
/// `Wav`, `Aiff` or `Flac`; anything else is [`AudioError::Unsupported`].
pub format: FileFormat,
pub bit_depth: BitDepth,
/// Add TPDF dither when quantizing to an integer depth.
pub dither: bool,
/// Write a BWF `bext` chunk (WAV only; ignored for other formats).
pub bwf: Option<BwfInfo>,
}
impl Default for EncodeOptions {
fn default() -> Self {
Self { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None }
}
}
/// Size of an ID3v2 tag at the start of `b`, if there is one. The 10-byte footer flag (`0x10`)
/// exists only in ID3v2.4; in v2.2/v2.3 that bit is reserved and must not extend the skip.
fn id3v2_len(b: &[u8]) -> Option<usize> {
if b.get(..3)? != b"ID3" {
return None;
}
let version = *b.get(3)?;
let flags = *b.get(5)?;
let size = b.get(6..10)?.iter().fold(0usize, |acc, &x| (acc << 7) | usize::from(x & 0x7F));
let footer = version == 4 && flags & 0x10 != 0;
Some(10 + size + if footer { 10 } else { 0 })
}
fn detect_by_magic(b: &[u8], depth: u32) -> Option<FileFormat> {
let head = |n: usize| b.get(..n);
if wav::is_wav(b) {
return Some(FileFormat::Wav);
}
if aiff::is_aiff(b) {
return Some(FileFormat::Aiff);
}
match head(4)? {
b"fLaC" => return Some(FileFormat::Flac),
b"OggS" => return Some(FileFormat::Ogg),
b"caff" => return Some(FileFormat::Caf),
[0x1A, 0x45, 0xDF, 0xA3] => return Some(FileFormat::Other),
_ => {}
}
if b.get(4..8) == Some(b"ftyp") {
// MP4 / M4A: the sample entry inside `stsd` names the codec.
let found = b.windows(4).position(|w| w == b"stsd");
let codec = found.and_then(|p| b.get(p.checked_add(16)?..p.checked_add(20)?));
return Some(if codec == Some(b"alac") { FileFormat::Alac } else { FileFormat::Aac });
}
if let Some(len) = id3v2_len(b) {
if depth == 0
&& let Some(f) = b.get(len..).and_then(|rest| detect_by_magic(rest, 1))
{
return Some(f);
}
return Some(FileFormat::Mp3);
}
let (b0, b1) = (*b.first()?, *b.get(1)?);
if b0 == 0xFF && b1 & 0xE0 == 0xE0 {
let layer = (b1 >> 1) & 0x3;
if b1 & 0xF0 == 0xF0 && layer == 0 {
return Some(FileFormat::Aac); // ADTS
}
if layer != 0 {
return Some(FileFormat::Mp3);
}
}
None
}
/// Identify a file by its magic bytes, falling back to the extension hint (`"wav"`, `".flac"`).
pub fn detect_format(bytes: &[u8], ext_hint: Option<&str>) -> FileFormat {
detect_by_magic(bytes, 0).unwrap_or_else(|| ext_hint.and_then(FileFormat::from_extension).unwrap_or(FileFormat::Other))
}
/// Read a file's format, sample rate, channel count, length and BWF metadata.
pub fn probe(bytes: &[u8], ext_hint: Option<&str>) -> Result<AudioInfo> {
let format = detect_format(bytes, ext_hint);
match format {
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
wav::WavParse::Native(l) => Ok(l.info),
wav::WavParse::Foreign { bwf } => {
let mut info = symph::probe(bytes, format)?;
info.bwf = bwf;
Ok(info)
}
},
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
aiff::AiffParse::Native(l) => Ok(l.info),
aiff::AiffParse::Foreign => symph::probe(bytes, format),
},
_ => symph::probe(bytes, format),
}
}
/// Decode a whole file to planar `f32`.
pub fn decode(bytes: &[u8], ext_hint: Option<&str>) -> Result<(AudioInfo, AudioBuffer)> {
let format = detect_format(bytes, ext_hint);
match format {
FileFormat::Wav if wav::is_wav(bytes) => match wav::parse(bytes)? {
wav::WavParse::Native(l) => {
let buf = wav::decode(bytes, &l)?;
let mut info = l.info;
info.frames = buf.frames() as u64;
Ok((info, buf))
}
wav::WavParse::Foreign { bwf } => {
let (mut info, buf) = symph::decode(bytes, format)?;
info.bwf = bwf;
Ok((info, buf))
}
},
FileFormat::Aiff if aiff::is_aiff(bytes) => match aiff::parse(bytes)? {
aiff::AiffParse::Native(l) => {
let buf = aiff::decode(bytes, &l)?;
let mut info = l.info;
info.frames = buf.frames() as u64;
Ok((info, buf))
}
aiff::AiffParse::Foreign => symph::decode(bytes, format),
},
_ => symph::decode(bytes, format),
}
}
/// Encode `buf` as WAV/BWF, AIFF/AIFF-C or FLAC. Samples are clamped to [-1, 1].
pub fn encode(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
match opts.format {
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, None),
FileFormat::Aiff => aiff::encode(buf, opts.bit_depth, opts.dither),
FileFormat::Flac => flac::encode(buf, opts.bit_depth, opts.dither),
other => Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
}
}
/// Encode a WAV file in the RF64 layout regardless of size (normally used only above 4 GiB).
pub fn encode_wav_rf64(buf: &AudioBuffer, opts: &EncodeOptions) -> Result<Vec<u8>> {
wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), true, None)
}
/// Like [`encode`], with an explicit WAV speaker mask (`dwChannelMask`, e.g. 0x60F) for layouts
/// a channel count alone cannot identify (7.0 vs 6.1, 5.1.4 vs 7.1.2, Ambisonics = 0). AIFF and
/// FLAC have fixed channel orders and ignore it.
pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u32) -> Result<Vec<u8>> {
match opts.format {
FileFormat::Wav => wav::encode(buf, opts.bit_depth, opts.dither, opts.bwf.as_ref(), false, Some(mask)),
_ => encode(buf, opts),
}
}
/// The error a stream encoder returns for every call after one of its writes has failed: the
/// sink may already hold a prefix of the failed block, so the encoder's counters can no longer
/// describe the bytes on the sink, and later writes or `finish` must not claim success.
pub(crate) fn failed_write_error() -> AudioError {
AudioError::Encode("the stream encoder already failed on an earlier write; its output is incomplete".into())
}
/// Writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32` or FLAC block by block, producing
/// the same bytes [`encode_with_channel_mask`] would for the whole buffer.
pub struct FileEncoder<W: Write + Seek>(Writer<W>);
enum Writer<W> {
Wav(wav::Writer<W>),
Aiff(aiff::Writer<W>),
Flac(flac::Writer<W>),
}
impl<W: Write + Seek> FileEncoder<W> {
/// Starts a file at the current position of `out`. `mask` overrides the WAV speaker mask
/// derived from the channel count.
pub fn new(out: W, opts: &EncodeOptions, channels: usize, sample_rate: u32, mask: Option<u32>) -> Result<Self> {
let (depth, dither) = (opts.bit_depth, opts.dither);
Ok(FileEncoder(match opts.format {
FileFormat::Wav => Writer::Wav(wav::Writer::new(
out,
channels,
sample_rate,
depth,
dither,
wav::HeaderExtras { bwf: opts.bwf.as_ref(), mask, force_rf64: false },
)?),
FileFormat::Aiff => Writer::Aiff(aiff::Writer::new(out, channels, sample_rate, depth, dither)?),
FileFormat::Flac => Writer::Flac(flac::Writer::new(out, channels, sample_rate, depth, dither)?),
other => return Err(AudioError::Unsupported(format!("encoding {other:?} is not supported"))),
}))
}
/// Fails, without writing anything, when `frames` more frames would not fit the format (an
/// AIFF holds 4 GiB, a FLAC 2^36 frames).
pub fn check_room(&self, frames: u64) -> Result<()> {
match &self.0 {
Writer::Wav(w) => w.check_room(frames),
Writer::Aiff(w) => w.check_room(frames),
Writer::Flac(w) => w.check_room(frames),
}
.map(drop)
}
/// Appends the first `frames` frames of `block`, one `Vec` per channel (missing samples are
/// silence), unless they don't fit (see [`FileEncoder::check_room`]). Once a write has
/// failed, the encoder is unusable: the sink may hold a prefix of the failed block, so
/// every later `write` and `finish` fails instead of completing an inconsistent file.
pub fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
match &mut self.0 {
Writer::Wav(w) => w.write(block, frames),
Writer::Aiff(w) => w.write(block, frames),
Writer::Flac(w) => w.write(block, frames),
}
}
/// Completes the headers and hands back `out`, positioned at the end of the file.
pub fn finish(self) -> Result<W> {
match self.0 {
Writer::Wav(w) => w.finish(),
Writer::Aiff(w) => w.finish(),
Writer::Flac(w) => w.finish(),
}
}
}
/// The WAVE_FORMAT_EXTENSIBLE speaker mask of a WAV file, if it has one.
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
wav::read_channel_mask(bytes)
}
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
/// switches to RF64 above 4 GiB.
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
if channels == 0 || sample_rate == 0 {
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
}
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
}
-106
View File
@@ -1,106 +0,0 @@
//! Size checks on MP4 atoms before symphonia reads them: it allocates whatever an atom, a sample
//! table or a sample claims, and a failed allocation aborts the process.
use crate::pcm::{be_u32, be_u64};
use crate::{AudioError, Result};
#[derive(Clone, Copy)]
enum Level {
Top,
Moov,
Trak,
Mdia,
Minf,
Stbl,
Udta,
Meta,
Ilst,
Tag,
}
/// Refuse the MP4 file whose top-level atoms start at `at` if an atom symphonia reads whole, a
/// sample table or a sample claims more bytes than the file holds.
pub(crate) fn check(b: &[u8], at: usize) -> Result<()> {
atoms(b, at, b.len(), Level::Top).map(|_| ())
}
fn malformed(what: &str) -> AudioError {
AudioError::Malformed(format!("MP4 {what} is larger than the file"))
}
/// Walk the atoms in `pos..end` that symphonia descends into, as it reads them. False where
/// symphonia stops reading: at a header it refuses, or after an atom that runs past `end`.
fn atoms(b: &[u8], mut pos: usize, end: usize, level: Level) -> Result<bool> {
// A header that starts inside the container is read whole, even past its end.
while pos < end {
let (Some(size), Some(kind)) = (be_u32(b, pos), b.get(pos + 4..pos + 8)) else { return Ok(false) };
let head = if size == 1 { 16 } else { 8 };
// Size 0: no data, and the next atom starts at the container's end.
let len = match size {
0 => head,
1 => be_u64(b, pos + 8).unwrap_or(0),
n => u64::from(n),
};
if len < head {
return Ok(false);
}
let body = pos + head as usize;
let data_end = usize::try_from(len).ok().and_then(|n| pos.checked_add(n));
let next = if size == 0 { Some(end) } else { data_end };
let inner = data_end.map_or(b.len(), |n| n.min(b.len()));
let more = match (level, kind) {
(Level::Top, b"moov") => atoms(b, body, inner, Level::Moov)?,
(Level::Top | Level::Udta, b"meta") => atoms(b, body + 4, inner, Level::Meta)?,
(Level::Moov, b"trak") => atoms(b, body, inner, Level::Trak)?,
(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::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::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))));
}
_ => true,
};
match next {
Some(n) if more && n <= end => pos = n,
_ => return Ok(false),
}
}
Ok(true)
}
/// 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> {
let Some(n) = be_u32(b, count) else { return Ok(false) };
let (first, bytes) = (count + 4, u64::from(n) * entry);
if bytes > b.len().saturating_sub(first) as u64 {
return Err(malformed("sample table"));
}
Ok(first as u64 + bytes <= end as u64)
}
/// Every sample's size must fit in the file: symphonia reads each one into a buffer of that size.
/// Sizes past the atom are never used, as symphonia stops before reading a packet.
fn sample_sizes(b: &[u8], body: usize, end: usize) -> Result<bool> {
let (Some(size), Some(count)) = (be_u32(b, body + 4), be_u32(b, body + 8)) else { return Ok(false) };
let file = b.len() as u64;
if u64::from(size) > file {
return Err(malformed("sample"));
}
if size != 0 {
return Ok(true);
}
let fits = table(b, body + 8, 4, end)?;
let (sizes, _) = b.get(body + 12..end).unwrap_or_default().as_chunks::<4>();
if sizes.iter().take(count as usize).any(|s| u64::from(u32::from_be_bytes(*s)) > file) {
return Err(malformed("sample"));
}
Ok(fits)
}
-279
View File
@@ -1,279 +0,0 @@
//! Byte-level PCM helpers shared by the WAV, AIFF and FLAC code: bounded readers, sample
//! decoding to `f32`, and quantization (with optional TPDF dither) for encoding.
use crate::{AudioError, BitDepth, Result};
use std::io::Write;
/// Maximum number of decoded `f32` samples (all channels) we will produce: 4 GiB of output.
pub(crate) const MAX_DECODE_SAMPLES: u64 = (4u64 << 30) / 4;
/// Little-endian `u16` at `pos`.
pub(crate) fn le_u16(b: &[u8], pos: usize) -> Option<u16> {
let s = b.get(pos..pos.checked_add(2)?)?;
Some(u16::from_le_bytes([*s.first()?, *s.get(1)?]))
}
/// Little-endian `u32` at `pos`.
pub(crate) fn le_u32(b: &[u8], pos: usize) -> Option<u32> {
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
Some(u32::from_le_bytes(s))
}
/// Little-endian `u64` at `pos`.
pub(crate) fn le_u64(b: &[u8], pos: usize) -> Option<u64> {
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
Some(u64::from_le_bytes(s))
}
/// Big-endian `u16` at `pos`.
pub(crate) fn be_u16(b: &[u8], pos: usize) -> Option<u16> {
let s: [u8; 2] = b.get(pos..pos.checked_add(2)?)?.try_into().ok()?;
Some(u16::from_be_bytes(s))
}
/// Big-endian `u32` at `pos`.
pub(crate) fn be_u32(b: &[u8], pos: usize) -> Option<u32> {
let s: [u8; 4] = b.get(pos..pos.checked_add(4)?)?.try_into().ok()?;
Some(u32::from_be_bytes(s))
}
/// Big-endian `u64` at `pos`.
pub(crate) fn be_u64(b: &[u8], pos: usize) -> Option<u64> {
let s: [u8; 8] = b.get(pos..pos.checked_add(8)?)?.try_into().ok()?;
Some(u64::from_be_bytes(s))
}
/// Four-byte tag at `pos`.
pub(crate) fn tag(b: &[u8], pos: usize) -> Option<[u8; 4]> {
b.get(pos..pos.checked_add(4)?)?.try_into().ok()
}
/// How the samples in a PCM byte stream are encoded.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PcmKind {
/// Signed (or, for 1-byte WAV, unsigned) integers occupying `bytes` bytes each, left-justified.
Int { bytes: usize, big_endian: bool, unsigned: bool },
/// IEEE float, 4 or 8 bytes.
Float { bytes: usize, big_endian: bool },
}
impl PcmKind {
pub(crate) fn bytes(self) -> usize {
match self {
PcmKind::Int { bytes, .. } | PcmKind::Float { bytes, .. } => bytes,
}
}
}
/// Decode one sample from exactly `kind.bytes()` bytes. Returns 0 for a short slice.
fn decode_sample(s: &[u8], kind: PcmKind) -> f32 {
match kind {
PcmKind::Int { bytes, big_endian, unsigned } => {
if bytes == 0 || bytes > 4 || s.len() < bytes {
return 0.0;
}
let mut acc: u32 = 0;
for i in 0..bytes {
let idx = if big_endian { i } else { bytes - 1 - i };
let mut byte = s.get(idx).copied().unwrap_or(0);
if unsigned && i == 0 {
byte ^= 0x80;
}
acc = (acc << 8) | u32::from(byte);
}
let shifted = acc << (32 - 8 * bytes as u32);
(f64::from(shifted as i32) / 2_147_483_648.0) as f32
}
PcmKind::Float { bytes, big_endian } => {
let v = match bytes {
4 => {
let Some(a) = s.get(..4).and_then(|x| <[u8; 4]>::try_from(x).ok()) else { return 0.0 };
if big_endian { f32::from_be_bytes(a) } else { f32::from_le_bytes(a) }
}
8 => {
let Some(a) = s.get(..8).and_then(|x| <[u8; 8]>::try_from(x).ok()) else { return 0.0 };
(if big_endian { f64::from_be_bytes(a) } else { f64::from_le_bytes(a) }) as f32
}
_ => 0.0,
};
if v.is_finite() { v } else { 0.0 }
}
}
}
/// Deinterleave `frames` frames of `channels`-channel PCM from `data` into planar `f32`.
pub(crate) fn deinterleave(data: &[u8], kind: PcmKind, channels: usize, frames: u64) -> Result<Vec<Vec<f32>>> {
let bytes = kind.bytes();
let block = bytes.checked_mul(channels).filter(|&b| b > 0).ok_or_else(|| AudioError::Malformed("zero-sized sample frame".into()))?;
let available = (data.len() / block) as u64;
let frames = frames.min(available);
let total = frames.checked_mul(channels as u64).ok_or_else(|| AudioError::TooLarge("sample count overflows".into()))?;
if total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the {MAX_DECODE_SAMPLES}-sample decode limit")));
}
let frames = usize::try_from(frames).map_err(|_| AudioError::TooLarge("frame count exceeds address space".into()))?;
let mut out: Vec<Vec<f32>> = (0..channels).map(|_| Vec::with_capacity(frames)).collect();
for frame in data.chunks_exact(block).take(frames) {
for (ch, s) in out.iter_mut().zip(frame.chunks_exact(bytes)) {
ch.push(decode_sample(s, kind));
}
}
Ok(out)
}
/// Converts `f32` samples to integers of a given bit depth, clamping to [-1, 1] and optionally
/// adding triangular (TPDF) dither of ±1 LSB.
pub(crate) struct Quantizer {
bits: u32,
dither: bool,
rng: u64,
}
impl Quantizer {
/// A quantizer for one channel; each channel gets an independent dither sequence.
pub(crate) fn new(bits: u32, dither: bool, channel: usize) -> Self {
let rng = 0x9E37_79B9_7F4A_7C15u64 ^ (channel as u64).wrapping_add(1).wrapping_mul(0xD1B5_4A32_D192_ED03);
Self { bits: bits.clamp(2, 32), dither, rng: if rng == 0 { 1 } else { rng } }
}
/// One quantizer per channel.
pub(crate) fn per_channel(bits: u32, dither: bool, channels: usize) -> Vec<Self> {
(0..channels).map(|c| Self::new(bits, dither, c)).collect()
}
/// Uniform in [0, 1) from a xorshift64* generator (deterministic, no OS entropy needed).
fn uniform(&mut self) -> f64 {
let mut x = self.rng;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.rng = x;
(x.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 11) as f64 / (1u64 << 53) as f64
}
pub(crate) fn quantize(&mut self, x: f32) -> i32 {
let x = if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) };
let scale = (1u64 << (self.bits - 1)) as f64;
let mut v = f64::from(x) * scale;
if self.dither {
v += self.uniform() - self.uniform();
}
let max = scale - 1.0;
round_half_away(v.clamp(-scale, max)) as i32
}
}
#[inline]
fn round_half_away(v: f64) -> f64 {
let t = v as i64;
let f = v - t as f64;
(t + i64::from(f >= 0.5) - i64::from(f <= -0.5)) as f64
}
/// Bits per sample at `depth`, and whether they are float.
pub(crate) fn sample_format(depth: BitDepth) -> (u16, bool) {
match depth {
BitDepth::Int16 => (16, false),
BitDepth::Int24 => (24, false),
BitDepth::Int32 => (32, false),
BitDepth::Float32 => (32, true),
}
}
/// Interleaves `frames` frames of planar audio (missing samples are silence) as `bits`-bit
/// samples and writes them to `out`, a few thousand frames at a time through `scratch`.
pub(crate) fn write_interleaved(
out: &mut impl Write,
scratch: &mut Vec<u8>,
block: &[Vec<f32>],
frames: usize,
quantizers: &mut [Quantizer],
(bits, is_float): (u16, bool),
big_endian: bool,
) -> std::io::Result<()> {
let size = usize::from(bits / 8);
let mut start = 0;
while start < frames {
let end = frames.min(start + 4096);
scratch.clear();
for f in start..end {
for (c, q) in quantizers.iter_mut().enumerate() {
let s = block.get(c).and_then(|ch| ch.get(f)).copied().unwrap_or(0.0);
match (is_float, big_endian) {
(true, false) => scratch.extend_from_slice(&sanitize_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&sanitize_float(s).to_be_bytes()),
(false, false) => scratch.extend_from_slice(q.quantize(s).to_le_bytes().get(..size).unwrap_or_default()),
(false, true) => scratch.extend_from_slice(q.quantize(s).to_be_bytes().get(4 - size..).unwrap_or_default()),
}
}
}
out.write_all(scratch)?;
start = end;
}
Ok(())
}
/// Preserve finite float samples, mapping NaN to 0 and infinities to their sign (±1).
fn sanitize_float(x: f32) -> f32 {
if x.is_finite() {
x
} else if x.is_nan() {
0.0
} else {
x.signum()
}
}
/// Check that a buffer is encodable: at least one channel and a non-zero sample rate.
pub(crate) fn validate_buffer(buf: &crate::AudioBuffer) -> Result<()> {
validate_layout(buf.channels.len(), buf.sample_rate)
}
/// Check that a stream is encodable: at least one channel and a non-zero sample rate.
pub(crate) fn validate_layout(channels: usize, sample_rate: u32) -> Result<()> {
if channels == 0 {
return Err(AudioError::Encode("buffer has no channels".into()));
}
if channels > usize::from(u16::MAX) {
return Err(AudioError::Encode("too many channels".into()));
}
if sample_rate == 0 {
return Err(AudioError::Encode("sample rate is 0".into()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_half_away_matches_f64_round_on_the_quantizer_range() {
let edges = [
0.0,
-0.0,
0.5,
-0.5,
1.5,
-1.5,
2.5,
0.499_999_999_999_999_94,
-0.499_999_999_999_999_94,
8_388_607.5,
-8_388_608.0,
2_147_483_647.0,
-2_147_483_648.0,
];
let mut x = 0x2545_f491_4f6c_dd1du64;
let spread = (0..10_000).map(|_| {
x ^= x << 13;
x ^= x >> 7;
x ^= x << 17;
(x >> 11) as f64 / (1u64 << 53) as f64 * 4_294_967_295.0 - 2_147_483_648.0
});
for v in edges.into_iter().chain(spread) {
assert!(round_half_away(v) == v.round(), "{v}");
assert_eq!(round_half_away(v) as i32, v.round() as i32, "{v}");
}
}
}
-273
View File
@@ -1,273 +0,0 @@
//! Multi-resolution overview for waveform drawing.
//!
//! Level 0 sums up each block of [`BLOCK`] samples (min, max and sum of squares); each following
//! level merges [`FANOUT`] blocks of the level below, until a level has a single block. Range
//! queries combine the coarsest whole blocks that fit inside the range and read the partial blocks
//! at its edges from the samples, so a query is exact and costs O(log n) regardless of zoom.
/// Samples per level-0 block.
pub const BLOCK: usize = 32;
/// Blocks of level `n` merged into one block of level `n + 1`.
pub const FANOUT: usize = 4;
/// Upper bound on the number of spans [`Peaks::spans`] will produce.
pub const MAX_COLUMNS: usize = 1 << 20;
/// Multi-resolution overview of one channel for waveform drawing.
#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Peaks {
len: usize,
levels: Vec<Vec<Block>>,
}
/// What a run of samples sums up to; NaN samples count for nothing.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
struct Block {
min: f32,
max: f32,
squares: f32,
}
impl Block {
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
fn point(s: f32) -> Block {
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
}
fn of(samples: &[f32]) -> Block {
samples.iter().fold(Block::EMPTY, |b, &s| b.merge(Block::point(s)))
}
fn merge(self, o: Block) -> Block {
Block { min: self.min.min(o.min), max: self.max.max(o.max), squares: self.squares + o.squares }
}
/// (min, max), or (0, 0) when nothing was summed up.
fn range(self) -> (f32, f32) {
if self.min > self.max { (0.0, 0.0) } else { (self.min, self.max) }
}
}
impl Peaks {
/// Build the overview of `samples` (one channel). NaN samples are ignored.
pub fn build(samples: &[f32]) -> Peaks {
if samples.is_empty() {
return Peaks::default();
}
let mut levels: Vec<Vec<Block>> = vec![samples.chunks(BLOCK).map(Block::of).collect()];
while let Some(last) = levels.last().filter(|l| l.len() > 1) {
let next = last.chunks(FANOUT).map(|c| c.iter().fold(Block::EMPTY, |a, &b| a.merge(b))).collect();
levels.push(next);
}
Peaks { len: samples.len(), levels }
}
/// Number of samples the overview was built from.
pub fn len_samples(&self) -> usize {
self.len
}
/// Number of resolution levels (0 for an empty overview).
pub fn num_levels(&self) -> usize {
self.levels.len()
}
/// (min, max) over the sample range `[start, end)`, clamped to the data. `samples` must be the
/// data the overview was built from. Returns `(0.0, 0.0)` for an empty range.
pub fn minmax(&self, samples: &[f32], start: usize, end: usize) -> (f32, f32) {
self.sum(samples, start, end).range()
}
fn sum(&self, samples: &[f32], start: usize, end: usize) -> Block {
let end = end.min(self.len).min(samples.len());
let (first, last) = (start.div_ceil(BLOCK), end / BLOCK);
let raw = |a: usize, b: usize| Block::of(samples.get(a..b).unwrap_or_default());
if first >= last {
return raw(start, end);
}
raw(start, first * BLOCK).merge(raw(last * BLOCK, end)).merge(self.blocks(first, last))
}
/// The level-0 blocks `[lo, hi)`, summed from the coarsest blocks that fit.
fn blocks(&self, mut lo: usize, mut hi: usize) -> Block {
let mut acc = Block::EMPTY;
for (lvl, level) in self.levels.iter().enumerate() {
let mut add = |i: usize| acc = acc.merge(level.get(i).copied().unwrap_or(Block::EMPTY));
if lvl + 1 == self.levels.len() || hi.saturating_sub(lo) < 2 * FANOUT {
(lo..hi).for_each(add);
break;
}
while !lo.is_multiple_of(FANOUT) {
add(lo);
lo += 1;
}
while !hi.is_multiple_of(FANOUT) {
hi -= 1;
add(hi);
}
lo /= FANOUT;
hi /= FANOUT;
}
acc
}
/// The spans of sample positions `[start + c * step, start + (c + 1) * step + overlap]` that
/// reach the data, clamped to it.
fn windows(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> impl Iterator<Item = Option<(f64, f64)>> {
let n = self.len.min(samples.len());
let valid = n > 0 && start.is_finite() && step.is_finite() && step > 0.0 && overlap.is_finite() && overlap >= 0.0;
let last = n.saturating_sub(1) as f64;
(0..cols.min(MAX_COLUMNS)).map(move |c| {
let (a, b) = (start + step * c as f64, start + step * (c + 1) as f64 + overlap);
(valid && b >= 0.0 && a <= last).then(|| (a.max(0.0), b.min(last)))
})
}
/// (min, max) of the line through `samples` over each of `cols` spans of sample positions
/// `[start + c * step, start + (c + 1) * step + overlap]`; fractional positions interpolate
/// between samples. Neighbouring spans share at least their boundary, so a waveform drawn from
/// them stays connected at any zoom. `None` for spans outside the data.
pub fn spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<(f32, f32)>> {
let at = |t: f64| {
let i = t.floor();
let a = samples.get(i as usize).copied().unwrap_or(0.0);
let b = samples.get(i as usize + 1).copied().unwrap_or(a);
Block::point(a + (b - a) * (t - i) as f32)
};
self.windows(samples, start, step, overlap, cols)
.map(|w| w.map(|(a, b)| self.sum(samples, a.ceil() as usize, b.floor() as usize + 1).merge(at(a)).merge(at(b)).range()))
.collect()
}
/// The RMS of `samples` over each span [`Peaks::spans`] gives, including the samples just
/// outside a span's ends.
pub fn rms_spans(&self, samples: &[f32], start: f64, step: f64, overlap: f64, cols: usize) -> Vec<Option<f32>> {
self.windows(samples, start, step, overlap, cols)
.map(|w| {
w.map(|(a, b)| {
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn brute(samples: &[f32], start: usize, end: usize) -> (f32, f32) {
let s = &samples[start..end];
(s.iter().copied().fold(f32::INFINITY, f32::min), s.iter().copied().fold(f32::NEG_INFINITY, f32::max))
}
fn signal(n: usize) -> Vec<f32> {
(0..n).map(|i| ((i as f32 * 0.037).sin() * (i as f32 * 0.0011).cos()) + if i % 997 == 0 { 0.5 } else { 0.0 }).collect()
}
#[test]
fn every_range_is_exact() {
let x = signal(100_003);
let p = Peaks::build(&x);
for &(a, b) in &[(0, 32), (0, 100_003), (32, 64_000), (3200, 3232), (96, 99_968), (64, 100_003), (5, 6), (31, 33), (1000, 1031)] {
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
}
let mut seed = 12345u64;
for _ in 0..2000 {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let a = (seed >> 33) as usize % 100_003;
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
let b = a + 1 + (seed >> 33) as usize % (100_003 - a);
assert_eq!(p.minmax(&x, a, b), brute(&x, a, b), "range {a}..{b}");
}
}
#[test]
fn degenerate_inputs() {
let p = Peaks::build(&[]);
assert_eq!(p.len_samples(), 0);
assert_eq!(p.minmax(&[], 0, 10), (0.0, 0.0));
assert_eq!(p.spans(&[], 0.0, 1.0, 0.0, 4), vec![None; 4]);
let x = [0.5, -0.25];
let p = Peaks::build(&x);
assert_eq!(p.minmax(&x, 0, 2), (-0.25, 0.5));
assert_eq!(p.minmax(&x, 5, 3), (0.0, 0.0));
assert_eq!(p.minmax(&x, 0, usize::MAX), (-0.25, 0.5));
assert_eq!(p.minmax(&x[..1], 0, 2), (0.5, 0.5));
for (start, step, overlap) in [
(f64::NAN, 1.0, 0.0),
(0.0, 0.0, 0.0),
(0.0, -1.0, 0.0),
(0.0, f64::INFINITY, 0.0),
(f64::NEG_INFINITY, 1.0, 0.0),
(0.0, 1.0, -0.5),
(0.0, 1.0, f64::NAN),
] {
assert_eq!(p.spans(&x, start, step, overlap, 3), vec![None; 3]);
}
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 0).len(), 0);
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, usize::MAX).len(), MAX_COLUMNS);
let x = [f32::NAN, 1.0];
let p = Peaks::build(&x);
assert_eq!(p.minmax(&x, 0, 2), (1.0, 1.0));
assert_eq!(p.spans(&x, 0.0, 1.0, 0.0, 1), vec![Some((1.0, 1.0))]);
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
}
#[test]
fn rms_spans_match_the_samples() {
let x = signal(20_000);
let p = Peaks::build(&x);
let rms = |s: usize, e: usize| (x[s..e].iter().map(|&v| f64::from(v) * f64::from(v)).sum::<f64>() / (e - s) as f64).sqrt() as f32;
for (step, overlap) in [(1.5, 0.0), (37.0, 37.0), (1000.0, 0.0), (4096.0, 512.0)] {
let cols = ((19_999.0 - overlap) / step) as usize;
for (c, got) in p.rms_spans(&x, 0.0, step, overlap, cols).into_iter().enumerate() {
let (a, b) = (c as f64 * step, ((c + 1) as f64 * step + overlap).min(19_999.0));
let (got, want) = (got.unwrap(), rms(a.floor() as usize, (b.ceil() as usize + 1).min(20_000)));
assert!((got - want).abs() <= want * 1e-4 + 1e-6, "span {c} at step {step}: {got} vs {want}");
}
}
let sine: Vec<f32> = (0..48_000).map(|i| (i as f32 * 0.01).sin()).collect();
let p = Peaks::build(&sine);
let r = p.rms_spans(&sine, 0.0, 47_000.0, 0.0, 1)[0].unwrap();
assert!((r - std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-3, "{r}");
assert_eq!(p.rms_spans(&sine, f64::NAN, 1.0, 0.0, 2), vec![None, None]);
assert_eq!(p.rms_spans(&sine, 50_000.0, 1.0, 0.0, 1), vec![None]);
}
#[test]
fn spans_cover_their_samples_and_touch_their_neighbours() {
let x = signal(10_000);
let p = Peaks::build(&x);
for step in [0.3, 1.0, 7.5, 100.0, 333.3] {
let cols = (9_999.0 / step) as usize;
let spans = p.spans(&x, 0.0, step, 0.0, cols);
for (c, s) in spans.iter().enumerate() {
let (lo, hi) = s.unwrap_or_else(|| panic!("span {c} at step {step}"));
let (a, b) = ((c as f64 * step).ceil() as usize, ((c + 1) as f64 * step).floor() as usize);
if a <= b {
let truth = brute(&x, a, b + 1);
assert!(lo <= truth.0 && hi >= truth.1, "span {c} at step {step}");
}
if let Some(Some((nlo, nhi))) = spans.get(c + 1) {
assert!(*nlo <= hi && *nhi >= lo, "spans {c} and {} at step {step} don't touch", c + 1);
}
}
let all = spans.iter().flatten().fold((f32::INFINITY, f32::NEG_INFINITY), |a, s| (a.0.min(s.0), a.1.max(s.1)));
assert_eq!(all, brute(&x, 0, ((cols as f64 * step).floor() as usize + 1).min(10_000)), "step {step}");
}
}
#[test]
fn spans_follow_the_line_between_samples() {
let x = [0.0, 1.0, -1.0, 0.5];
let p = Peaks::build(&x);
assert_eq!(p.spans(&x, 0.0, 0.25, 0.0, 4), vec![Some((0.0, 0.25)), Some((0.25, 0.5)), Some((0.5, 0.75)), Some((0.75, 1.0))]);
assert_eq!(p.spans(&x, 0.5, 1.0, 0.0, 1), vec![Some((0.0, 1.0))]);
let spans = vec![None, Some((0.0, 0.0)), Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5)), Some((0.5, 0.5)), None];
assert_eq!(p.spans(&x, -2.0, 1.0, 0.0, 7), spans);
assert_eq!(p.spans(&x, 0.0, 1.0, 1.0, 3), vec![Some((-1.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
assert_eq!(p.spans(&x, 0.0, 1.0, 0.5, 3), vec![Some((0.0, 1.0)), Some((-1.0, 1.0)), Some((-1.0, 0.5))]);
}
}
-751
View File
@@ -1,751 +0,0 @@
//! Decode path for everything that isn't native PCM WAV/AIFF, backed by `symphonia`.
use std::io::{Cursor, Seek, SeekFrom};
use std::panic::{AssertUnwindSafe, catch_unwind};
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
};
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::probe::{Hint, ProbeOptions, ProbeableFormat, Score, Scoreable};
use symphonia::core::formats::{FormatOptions, FormatReader, Track};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ScopedStream};
use symphonia::core::meta::MetadataOptions;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
/// The most frames a packet may declare: decoders allocate a packet's frames up front, and ALAC
/// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// 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;
/// How many MPEG/ADTS candidates [`first_marker`] may hand to the readers' expensive `Scoreable`
/// implementations. A crafted 1 MiB prefix of repeated sync markers otherwise asks for hundreds
/// of thousands of scores, each allowed up to symphonia's 16 KiB score depth. Once the budget is
/// spent, the scan stops at the next audio candidate: a container-like byte string later in an
/// unscored audio payload must not become authoritative. A container marker reached first is still
/// checked; an unchecked one the probe selects is refused after probing.
const MAX_AUDIO_SCORE_ATTEMPTS: usize = 256;
/// A per-scan budget for `Scoreable::score` calls. Taking a slot consumes it even when the score
/// rejects the candidate, so a stream of false syncs cannot multiply the work.
struct ScoreBudget {
used: usize,
}
impl ScoreBudget {
/// Start a scan with no expensive score attempts spent.
fn new() -> Self {
Self { used: 0 }
}
/// Spend one score attempt, returning false once the per-scan budget is exhausted.
fn take(&mut self) -> bool {
if self.exhausted() {
return false;
}
self.used = self.used.saturating_add(1);
true
}
/// Whether no attempt is left for this scan.
fn exhausted(&self) -> bool {
self.used >= MAX_AUDIO_SCORE_ATTEMPTS
}
}
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
/// distinct from a malformed file.
fn map_err(e: SymError) -> AudioError {
match e {
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
SymError::LimitError(m) => AudioError::TooLarge(m.to_string()),
other => AudioError::Malformed(other.to_string()),
}
}
/// Run `f`, turning a panic inside the third-party decoder into an error.
fn guarded<T>(f: impl FnOnce() -> Result<T>) -> Result<T> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(AudioError::Malformed("decoder failed on malformed input".into())))
}
/// What [`open`] found: a reader, or a stream that holds no audio frames and has no reader.
enum Opened {
Reader(Box<dyn FormatReader>),
Empty(AudioInfo),
}
/// Decoder options for every compressed format. Gapless playback used to be a
/// `FormatOptions` flag in symphonia 0.5; in 0.6 the decoder option (on by default) trims the
/// encoder delay and padding that the demuxer reports on each packet.
fn decoder_options() -> AudioDecoderOptions {
AudioDecoderOptions::default().gapless(true)
}
/// The first byte after any ID3v2 tags at the start of `b`. Symphonia's tag reader allocates
/// whatever a frame claims, so the tags are skipped, not read: they are not used here.
fn skip_id3v2(b: &[u8]) -> usize {
let mut at = 0usize;
loop {
match b.get(at..).and_then(crate::id3v2_len) {
Some(n) if n > 0 => at = at.saturating_add(n),
_ => return at,
}
}
}
/// The first format marker the probe can reach from `start`, within its 1 MiB scan limit.
#[derive(Clone, Copy)]
enum FirstMarker {
Flac(usize),
Caf(usize),
Mp4(usize),
Wav(usize),
/// MPEG audio, Ogg, Matroska or AIFF: formats with no separate container size check.
Audio,
}
/// What the preflight container checks covered before the probe built a reader.
enum Precheck {
/// A zero-frame FLAC: returned without a reader, because Symphonia 0.6's reader needs a first
/// audio frame to resynchronize to.
Empty(AudioInfo),
/// The size checks ran for the container the probe reports under this short name.
Checked(&'static str),
/// No container size check was needed.
Unchecked,
}
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
/// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
/// The container markers to check for the family `detected` names. `None` is a raw audio stream
/// (MP3, Ogg, AIFF), whose payload may contain container-like bytes: the unrestricted scan below
/// finds a container only when one starts the stream, before any audio marker.
fn container_markers(detected: FileFormat) -> Option<&'static [&'static [u8; 4]]> {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => Some(&[b"RIFF", b"RF64", b"BW64"]),
FileFormat::Flac => Some(&[b"fLaC"]),
FileFormat::Caf => Some(&[b"caff"]),
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => Some(&[b"ftyp"]),
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => Some(&[b"caff", b"ftyp", b"fLaC", b"RIFF"]),
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => None,
}
}
/// The probe short name a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static str {
match marker {
b"caff" => "caf",
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => "isomp4",
b"fLaC" => "flac",
// RIFF, RF64 and BW64.
_ => "wave",
}
}
/// Find the first marker of `markers` at or after `at`, within symphonia's probe depth. A RIFF
/// form other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_container_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PREFIX_SCAN));
for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
continue;
}
return Some((pos, *marker));
}
}
None
}
/// The scan variant for a marker found by [`find_container_marker`], with the probe short name its
/// size check stands for. `ftyp` sits four bytes into its atom, so the MP4 check starts there.
fn marker_kind(at: usize, marker: &[u8; 4]) -> (FirstMarker, &'static str) {
match marker {
b"fLaC" => (FirstMarker::Flac(at), expected_probe_format(marker)),
b"caff" => (FirstMarker::Caf(at), expected_probe_format(marker)),
b"ftyp" => (FirstMarker::Mp4(at.saturating_sub(4)), expected_probe_format(marker)),
_ => (FirstMarker::Wav(at), expected_probe_format(marker)),
}
}
/// The probe short name a container scan variant stands for.
fn marker_name(marker: FirstMarker) -> &'static str {
match marker {
FirstMarker::Flac(_) => "flac",
FirstMarker::Caf(_) => "caf",
FirstMarker::Mp4(_) => "isomp4",
FirstMarker::Wav(_) => "wave",
FirstMarker::Audio => "",
}
}
/// Whether `rest` starts with one of `R`'s exact registered probe markers.
fn has_probe_marker<R: ProbeableFormat<'static>>(rest: &[u8]) -> bool {
R::probe_data().iter().any(|data| data.spec.markers.iter().any(|marker| rest.starts_with(marker)))
}
/// Score `R` at `at` exactly as symphonia's private probe does: `Supported` wins, EOF and every
/// other scoring error mean "not this format", while a real I/O error aborts the probe.
fn scores_supported<R: Scoreable>(stream: &mut MediaSourceStream<'_>, at: usize, depth: u16) -> Result<bool> {
stream.seek(SeekFrom::Start(u64::try_from(at).unwrap_or(u64::MAX)))?;
match R::score(ScopedStream::new(stream, u64::from(depth))) {
Ok(Score::Supported(_)) => Ok(true),
Err(SymError::IoError(e)) if e.kind() != std::io::ErrorKind::UnexpectedEof => Err(AudioError::Malformed(e.to_string())),
_ => Ok(false),
}
}
/// Whether `b[at..]` is an MPEG/ADTS candidate the probe would actually select: an exact
/// `probe_data()` marker and a `Supported` score from the same reader. A loose `0xFFEx` pair is
/// not enough: `0xFFE0` is neither a registered marker nor a valid frame, so it must not stop the
/// scan before a later FLAC/CAF/MP4 candidate.
fn audio_sync_at<'a>(b: &'a [u8], at: usize, stream: &mut Option<MediaSourceStream<'a>>, budget: &mut ScoreBudget) -> Result<bool> {
let rest = b.get(at..).unwrap_or_default();
let adts = has_probe_marker::<symphonia::default::formats::AdtsReader<'static>>(rest);
let mpa = has_probe_marker::<symphonia::default::formats::MpaReader<'static>>(rest);
if !adts && !mpa {
return Ok(false);
}
if stream.is_none() {
*stream = Some(MediaSourceStream::new(Box::new(Cursor::new(b)), MediaSourceStreamOptions::default()));
}
let Some(mss) = stream.as_mut() else { return Ok(false) };
let depth = ProbeOptions::default().max_score_depth;
if adts && budget.take() && scores_supported::<symphonia::default::formats::AdtsReader<'static>>(mss, at, depth)? {
return Ok(true);
}
if mpa && budget.take() && scores_supported::<symphonia::default::formats::MpaReader<'static>>(mss, at, depth)? {
return Ok(true);
}
Ok(false)
}
/// Find the first marker that can start a container or audio stream. Symphonia's `Probe::probe`
/// constructs the reader itself and exposes no pre-construction hook, while checking every magic
/// in the scan window would validate bytes the probe never reaches (for example a valid MP3's
/// audio payload). Stopping at the first marker avoids that: it is the candidate the probe selects
/// for FLAC, CAF, MP4, Ogg and Matroska, and for WAV/AIFF once their full form is verified below.
/// MPEG/ADTS candidates are checked with the readers' own `probe_data()`/`Scoreable` APIs, so a
/// false sync byte pair cannot stop the scan. If that scoring budget runs out, the scan stops at
/// the next audio candidate instead of trusting a container-like byte string later in the unscored
/// audio payload. A container marker reached before any such candidate is still preflighted; if
/// the probe selects a size-checked container the scan did not check, `open` refuses it.
///
/// WAV candidates include RF64/BW64 so `check_info` can run, although symphonia 0.6 registers only
/// the RIFF marker. Native PCM RF64/BW64 is parsed by `wav::parse` before this path; a foreign
/// RF64/BW64 stream therefore remains unsupported by the probe rather than misdetected.
fn first_marker(b: &[u8], start: usize) -> Result<Option<FirstMarker>> {
first_marker_with_budget(b, start, &mut ScoreBudget::new())
}
/// [`first_marker`] with an explicit score budget, so a test can prove a prefix crowded with
/// false sync markers performs only the bounded number of reader scores.
fn first_marker_with_budget(b: &[u8], start: usize, budget: &mut ScoreBudget) -> Result<Option<FirstMarker>> {
let end = b.len().min(start.saturating_add(MAX_PREFIX_SCAN));
let mut at = start;
let mut audio_probe = None;
while at.saturating_add(4) <= end {
let rest = b.get(at..).unwrap_or_default();
if rest.get(..4) == Some(b"fLaC".as_slice()) {
return Ok(Some(FirstMarker::Flac(at)));
}
if rest.get(..4) == Some(b"caff".as_slice()) {
return Ok(Some(FirstMarker::Caf(at)));
}
if rest.get(..4) == Some(b"OggS".as_slice()) || rest.get(..4) == Some(b"\x1A\x45\xDF\xA3".as_slice()) || crate::aiff::is_aiff(rest) {
return Ok(Some(FirstMarker::Audio));
}
if crate::wav::is_wav(rest) {
return Ok(Some(FirstMarker::Wav(at)));
}
if let (Some(&b0), Some(&b1)) = (b.get(at), b.get(at + 1))
&& b0 == 0xFF
&& b1 & 0xE0 == 0xE0
{
// A candidate after the cap cannot be scored. Stop rather than let a container-like
// byte string later in an unscored audio payload become preflight-authoritative; the
// probe will select the reader, and `open` refuses an unchecked size-checked one.
if budget.exhausted() {
return Ok(None);
}
if audio_sync_at(b, at, &mut audio_probe, budget)? {
return Ok(Some(FirstMarker::Audio));
}
}
if rest.get(4..8) == Some(b"ftyp".as_slice()) {
return Ok(Some(FirstMarker::Mp4(at)));
}
at += 1;
}
Ok(None)
}
/// Run the early size checks on the container the probe can select. `start` is the first byte
/// after leading ID3v2 tags. A zero-frame FLAC is returned as an empty stream because Symphonia
/// 0.6's reader requires a first audio frame to resynchronize to.
fn precheck(b: &[u8], start: usize, detected: FileFormat) -> Result<Precheck> {
let any_marker = |b: &[u8], start: usize| -> Result<Option<(FirstMarker, &'static str)>> {
Ok(match first_marker(b, start)? {
// Stop at the first marker of any kind, so container magic incidental to an audio
// payload is not mistaken for the stream's container.
Some(FirstMarker::Audio) | None => None,
Some(marker) => Some((marker, marker_name(marker))),
})
};
let found = match container_markers(detected) {
// Follow the detection: check the first marker of the family it names, so a misleading
// marker cannot let a differently sized container reach the probe unchecked. A detection
// is only a hint, though: when the stream holds none of its markers (a FLAC behind a
// `.mp3` name), the probe can still select another container, which is checked here.
Some(markers) => match find_container_marker(b, start, markers) {
Some((at, marker)) => Some(marker_kind(at, marker)),
None => any_marker(b, start)?,
},
None => any_marker(b, start)?,
};
let Some((marker, expected)) = found else { return Ok(Precheck::Unchecked) };
match marker {
FirstMarker::Flac(at) => {
crate::flac::check_metadata(b, at)?;
if let Some(info) = crate::flac::empty_stream_info(b, at) {
return Ok(Precheck::Empty(info));
}
Ok(Precheck::Checked(expected))
}
FirstMarker::Caf(at) => {
crate::caf::check(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Mp4(at) => {
crate::mp4::check(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Wav(at) => {
crate::wav::check_info(b, at)?;
Ok(Precheck::Checked(expected))
}
FirstMarker::Audio => Ok(Precheck::Unchecked),
}
}
/// Skip leading ID3v2 tags, run the container size preflight, then probe with symphonia; a zero-frame FLAC found by
/// the preflight comes back as [`Opened::Empty`] without probing. A size-checked container is refused when probing
/// selects a different reader or one that was never checked, so incidental payload magic cannot bypass those limits.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let start = skip_id3v2(bytes);
if start > 0 && start >= bytes.len() {
return Err(AudioError::Malformed("ID3v2 tag extends past the end of the file".into()));
}
let checked = precheck(bytes, start, detected)?;
if let Precheck::Empty(info) = checked {
return Ok(Opened::Empty(info));
}
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
// Seek (rather than pre-positioning the cursor) so the stream's absolute position is `start`
// too: container offsets such as MP4's `stco` are absolute file offsets.
mss.seek(SeekFrom::Start(u64::try_from(start).unwrap_or(u64::MAX)))?;
let reader: Box<dyn FormatReader> =
symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
let probed = reader.format_info().short_name;
match checked {
// Only containers whose readers allocate from sizes in the file need the check. A stream
// the probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a
// marker found in its audio data must not reject it.
Precheck::Checked(expected) if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) && probed != expected => {
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
}
Precheck::Unchecked if SIZE_CHECKED_PROBE_FORMATS.contains(&probed) => {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
}
_ => {}
}
Ok(Opened::Reader(reader))
}
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other {
return detected;
}
match codec {
c if c == CODEC_ID_FLAC => FileFormat::Flac,
c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
c if c == CODEC_ID_AAC => FileFormat::Aac,
c if c == CODEC_ID_ALAC => FileFormat::Alac,
c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
_ => FileFormat::Other,
}
}
/// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
/// codecs decode to `Compressed`.
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
let c = p.codec;
if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
return SampleFormat::Float32;
}
if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
return SampleFormat::Float64;
}
let lossy = [
CODEC_ID_MP1,
CODEC_ID_MP2,
CODEC_ID_MP3,
CODEC_ID_AAC,
CODEC_ID_VORBIS,
CODEC_ID_OPUS,
CODEC_ID_ADPCM_MS,
CODEC_ID_ADPCM_IMA_WAV,
CODEC_ID_ADPCM_IMA_QT,
CODEC_ID_ADPCM_G722,
CODEC_ID_ADPCM_G726,
CODEC_ID_ADPCM_G726LE,
CODEC_ID_PCM_ALAW,
CODEC_ID_PCM_MULAW,
];
let lossless = !lossy.contains(&c);
match (lossless, p.bits_per_sample) {
(true, Some(1..=8)) => SampleFormat::Int8,
(true, Some(9..=16)) => SampleFormat::Int16,
(true, Some(17..=24)) => SampleFormat::Int24,
(true, Some(25..=32)) => SampleFormat::Int32,
_ => SampleFormat::Compressed,
}
}
/// The audio parameters of `track`, if it carries any.
fn audio_params(track: &Track) -> Option<&AudioCodecParameters> {
track.codec_params.as_ref()?.audio()
}
/// The first track with a decodable audio codec, with the stream information the UI shows.
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
let track = reader
.tracks()
.iter()
.find(|t| audio_params(t).is_some_and(|p| p.codec != CODEC_ID_NULL_AUDIO))
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let p = audio_params(track).ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((
track.id,
AudioInfo {
format: format_from_codec(p.codec, detected),
sample_format: sample_format_of(p),
sample_rate,
channels,
frames: track.num_frames.unwrap_or(0),
bwf: None,
},
))
}
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
if frames == 0 {
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
Ok(())
}
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
log::debug!("skipping corrupt packet: {msg}");
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
}
Ok(())
}
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((0..ch).map(|_| Vec::new()).collect())
}
/// Append one interleaved block to the planes, replacing non-finite samples with silence.
fn append_interleaved(planar: &mut [Vec<f32>], interleaved: &[f32], ch: usize) {
for frame in interleaved.chunks_exact(ch) {
for (dst, &s) in planar.iter_mut().zip(frame) {
dst.push(if s.is_finite() { s } else { 0.0 });
}
}
}
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => {
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
return Ok((info, buf));
}
};
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
let track = reader.tracks().iter().find(|t| t.id == track_id).ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
if let Some(n) = track.num_frames {
let total = n.saturating_mul(u64::from(info.channels.max(1)));
if !count_only && total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
}
}
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) {
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)?;
let mut planar: Vec<Vec<f32>> = Vec::new();
let mut interleaved: Vec<f32> = Vec::new();
let mut frames: u64 = 0;
let mut errors = 0usize;
loop {
let packet = match reader.next_packet() {
Ok(Some(packet)) => packet,
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
demux_failure(frames, e)?;
break;
}
};
if packet.track_id != track_id {
continue;
}
let decoded = match decoder.decode(&packet) {
Ok(d) => d,
Err(SymError::DecodeError(m)) => {
errors += 1;
corrupt_packet(errors, frames, m)?;
continue;
}
Err(SymError::IoError(_)) => break,
Err(e) => return Err(map_err(e)),
};
// The interleaved copy below is laid out by planes: use their count so a decoder whose
// channel spec disagrees with its planes cannot trip the copy's length assertion.
let ch = decoded.num_planes();
if ch == 0 {
continue;
}
if planar.is_empty() {
planar = init_planar(&mut info, ch)?;
}
let n = decoded.frames() as u64;
frames = frames.saturating_add(n);
if frames.saturating_mul(ch as u64) > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()));
}
if count_only {
continue;
}
let needed = usize::try_from(n)
.ok()
.and_then(|n| n.checked_mul(ch))
.ok_or_else(|| AudioError::TooLarge("decoded audio exceeds the 4 GiB limit".into()))?;
if interleaved.len() < needed {
interleaved.try_reserve(needed - interleaved.len()).map_err(|_| AudioError::TooLarge("cannot allocate decoded audio".into()))?;
interleaved.resize(needed, 0.0);
}
decoded.copy_to_slice_interleaved(&mut interleaved[..needed]);
append_interleaved(&mut planar, &interleaved[..needed], ch);
}
if info.channels == 0 {
return Err(AudioError::Malformed("stream contains no audio".into()));
}
if planar.is_empty() {
planar = (0..usize::from(info.channels)).map(|_| Vec::new()).collect();
}
info.frames = if count_only { frames } else { planar.iter().map(Vec::len).min().unwrap_or(0) as u64 };
let sample_rate = info.sample_rate;
Ok((info, AudioBuffer { sample_rate, channels: planar }))
}
/// Frames symphonia's AAC decoder yields for every packet; it supports no other frame length.
const AAC_FRAMES_PER_PACKET: u64 = 1024;
/// Whether `track` of `reader` is AAC in MP4. The MP4 reader reports the stts total as the length,
/// but the decoder yields a whole frame per packet (sample durations are not applied), so a last
/// sample whose duration differs from a frame makes the two disagree.
fn is_mp4_aac(reader: &dyn FormatReader, track_id: u32) -> bool {
reader.format_info().short_name == "isomp4"
&& reader.tracks().iter().find(|t| t.id == track_id).and_then(audio_params).is_some_and(|p| p.codec == CODEC_ID_AAC)
}
/// The decoded length of AAC in MP4: count the track's packets without decoding them.
fn mp4_aac_frames(reader: &mut dyn FormatReader, track_id: u32) -> Result<u64> {
let mut frames: u64 = 0;
loop {
match reader.next_packet() {
Ok(Some(packet)) if packet.track_id == track_id => frames = frames.saturating_add(AAC_FRAMES_PER_PACKET),
Ok(Some(_)) => {}
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break,
Err(e) => {
demux_failure(frames, e)?;
break;
}
}
}
Ok(frames)
}
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| {
let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info),
};
let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if info.frames > 0 && info.channels > 0 && is_mp4_aac(reader.as_ref(), track_id) {
info.frames = mp4_aac_frames(reader.as_mut(), track_id)?;
}
if info.frames > 0 && info.channels > 0 {
return Ok(info);
}
// Length not in the header (e.g. MP3 without a Xing frame): count by decoding.
run(bytes, detected, true).map(|(i, _)| i)
})
}
pub(crate) fn decode(bytes: &[u8], detected: FileFormat) -> Result<(AudioInfo, AudioBuffer)> {
guarded(|| run(bytes, detected, false))
}
#[cfg(test)]
mod tests {
use super::*;
use symphonia::core::codecs::audio::well_known::{
CODEC_ID_AAC, CODEC_ID_ADPCM_MS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP3, CODEC_ID_PCM_F32LE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_S16LE,
CODEC_ID_VORBIS,
};
/// Build codec parameters for a test, optionally declaring a bit depth.
fn params(codec: AudioCodecId, bits_per_sample: Option<u32>) -> AudioCodecParameters {
let mut params = AudioCodecParameters::new();
params.for_codec(codec);
if let Some(bits) = bits_per_sample {
params.with_bits_per_sample(bits);
}
params
}
/// Codec ids map to their reported formats, while a format detected from the container wins over the codec.
#[test]
fn codecs_map_to_the_reported_formats() {
assert_eq!(format_from_codec(CODEC_ID_FLAC, FileFormat::Other), FileFormat::Flac);
assert_eq!(format_from_codec(CODEC_ID_MP3, FileFormat::Other), FileFormat::Mp3);
assert_eq!(format_from_codec(CODEC_ID_AAC, FileFormat::Other), FileFormat::Aac);
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Other), FileFormat::Alac);
assert_eq!(format_from_codec(CODEC_ID_VORBIS, FileFormat::Other), FileFormat::Ogg);
// The container detected from the bytes wins over the codec.
assert_eq!(format_from_codec(CODEC_ID_ALAC, FileFormat::Aac), FileFormat::Aac);
}
/// PCM and FLAC keep their declared bit depth; lossy codecs report `Compressed` regardless of their decoded
/// width.
#[test]
fn sample_formats_keep_their_depth_and_lossy_marker() {
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_S16LE, Some(16))), SampleFormat::Int16);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(24))), SampleFormat::Int24);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(8))), SampleFormat::Int8);
assert_eq!(sample_format_of(&params(CODEC_ID_FLAC, Some(32))), SampleFormat::Int32);
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_F32LE, Some(32))), SampleFormat::Float32);
assert_eq!(sample_format_of(&params(CODEC_ID_PCM_F64LE, Some(64))), SampleFormat::Float64);
// Lossy codecs report no fixed bit depth, whatever their decoded sample format is.
assert_eq!(sample_format_of(&params(CODEC_ID_AAC, Some(16))), SampleFormat::Compressed);
assert_eq!(sample_format_of(&params(CODEC_ID_ADPCM_MS, Some(4))), SampleFormat::Compressed);
assert_eq!(sample_format_of(&params(CODEC_ID_ALAC, None)), SampleFormat::Compressed);
}
/// Decoder options keep gapless trimming enabled so the encoder delay and padding reported by the demuxer are
/// removed.
#[test]
fn gapless_decoding_stays_enabled() {
assert!(decoder_options().gapless);
}
/// A complete MPEG frame header is a candidate; loose or unregistered sync pairs are rejected before scoring,
/// while a registered but truncated header is rejected by the reader's score after consuming a budget slot.
#[test]
fn only_the_readers_own_audio_markers_are_candidates() {
/// Whether the preflight would score `b` as an audio candidate at offset 0.
fn candidate(b: &[u8]) -> bool {
let mut stream = None;
let mut budget = ScoreBudget::new();
matches!(audio_sync_at(b, 0, &mut stream, &mut budget), Ok(true))
}
let mut mp3_frame = vec![0u8; 417];
mp3_frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz
assert!(candidate(&[&mp3_frame[..], &mp3_frame].concat()));
for false_sync in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
assert!(!candidate(&false_sync), "{false_sync:?} is not a probe candidate");
}
}
/// A prefix made of false MPEG sync markers must not ask the readers to score every byte
/// position: the per-scan budget caps the expensive work. A cheap container marker reached
/// before the next audio candidate is still found, but once the cap is spent an audio
/// candidate stops the scan so later payload magic cannot be preflight-authoritative.
#[test]
fn a_prefix_crowded_with_sync_markers_scores_only_the_budget() {
let mut crowded = vec![0u8; 1 << 16];
for pair in crowded.as_chunks_mut::<2>().0 {
pair.copy_from_slice(&[0xFF, 0xE2]);
}
let mut budget = ScoreBudget::new();
assert!(matches!(first_marker_with_budget(&crowded, 0, &mut budget), Ok(None)));
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS, "no candidate after the cap is scored");
// At the cap boundary, with no later audio candidate, the cheap marker is still found.
let mut at_cap: Vec<u8> = (0..MAX_AUDIO_SCORE_ATTEMPTS).flat_map(|_| [0xFFu8, 0xE2]).collect();
at_cap.extend_from_slice(b"fLaC");
let mut budget = ScoreBudget::new();
assert!(
matches!(first_marker_with_budget(&at_cap, 0, &mut budget), Ok(Some(FirstMarker::Flac(_)))),
"a container marker reached before the next audio candidate is still found"
);
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
// One more false candidate between the cap and the magic makes the scan stop unchecked:
// the same magic could instead sit in a valid audio payload.
let mut after_candidate = at_cap[..2 * MAX_AUDIO_SCORE_ATTEMPTS].to_vec();
after_candidate.extend_from_slice(&[0xFF, 0xE2]);
after_candidate.extend_from_slice(b"fLaC");
let mut budget = ScoreBudget::new();
assert!(matches!(first_marker_with_budget(&after_candidate, 0, &mut budget), Ok(None)), "an audio candidate after the cap stops the scan");
assert_eq!(budget.used, MAX_AUDIO_SCORE_ATTEMPTS);
}
/// ID3v2 sizes are read in their 7-bit syncsafe form; consecutive tags are skipped by their declared sizes, and
/// the v2.3 reserved footer bit does not extend a tag.
#[test]
fn leading_id3_tags_are_skipped_by_their_declared_size() {
let tag = [&b"ID3\x04\0\0\0\0\0\x05"[..], b"hello"].concat();
assert_eq!(skip_id3v2(&[&tag[..], &tag[..]].concat()), 30);
assert_eq!(skip_id3v2(b"fLaC"), 0);
// The 10-byte footer flag exists only in v2.4; the same bit in v2.3 is reserved.
assert_eq!(crate::id3v2_len(b"ID3\x03\0\x10\0\0\0\0"), Some(10));
assert_eq!(crate::id3v2_len(b"ID3\x04\0\x10\0\0\0\0"), Some(20));
}
}
-562
View File
@@ -1,562 +0,0 @@
//! Native WAV / BWF / RF64 reader and writer (RIFF chunk layout per the Microsoft multimedia
//! specification, `bext` per EBU Tech 3285, `ds64` per EBU Tech 3306).
use crate::pcm::{PcmKind, Quantizer, deinterleave, le_u16, le_u32, le_u64, sample_format, tag, validate_buffer, validate_layout, write_interleaved};
use crate::{AudioBuffer, AudioError, AudioInfo, BitDepth, BwfInfo, FileFormat, Result, SampleFormat};
use std::io::{Cursor, Seek, SeekFrom, Write};
const WAVE_FORMAT_PCM: u16 = 1;
const WAVE_FORMAT_IEEE_FLOAT: u16 = 3;
const WAVE_FORMAT_EXTENSIBLE: u16 = 0xFFFE;
/// Tail of the KSDATAFORMAT_SUBTYPE_* GUIDs (bytes 2..16); bytes 0..2 hold the format tag.
const GUID_TAIL: [u8; 14] = [0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0x80, 0x00, 0x00, 0xAA, 0x00, 0x38, 0x9B, 0x71];
/// Tail of the Ambisonic subtype GUIDs {0000000x-0721-11D3-8644-C8C1CA000000}: PCM (x = 1) and float (x = 3) as well.
const AMBISONIC_TAIL: [u8; 14] = [0x00, 0x00, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0x00, 0x00, 0x00];
const BEXT_MIN_LEN: usize = 602;
/// Parsed WAV layout (no sample data decoded yet).
pub(crate) struct WavLayout {
pub info: AudioInfo,
pub kind: PcmKind,
pub data_start: usize,
pub data_len: usize,
}
/// Outcome of parsing a WAV header.
pub(crate) enum WavParse {
/// A PCM / float layout we decode natively.
Native(WavLayout),
/// A valid WAV whose codec we hand to symphonia (ADPCM, A-law, ...).
Foreign { bwf: Option<BwfInfo> },
}
struct Fmt {
tag: u16,
channels: u16,
sample_rate: u32,
block_align: u16,
bits: u16,
valid_bits: u16,
}
fn parse_fmt(c: &[u8]) -> Result<Fmt> {
let short = || AudioError::Malformed("fmt chunk too short".into());
let mut fmt = Fmt {
tag: le_u16(c, 0).ok_or_else(short)?,
channels: le_u16(c, 2).ok_or_else(short)?,
sample_rate: le_u32(c, 4).ok_or_else(short)?,
block_align: le_u16(c, 12).ok_or_else(short)?,
bits: le_u16(c, 14).unwrap_or(0),
valid_bits: 0,
};
fmt.valid_bits = fmt.bits;
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
let (Some(cb_size), Some(valid), Some(sub)) = (le_u16(c, 16), le_u16(c, 18), le_u16(c, 24)) else {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
};
let Some(tail) = c.get(26..40) else {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE fmt chunk too short".into()));
};
if cb_size < 22 {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE cbSize is below 22".into()));
}
if valid > fmt.bits {
return Err(AudioError::Malformed("WAVE_FORMAT_EXTENSIBLE valid bits exceed the container size".into()));
}
if valid != 0 {
fmt.valid_bits = valid;
}
// A subtype outside the KSDATAFORMAT_SUBTYPE_* family is not PCM/float, whatever its first two bytes say:
// keep the extensible tag so the file goes to the foreign decoder.
if tail == GUID_TAIL || tail == AMBISONIC_TAIL {
fmt.tag = sub;
}
}
Ok(fmt)
}
/// Read a NUL-padded ASCII field.
fn fixed_str(b: &[u8]) -> String {
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
String::from_utf8_lossy(b.get(..end).unwrap_or_default()).trim_end().to_string()
}
fn parse_bext(c: &[u8]) -> Option<BwfInfo> {
if c.len() < 346 {
return None;
}
let lo = u64::from(le_u32(c, 338)?);
let hi = u64::from(le_u32(c, 342)?);
Some(BwfInfo {
description: fixed_str(c.get(0..256)?),
originator: fixed_str(c.get(256..288)?),
originator_reference: fixed_str(c.get(288..320)?),
origination_date: fixed_str(c.get(320..330)?),
origination_time: fixed_str(c.get(330..338)?),
time_reference: (hi << 32) | lo,
})
}
/// Refuse the WAVE file at `at`, which symphonia reads (A-law, ADPCM, ...), if an INFO entry
/// claims more bytes than the file holds: symphonia allocates each entry's length. Chunks are
/// walked where symphonia reads them: after the bytes a chunk's parser used, not its length.
pub(crate) fn check_info(b: &[u8], at: usize) -> Result<()> {
if tag(b, at + 8) != Some(*b"WAVE") {
return Ok(());
}
let mut pos = at + 12;
while let Some(id) = tag(b, pos) {
let Some(len) = le_u32(b, pos + 4).map(|n| n as usize) else { break };
let (body, pad) = (pos + 8, len & 1);
let used = match &id {
b"data" => break,
b"fmt " => fmt_used(b, body, len),
b"LIST" if len < 4 => break,
b"LIST" => {
let entries = if tag(b, body) == Some(*b"INFO") { info_entries(b, body + 4, len - 4)? } else { 0 };
// The list pads itself, then the chunk is padded again.
4 + entries.max(len - 4) + pad
}
_ => len,
};
pos = body.saturating_add(used).saturating_add(pad);
}
Ok(())
}
/// The bytes symphonia reads of a `fmt ` chunk: some formats' parsers go by their own extra size.
fn fmt_used(b: &[u8], body: usize, len: usize) -> usize {
match le_u16(b, body) {
Some(2 | 6 | 7 | 0x11) => le_u16(b, body + 16).map_or(len, |extra| 18 + usize::from(extra)),
Some(0xfffe) => 40,
_ => len,
}
}
/// Check the INFO entries in the `len` bytes from `start`; returns the bytes symphonia reads of
/// them, with the pad it reads after an odd entry.
fn info_entries(b: &[u8], start: usize, len: usize) -> Result<usize> {
let mut used = 0;
while used + 8 <= len {
let Some(n) = le_u32(b, start + used + 4).map(|n| n as usize) else { break };
if n > b.len().saturating_sub(start + used + 8) {
return Err(AudioError::Malformed("a WAV INFO entry is larger than the file".into()));
}
used += 8 + n;
used += used & 1;
}
Ok(used)
}
/// True when the bytes start like a RIFF/RF64/BW64 WAVE file.
pub(crate) fn is_wav(b: &[u8]) -> bool {
matches!(tag(b, 0), Some(t) if &t == b"RIFF" || &t == b"RF64" || &t == b"BW64") && tag(b, 8).is_some_and(|t| &t == b"WAVE")
}
/// True when everything from `from` to the end of the file is whole RIFF chunks (four printable
/// ASCII id bytes, a size that fits, the pad byte after an odd size; the last chunk's pad may be
/// missing): the metadata of a finished file, not samples.
fn only_chunks_follow(b: &[u8], from: usize) -> bool {
let mut pos = from;
while pos < b.len() {
let (Some(id), Some(size)) = (tag(b, pos), le_u32(b, pos + 4)) else { return false };
if !id.iter().all(|c| (0x20..0x7f).contains(c)) {
return false;
}
let size = size as usize;
let Some(end) = pos.checked_add(8).and_then(|body| body.checked_add(size)) else { return false };
if end > b.len() {
return false;
}
let next = end + (size & 1);
if end == b.len() || next == b.len() {
return true;
}
pos = next;
}
false
}
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
struct DataChunk {
body: usize,
size32: u32,
is_rf64: bool,
remaining: usize,
declared: usize,
}
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
/// the recording was never finalised.
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
}
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
Ok(Some(match (fmt.tag, fmt.bits) {
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(None),
}))
}
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if !is_wav(b) {
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
}
let is_rf64 = tag(b, 0).is_some_and(|t| &t != b"RIFF");
let mut ds64_data: Option<u64> = None;
let mut fmt: Option<Fmt> = None;
let mut bwf: Option<BwfInfo> = None;
let mut data: Option<(usize, usize)> = None;
let mut pos: usize = 12;
// Bound the number of chunks we look at so a file of tiny chunks can't spin forever.
let mut chunks = 0usize;
while let (Some(id), Some(size32)) = (tag(b, pos), le_u32(b, pos + 4)) {
chunks += 1;
if chunks > 100_000 {
break;
}
let body = pos.checked_add(8).ok_or_else(|| AudioError::Malformed("chunk offset overflow".into()))?;
let remaining = b.len().saturating_sub(body);
let mut size = u64::from(size32);
if &id == b"data" && is_rf64 && size32 == u32::MAX {
size = ds64_data.unwrap_or(remaining as u64);
}
let declared = usize::try_from(size).unwrap_or(usize::MAX);
if &id == b"data" {
// Lenient: a truncated recording (or a streaming writer's 0 / 0xFFFFFFFF size) still
// yields whatever audio is actually present.
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
// that was never finalised, and what follows is audio.
let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
if data.is_none() {
data = Some((body, len));
}
if declared > remaining {
break;
}
} else {
if declared > remaining {
// A truncated non-audio chunk at the end of the file: stop scanning.
break;
}
let c = b.get(body..body + declared).unwrap_or_default();
match &id {
b"fmt " => fmt = Some(parse_fmt(c)?),
b"bext" => bwf = parse_bext(c),
b"ds64" => ds64_data = le_u64(c, 8),
_ => {}
}
}
let padded = declared.saturating_add(declared & 1);
pos = match body.checked_add(padded) {
Some(p) => p,
None => break,
};
}
let fmt = fmt.ok_or_else(|| AudioError::Malformed("WAV has no fmt chunk".into()))?;
if fmt.channels == 0 {
return Err(AudioError::Malformed("WAV declares zero channels".into()));
}
if fmt.sample_rate == 0 {
return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
}
let channels = usize::from(fmt.channels);
let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
if usize::from(fmt.block_align) != block {
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
}
let (data_start, data_len) = data.ok_or_else(|| AudioError::Malformed("WAV has no data chunk".into()))?;
let frames = (data_len / block) as u64;
Ok(WavParse::Native(WavLayout {
info: AudioInfo { format: FileFormat::Wav, sample_format, sample_rate: fmt.sample_rate, channels: fmt.channels, frames, bwf },
kind,
data_start,
data_len,
}))
}
pub(crate) fn decode(b: &[u8], layout: &WavLayout) -> Result<AudioBuffer> {
let end = layout.data_start.saturating_add(layout.data_len).min(b.len());
let data = b.get(layout.data_start..end).unwrap_or_default();
let channels = deinterleave(data, layout.kind, usize::from(layout.info.channels), layout.info.frames)?;
Ok(AudioBuffer { sample_rate: layout.info.sample_rate, channels })
}
/// Default speaker mask for a channel count (callers that know the layout pass their own):
/// mono, stereo, LCR, quad, 5.0, 5.1, 6.1, 7.1 (sides + rears), 7.1.4.
fn channel_mask(channels: usize) -> u32 {
match channels {
1 => 0x4,
2 => 0x3,
3 => 0x7,
4 => 0x33,
5 => 0x37,
6 => 0x3F,
7 => 0x13F,
8 => 0x63F,
// L R C LFE Lrs Rrs Lss Rss Ltf Rtf Ltr Rtr (7.1.4).
12 => 0x2_D63F,
n if n <= 18 => (1u32 << n) - 1,
_ => 0,
}
}
/// The `dwChannelMask` of a WAVE_FORMAT_EXTENSIBLE file (None for plain PCM/float headers).
pub(crate) fn read_channel_mask(b: &[u8]) -> Option<u32> {
if !is_wav(b) {
return None;
}
let mut pos = 12usize;
for _ in 0..10_000 {
let (id, size) = (tag(b, pos)?, usize::try_from(le_u32(b, pos + 4)?).ok()?);
if &id == b"fmt " {
return (le_u16(b, pos + 8)? == WAVE_FORMAT_EXTENSIBLE).then(|| le_u32(b, pos + 8 + 20)).flatten();
}
pos = pos.checked_add(8)?.checked_add(size)?.checked_add(size & 1)?;
}
None
}
fn put_fixed_str(out: &mut Vec<u8>, s: &str, len: usize) {
let bytes = s.as_bytes();
let mut n = bytes.len().min(len);
while !s.is_char_boundary(n) {
n -= 1;
}
out.extend_from_slice(bytes.get(..n).unwrap_or_default());
out.resize(out.len() + (len - n), 0);
}
fn bext_chunk(bwf: &BwfInfo) -> Vec<u8> {
let mut c = Vec::with_capacity(BEXT_MIN_LEN);
put_fixed_str(&mut c, &bwf.description, 256);
put_fixed_str(&mut c, &bwf.originator, 32);
put_fixed_str(&mut c, &bwf.originator_reference, 32);
put_fixed_str(&mut c, &bwf.origination_date, 10);
put_fixed_str(&mut c, &bwf.origination_time, 8);
c.extend_from_slice(&((bwf.time_reference & 0xFFFF_FFFF) as u32).to_le_bytes());
c.extend_from_slice(&((bwf.time_reference >> 32) as u32).to_le_bytes());
c.extend_from_slice(&1u16.to_le_bytes()); // version 1
c.resize(BEXT_MIN_LEN, 0); // UMID, loudness, reserved; no coding history
c
}
fn push_chunk(out: &mut Vec<u8>, id: &[u8; 4], body: &[u8]) {
out.extend_from_slice(id);
out.extend_from_slice(&u32::try_from(body.len()).unwrap_or(u32::MAX).to_le_bytes());
out.extend_from_slice(body);
if body.len() % 2 == 1 {
out.push(0);
}
}
/// A WAV written block by block: a placeholder header first, then the real one (the same length,
/// RF64 above 4 GiB, or always when `force_rf64`) written over it by [`Writer::finish`].
pub(crate) struct Writer<W> {
out: W,
start: u64,
channels: usize,
sample_rate: u32,
format: (u16, bool),
bwf: Option<BwfInfo>,
mask: Option<u32>,
force_rf64: bool,
quantizers: Vec<Quantizer>,
frames: u64,
bytes: Vec<u8>,
/// Set when a block write has failed: the sink may hold a prefix of that block while
/// `frames` does not count it, so the writer refuses every later call.
failed: bool,
}
/// The optional header parts of a WAV file: BWF metadata, speaker mask, and whether to force RF64.
#[derive(Clone, Copy)]
pub(crate) struct HeaderExtras<'a> {
pub(crate) bwf: Option<&'a BwfInfo>,
pub(crate) mask: Option<u32>,
pub(crate) force_rf64: bool,
}
impl<W: Write + Seek> Writer<W> {
pub(crate) fn new(mut out: W, channels: usize, sample_rate: u32, depth: BitDepth, dither: bool, extras: HeaderExtras<'_>) -> Result<Self> {
let HeaderExtras { bwf, mask, force_rf64 } = extras;
validate_layout(channels, sample_rate)?;
let format = sample_format(depth);
let start = out.stream_position()?;
out.write_all(&header(channels, sample_rate, format, bwf, mask, 0, force_rf64)?)?;
let quantizers = Quantizer::per_channel(u32::from(format.0), dither, channels);
Ok(Writer {
out,
start,
channels,
sample_rate,
format,
bwf: bwf.cloned(),
mask,
force_rf64,
quantizers,
frames: 0,
bytes: Vec::new(),
failed: false,
})
}
fn header(&self, frames: u64) -> Result<Vec<u8>> {
header(self.channels, self.sample_rate, self.format, self.bwf.as_ref(), self.mask, frames, self.force_rf64)
}
/// The frame count after `frames` more (RF64 has room for any bounce).
pub(crate) fn check_room(&self, frames: u64) -> Result<u64> {
let total = self.frames.saturating_add(frames);
self.header(total)?;
Ok(total)
}
/// Appends `frames` frames of planar audio (missing samples are silence).
pub(crate) fn write(&mut self, block: &[Vec<f32>], frames: usize) -> Result<()> {
if self.failed {
return Err(crate::failed_write_error());
}
let total = self.check_room(frames as u64)?;
if let Err(e) = write_interleaved(&mut self.out, &mut self.bytes, block, frames, &mut self.quantizers, self.format, false) {
self.failed = true;
return Err(AudioError::from(e));
}
self.frames = total;
Ok(())
}
pub(crate) fn finish(mut self) -> Result<W> {
if self.failed {
return Err(crate::failed_write_error());
}
let data_len = u64::from(self.format.0 / 8).saturating_mul(self.channels as u64).saturating_mul(self.frames);
if data_len % 2 == 1 {
self.out.write_all(&[0])?;
}
let head = self.header(self.frames)?;
self.out.seek(SeekFrom::Start(self.start))?;
self.out.write_all(&head)?;
self.out.seek(SeekFrom::End(0))?;
self.out.flush()?;
Ok(self.out)
}
}
/// Encode a WAV file. `force_rf64` writes the RF64 layout even for small files (for tests);
/// it is used automatically when the file would exceed 4 GiB.
/// `mask` overrides the speaker mask derived from the channel count.
pub(crate) fn encode(
buf: &AudioBuffer,
depth: BitDepth,
dither: bool,
bwf: Option<&BwfInfo>,
force_rf64: bool,
mask: Option<u32>,
) -> Result<Vec<u8>> {
validate_buffer(buf)?;
let channels = buf.channels.len();
let frames = buf.frames();
let format = sample_format(depth);
let head = header(channels, buf.sample_rate, format, bwf, mask, frames as u64, force_rf64)?;
let data_len = (usize::from(format.0 / 8) * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
.map_err(|_| AudioError::TooLarge(format!("cannot allocate {data_len} bytes for WAV output")))?;
let mut w = Writer::new(Cursor::new(out), channels, buf.sample_rate, depth, dither, HeaderExtras { bwf, mask, force_rf64 })?;
w.write(&buf.channels, frames)?;
Ok(w.finish()?.into_inner())
}
/// Everything before the sample data of a WAV holding `frames` frames.
pub(crate) fn header(
channels: usize,
sample_rate: u32,
(bits, is_float): (u16, bool),
bwf: Option<&BwfInfo>,
mask: Option<u32>,
frames: u64,
force_rf64: bool,
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = ((4 + (8 + rf64_body_len) + meta.len() + 8) as u64)
.checked_add(data_len)
.and_then(|t| t.checked_add(data_len & 1))
.ok_or_else(|| AudioError::TooLarge("WAV size overflow".into()))?;
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&total.to_le_bytes());
ds64.extend_from_slice(&data_len.to_le_bytes());
ds64.extend_from_slice(&frames.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
Ok(head)
}
-17
View File
@@ -1,17 +0,0 @@
Tiny fixtures for `tests/it/decode_read_list.rs`: one per container or codec the READ list names
that the crate cannot write itself. Made with ffmpeg 8.0 from a 0.1 s mono 440 Hz sine
(`F="-y -f lavfi -i sine=frequency=440:duration=0.1 -ac 1"`):
ffmpeg $F -ar 8000 -c:a libmp3lame -b:a 16k tone.mp3
ffmpeg $F -ar 24000 -c:a mp2 -b:a 32k tone.mp2
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.ogg
ffmpeg $F -ar 8000 -c:a aac -b:a 16k tone.m4a
ffmpeg $F -ar 8000 -c:a aac -b:a 16k -f adts tone.aac
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.caf
ffmpeg $F -ar 8000 -c:a pcm_s16le tone.mkv
ffmpeg $F -ar 8000 -c:a libvorbis -q:a -1 tone.webm
ffmpeg $F -ar 8000 -c:a pcm_s16le -f aiff tone.aifc # AIFF-C "sowt"
`silence.mp1` has no ffmpeg encoder. It is four hand-built MPEG-1 Layer I frames (mono, 32 kHz,
32 kbit/s, 48 bytes each: header `FF FF 18 C0`, all subband allocations zero), which decode to
silence.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,51 +0,0 @@
//! Every extension the format table reads decodes with the compiled-in `symphonia` features.
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat, decode, encode, probe};
fn fixture(name: &str) -> Vec<u8> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name);
std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e}", path.display()))
}
fn generated(format: FileFormat) -> Vec<u8> {
let tone: Vec<f32> = (0..800).map(|i| (i as f32 * 0.1).sin() * 0.5).collect();
let buf = AudioBuffer { sample_rate: 8000, channels: vec![tone] };
encode(&buf, &EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None }).unwrap()
}
fn bytes_for(ext: &str) -> Vec<u8> {
match ext {
"wav" | "wave" | "bwf" => generated(FileFormat::Wav),
"rf64" => {
let tone = AudioBuffer { sample_rate: 8000, channels: vec![vec![0.25; 800]] };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
soundcraft_audio_io::encode_wav_rf64(&tone, &opts).unwrap()
}
"aif" | "aiff" => generated(FileFormat::Aiff),
"aifc" => fixture("tone.aifc"),
"flac" => generated(FileFormat::Flac),
"mp3" => fixture("tone.mp3"),
"mp2" => fixture("tone.mp2"),
"mp1" => fixture("silence.mp1"),
"ogg" | "oga" => fixture("tone.ogg"),
"m4a" | "m4b" | "mp4" => fixture("tone.m4a"),
"aac" | "adts" => fixture("tone.aac"),
"caf" => fixture("tone.caf"),
"mkv" => fixture("tone.mkv"),
"webm" => fixture("tone.webm"),
other => panic!("no fixture for the READ extension {other:?}: add one"),
}
}
#[test]
fn every_read_extension_probes_and_decodes() {
for &format in FileFormat::READ {
for ext in format.extensions() {
let bytes = bytes_for(ext);
let info = probe(&bytes, Some(ext)).unwrap_or_else(|e| panic!("probe .{ext}: {e}"));
let (_, buf) = decode(&bytes, Some(ext)).unwrap_or_else(|e| panic!("decode .{ext}: {e}"));
assert!(info.channels >= 1, ".{ext}");
assert!(buf.frames() > 0, ".{ext}: no frames");
}
}
}
-36
View File
@@ -1,36 +0,0 @@
use soundcraft_audio_io::FileFormat;
#[test]
fn every_read_and_write_format_round_trips_through_its_extensions() {
for &format in FileFormat::READ {
assert!(!format.extensions().is_empty(), "{format:?}");
for ext in format.extensions() {
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}");
assert_eq!(FileFormat::from_extension(&format!(".{}", ext.to_ascii_uppercase())), Some(format), "{ext}");
assert_eq!(FileFormat::from_path(format!("dir.x/Take 1.{ext}")), Some(format), "{ext}");
}
}
for &format in FileFormat::WRITE {
assert!(!format.write_extensions().is_empty(), "{format:?}");
for ext in format.write_extensions() {
assert_eq!(FileFormat::from_write_extension(ext), Some(format), "{ext}");
assert_eq!(FileFormat::from_write_extension(&ext.to_ascii_uppercase()), Some(format), "{ext}");
assert_eq!(FileFormat::from_extension(ext), Some(format), "{ext}: what is written reads back");
}
assert!(format.write_extensions().contains(&format.default_extension()), "{format:?}");
}
}
#[test]
fn only_names_whose_readers_expect_the_encoder_output_are_writable() {
assert_eq!(FileFormat::writable_extensions(), ["wav", "wave", "bwf", "aif", "aiff", "flac"]);
for read_only in ["rf64", "aifc", "mp3", "ogg", "m4a", "mkv"] {
assert!(FileFormat::from_extension(read_only).is_some(), "{read_only}");
assert_eq!(FileFormat::from_write_extension(read_only), None, "{read_only}");
}
for unknown in ["xyz", "", "mid"] {
assert_eq!(FileFormat::from_extension(unknown), None, "{unknown}");
}
assert_eq!(FileFormat::from_path("no-extension"), None);
assert_eq!(FileFormat::Aiff.default_extension(), "aif");
}
-101
View File
@@ -1,101 +0,0 @@
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileEncoder, FileFormat, SampleFormat, decode, encode};
use std::io::Cursor;
fn encoded(audio: &AudioBuffer, options: &EncodeOptions, streaming: bool) -> Vec<u8> {
if !streaming {
return encode(audio, options).unwrap();
}
let mut writer = FileEncoder::new(Cursor::new(Vec::new()), options, audio.num_channels(), audio.sample_rate, None).unwrap();
// Cross the writer's 4096-frame scratch boundary, then write a short final block.
for start in (0..audio.frames()).step_by(4097) {
let end = (start + 4097).min(audio.frames());
let block: Vec<Vec<f32>> = audio.channels.iter().map(|channel| channel[start..end].to_vec()).collect();
writer.write(&block, end - start).unwrap();
}
writer.finish().unwrap().into_inner()
}
fn assert_float_headroom(streaming: bool) {
let samples = [
-f32::MAX,
-2.0,
-1.25,
-1.0,
-0.5,
-f32::MIN_POSITIVE,
-f32::from_bits(1),
-0.0,
0.0,
f32::from_bits(1),
f32::MIN_POSITIVE,
0.5,
1.0,
1.25,
2.0,
f32::MAX,
];
for format in [FileFormat::Wav, FileFormat::Aiff] {
for dither in [false, true] {
for channels in [1, 2] {
let audio = AudioBuffer {
sample_rate: 48_000,
channels: (0..channels).map(|c| samples.iter().copied().cycle().skip(c).take(4103).collect()).collect(),
};
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither, bwf: None };
let bytes = encoded(&audio, &options, streaming);
let pcm: Vec<u8> =
audio.interleaved().iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
assert!(bytes.ends_with(&pcm), "{format:?}, dither={dither}: stored sample bytes changed");
let (info, decoded) = decode(&bytes, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Float32);
assert_eq!((decoded.sample_rate, decoded.num_channels(), decoded.frames()), (48_000, channels, 4103));
for (channel, (actual, expected)) in decoded.channels.iter().zip(&audio.channels).enumerate() {
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.to_bits(), expected.to_bits(), "{format:?}, dither={dither}, channel={channel}, frame={frame}");
}
}
}
}
}
}
#[test]
fn whole_file_float32_preserves_finite_headroom() {
assert_float_headroom(false);
}
#[test]
fn streaming_float32_preserves_finite_headroom() {
assert_float_headroom(true);
}
#[test]
fn float32_keeps_non_finite_sample_handling() {
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![f32::NAN, f32::NEG_INFINITY, f32::INFINITY]] };
let expected = [0.0_f32, -1.0, 1.0];
for format in [FileFormat::Wav, FileFormat::Aiff] {
for streaming in [false, true] {
let options = EncodeOptions { format, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = encoded(&audio, &options, streaming);
// Inspect stored samples too: the decoder itself replaces non-finite values.
let pcm: Vec<u8> = expected.iter().flat_map(|v| if format == FileFormat::Wav { v.to_le_bytes() } else { v.to_be_bytes() }).collect();
assert!(bytes.ends_with(&pcm), "{format:?}, streaming={streaming}");
assert_eq!(decode(&bytes, None).unwrap().1.channels[0], expected);
}
}
}
#[test]
fn integer_pcm_still_clips_at_full_scale() {
let audio = AudioBuffer { sample_rate: 48_000, channels: vec![vec![-2.0, -1.25, -1.0, -0.5, 0.0, 0.5, 1.0, 1.25, 2.0]] };
for (depth, max) in [(BitDepth::Int16, 32767.0 / 32768.0), (BitDepth::Int24, 8_388_607.0 / 8_388_608.0), (BitDepth::Int32, 1.0)] {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for streaming in [false, true] {
let options = EncodeOptions { format, bit_depth: depth, dither: false, bwf: None };
let bytes = encoded(&audio, &options, streaming);
let (_, decoded) = decode(&bytes, None).unwrap();
assert_eq!(decoded.channels[0], [-1.0, -1.0, -1.0, -0.5, 0.0, 0.5, max, max, max], "{format:?} {depth:?}, streaming={streaming}");
}
}
}
}
-10
View File
@@ -1,10 +0,0 @@
//! Integration tests, built as one test binary: each file is a module.
mod decode_read_list;
mod file_format;
mod float_headroom;
mod malformed;
mod mp4_length;
mod roundtrip;
mod stream;
mod wav_empty_data;
-810
View File
@@ -1,810 +0,0 @@
//! Hostile inputs must produce `Err` (or a best-effort `Ok`), never a panic.
use proptest::prelude::*;
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, probe};
fn small(format: FileFormat, depth: BitDepth, channels: usize) -> Vec<u8> {
let mut b = AudioBuffer::new(44_100, channels, 300);
for ch in &mut b.channels {
for (i, s) in ch.iter_mut().enumerate() {
*s = ((i as f32) * 0.1).sin() * 0.5;
}
}
encode(&b, &EncodeOptions { format, bit_depth: depth, dither: false, bwf: None }).unwrap()
}
/// An ID3v2.4 header whose syncsafe size field declares `size` bytes of tag body.
fn id3_header(size: usize) -> [u8; 10] {
let size = size as u32;
[b'I', b'D', b'3', 4, 0, 0, ((size >> 21) & 0x7F) as u8, ((size >> 14) & 0x7F) as u8, ((size >> 7) & 0x7F) as u8, (size & 0x7F) as u8]
}
/// Wrap `body` in an ID3v2.4 tag.
fn id3_tag(body: &[u8]) -> Vec<u8> {
[&id3_header(body.len())[..], body].concat()
}
/// Little helper for building RIFF files by hand.
fn riff(chunks: &[(&[u8; 4], Vec<u8>)]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
for (id, data) in chunks {
body.extend_from_slice(*id);
body.extend_from_slice(&(data.len() as u32).to_le_bytes());
body.extend_from_slice(data);
if data.len() % 2 == 1 {
body.push(0);
}
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn fmt(tag: u16, channels: u16, sr: u32, bits: u16) -> Vec<u8> {
let align = channels * bits.div_ceil(8);
let mut f = Vec::new();
f.extend_from_slice(&tag.to_le_bytes());
f.extend_from_slice(&channels.to_le_bytes());
f.extend_from_slice(&sr.to_le_bytes());
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
f.extend_from_slice(&align.to_le_bytes());
f.extend_from_slice(&bits.to_le_bytes());
f
}
#[test]
fn every_truncation_is_handled() {
let files = [
small(FileFormat::Wav, BitDepth::Int16, 2),
small(FileFormat::Wav, BitDepth::Float32, 6),
small(FileFormat::Aiff, BitDepth::Int24, 1),
small(FileFormat::Aiff, BitDepth::Float32, 2),
small(FileFormat::Flac, BitDepth::Int16, 2),
small(FileFormat::Flac, BitDepth::Int24, 1),
];
for file in &files {
for n in 0..file.len() {
let cut = &file[..n];
let _ = probe(cut, None);
if let Ok((info, buf)) = decode(cut, None) {
assert_eq!(buf.num_channels(), info.channels as usize);
assert_eq!(buf.frames() as u64, info.frames);
}
}
}
}
#[test]
fn truncated_data_chunk_yields_partial_audio() {
let file = small(FileFormat::Wav, BitDepth::Int16, 2);
let (info, buf) = decode(&file[..file.len() - 400], None).unwrap();
assert_eq!(info.frames, 200);
assert_eq!(buf.frames(), 200);
}
#[test]
fn every_single_byte_corruption_is_handled() {
for file in [small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Aiff, BitDepth::Int16, 2), small(FileFormat::Flac, BitDepth::Int16, 1)]
{
for i in 0..file.len().min(200) {
for v in [0x00, 0xFF, 0x7F, 0x80] {
let mut f = file.clone();
f[i] = v;
let _ = probe(&f, None);
let _ = decode(&f, None);
}
}
}
}
#[test]
fn zero_channels_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 0, 44_100, 16)), (b"data", vec![0; 16])]);
assert!(matches!(decode(&wav, None), Err(AudioError::Malformed(_))));
assert!(probe(&wav, None).is_err());
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0;
aiff[comm + 9] = 0;
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
assert!(decode(&wav, None).is_err());
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
for b in &mut aiff[comm + 16..comm + 26] {
*b = 0;
}
assert!(decode(&aiff, None).is_err());
// Infinite exponent.
aiff[comm + 16] = 0x7F;
aiff[comm + 17] = 0xFF;
assert!(decode(&aiff, None).is_err());
}
#[test]
fn absurd_chunk_sizes() {
// fmt chunk claims 4 GiB.
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
wav[16..20].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(decode(&wav, None).is_err());
// data chunk claims 4 GiB: lenient read of what's there.
let mut wav = riff(&[(b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
let d = wav.windows(4).position(|w| w == b"data").unwrap();
wav[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!((info.frames, buf.frames()), (4, 4));
// An unknown chunk with an absurd size before fmt.
let mut wav = riff(&[(b"junk", vec![0; 4]), (b"fmt ", fmt(1, 2, 44_100, 16)), (b"data", vec![0; 16])]);
wav[16..20].copy_from_slice(&0xFFFF_FFF0u32.to_le_bytes());
assert!(decode(&wav, None).is_err());
// RF64 with a ds64 claiming an exabyte of data.
let mut ds64 = Vec::new();
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&u64::MAX.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
let mut rf = riff(&[(b"ds64", ds64), (b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80; 10])]);
rf[..4].copy_from_slice(b"RF64");
let d = rf.windows(4).rposition(|w| w == b"data").unwrap();
rf[d + 4..d + 8].copy_from_slice(&u32::MAX.to_le_bytes());
let (info, buf) = decode(&rf, None).unwrap();
assert_eq!((info.frames, buf.frames()), (10, 10));
// AIFF COMM claims more frames than present; SSND offset beyond the end.
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 10..comm + 14].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 300);
let ssnd = aiff.windows(4).position(|w| w == b"SSND").unwrap();
aiff[ssnd + 8..ssnd + 12].copy_from_slice(&u32::MAX.to_be_bytes());
assert_eq!(decode(&aiff, None).unwrap().1.frames(), 0);
}
fn caf(chunks: &[(&[u8; 4], i64, Vec<u8>)]) -> Vec<u8> {
let mut out = b"caff\0\x01\0\0".to_vec();
for (kind, size, body) in chunks {
out.extend_from_slice(*kind);
out.extend_from_slice(&size.to_be_bytes());
out.extend_from_slice(body);
}
out
}
fn caf_desc(format: &[u8; 4], bytes_per_packet: u32, frames_per_packet: u32, channels: u32, bits: u32) -> (&'static [u8; 4], i64, Vec<u8>) {
let mut d = 48_000f64.to_be_bytes().to_vec();
d.extend_from_slice(format);
for v in [0, bytes_per_packet, frames_per_packet, channels, bits] {
d.extend_from_slice(&v.to_be_bytes());
}
(b"desc", 32, d)
}
fn packet_table(packets: i64, frames: i64, entries: &[u8]) -> Vec<u8> {
[&packets.to_be_bytes()[..], &frames.to_be_bytes(), &[0; 8], entries].concat()
}
#[test]
fn caf_sizes_beyond_the_file_are_errors_not_aborts() {
let aac = || caf_desc(b"aac ", 0, 1024, 2, 0);
let huge = 1i64 << 50;
let cookie = caf(&[aac(), (b"kuki", huge, vec![0; 8])]);
let files = [
cookie.clone(),
[vec![0; 100], cookie].concat(),
caf(&[aac(), (b"data", -1, [&[0; 4][..], b"kuki", &huge.to_be_bytes()].concat())]),
caf(&[aac(), (b"pakt", 24, packet_table(huge, 1024, &[])), (b"data", -1, vec![0; 16])]),
caf(&[aac(), (b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])), (b"data", -1, vec![0; 64])]),
caf(&[aac(), (b"pakt", 25, packet_table(1, 1024, &[0x80]))]),
];
for f in files {
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
let constant = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"pakt", 25, packet_table(1, 1, &[1])), (b"data", -1, vec![0; 16])]);
assert!(matches!(decode(&constant, None), Err(AudioError::Malformed(m)) if m.contains("lists packets")));
}
/// An EBML element: `id`, then `size` as an 8-byte vint (so any size up to 2^56 − 2 fits, and
/// `u64::MAX` writes the reserved "unknown size"), then `body`.
fn ebml(id: &[u8], size: u64, body: &[u8]) -> Vec<u8> {
let size = if size == u64::MAX { 0x01FF_FFFF_FFFF_FFFF } else { 0x0100_0000_0000_0000 | size };
[id, &size.to_be_bytes(), body].concat()
}
/// The EBML header of a Matroska file.
fn ebml_header() -> Vec<u8> {
let doc_type = ebml(&[0x42, 0x82], 8, b"matroska");
ebml(&[0x1A, 0x45, 0xDF, 0xA3], doc_type.len() as u64, &doc_type)
}
/// #878: symphonia's Matroska reader takes element sizes from the file. With symphonia 0.5 a size
/// far beyond the file was allocated up front and aborted the process (no `catch_unwind` can catch
/// an allocation failure); since 0.6 such reads grow with the data actually read. Keep it that way
/// for the header, for codec data and for block frames.
#[test]
fn matroska_sizes_beyond_the_file_are_errors_not_aborts() {
let huge = 1u64 << 47;
// The 30-byte file from the report: EBML header and DocType sizes of 2^48 − 1 and 2^47.
let reported = [
0x1A, 0x45, 0xDF, 0xA3, 0x01, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x42, 0x82, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x6D,
0x61, 0x74, 0x72, 0x6F, 0x73, 0x6B, 0x61,
];
let segment = |body: Vec<u8>| [ebml_header(), ebml(&[0x18, 0x53, 0x80, 0x67], u64::MAX, &body)].concat();
let info = [ebml(&[0x2A, 0xD7, 0xB1], 3, &[0x0F, 0x42, 0x40]), ebml(&[0x4D, 0x80], 4, b"test"), ebml(&[0x57, 0x41], 4, b"test")].concat();
let info = ebml(&[0x15, 0x49, 0xA9, 0x66], info.len() as u64, &info);
// A valid 16-bit PCM track entry's children, followed by `extra`.
let pcm = |extra: &[u8]| {
let audio = [ebml(&[0xB5], 8, &48_000f64.to_be_bytes()), ebml(&[0x9F], 1, &[1]), ebml(&[0x62, 0x64], 1, &[16])].concat();
[
ebml(&[0xD7], 1, &[1]),
ebml(&[0x73, 0xC5], 1, &[1]),
ebml(&[0x83], 1, &[2]),
ebml(&[0x86], 13, b"A_PCM/INT/LIT"),
ebml(&[0xE1], audio.len() as u64, &audio),
extra.to_vec(),
]
.concat()
};
// That track with a CodecPrivate claiming 2^46 bytes, inside parents that claim even more: the
// segment is valid up to that read, which runs out of file.
let track = ebml(&[0xAE], huge, &pcm(&ebml(&[0x63, 0xA2], huge >> 1, &[0; 16])));
let codec_data = segment([info.clone(), ebml(&[0x16, 0x54, 0xAE, 0x6B], huge, &track)].concat());
// The plain track, then a cluster whose SimpleBlock claims 2^47 bytes of frame data.
let track = pcm(&[]);
let track = ebml(&[0xAE], track.len() as u64, &track);
let block = ebml(&[0x1F, 0x43, 0xB6, 0x75], u64::MAX, &[ebml(&[0xE7], 1, &[0]), ebml(&[0xA3], huge, &[0x81, 0, 0, 0x80])].concat());
let cluster = segment([info, ebml(&[0x16, 0x54, 0xAE, 0x6B], track.len() as u64, &track), block].concat());
let out_of_file = |r: Result<_, AudioError>| matches!(r, Err(AudioError::Malformed(m)) if m.contains("end of file"));
for hint in [None, Some("mkv"), Some("wav")] {
assert!(probe(&reported, hint).is_err(), "{hint:?}");
assert!(decode(&reported, hint).is_err(), "{hint:?}");
// Refused at the oversized read itself, not by anything before it.
assert!(out_of_file(probe(&codec_data, hint).map(|_| ())), "{hint:?}: {:?}", probe(&codec_data, hint).err());
assert!(out_of_file(decode(&codec_data, hint).map(|_| ())), "{hint:?}");
// The file and its track open, so decoding reaches the block; the block reader stops at the
// end of the file, a best-effort read of what is there.
assert!(probe(&cluster, hint).is_ok(), "{hint:?}: {:?}", probe(&cluster, hint).err());
assert!(decode(&cluster, hint).map_or(0, |(_, b)| b.frames()) < 1_000, "{hint:?}");
}
}
#[test]
fn well_formed_caf_still_reads() {
let pcm: Vec<u8> = (0..480i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
let chan = [((100u32 << 16) | 1).to_be_bytes(), [0; 4], [0; 4]].concat();
let data = [vec![0; 4], pcm].concat();
let lpcm = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"chan", 12, chan), (b"free", 8, vec![0; 8]), (b"data", data.len() as i64, data)]);
let (info, buf) = decode(&lpcm, None).unwrap();
assert_eq!((info.format, buf.frames()), (FileFormat::Caf, 480));
let table = packet_table(3, 3072, &[10, 0x81, 0x48, 5]);
let aac = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"pakt", table.len() as i64, table), (b"data", 219, vec![0; 219])]);
assert_eq!(probe(&aac, None).unwrap().frames, 3072);
}
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A mono 16-bit, 48 kHz audio sample entry of `version`, its version fields and codec atoms.
fn sample_entry(kind: &[u8; 4], version: u8, fields: &[u8], codec: &[u8]) -> Vec<u8> {
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, version, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
atom(kind, &[&base[..], &(48_000u32 << 16).to_be_bytes(), fields, codec].concat())
}
/// An MP4 of `frames` 2-byte samples described by `entry`; `swap` may replace any version-0 atom.
fn mp4(frames: u32, entry: &[u8], swap: impl Fn(&[u8; 4], Vec<u8>) -> Vec<u8>) -> Vec<u8> {
let full = |kind: &[u8; 4], fields: &[u32]| {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
swap(kind, atom(kind, &body))
};
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], entry].concat());
let stbl =
[stsd, full(b"stts", &[1, frames, 1]), full(b"stsc", &[1, 1, frames, 1]), full(b"stsz", &[2, frames]), full(b"stco", &[1, offset])];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, frames, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, frames, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
let pcm: Vec<u8> = (0..frames as i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
[ftyp, moov(offset), atom(b"mdat", &pcm)].concat()
}
#[test]
fn mp4_sizes_beyond_the_file_are_errors_not_aborts() {
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let (_, buf) = decode(&mp4(480, &pcm, |_, a| a), None).unwrap();
assert_eq!(buf.frames(), 480);
let huge = |kind: &[u8; 4], a: Vec<u8>| [&1u32.to_be_bytes()[..], kind, &(1u64 << 60).to_be_bytes(), &a[8..]].concat();
// The first field after version and flags: stts's entry count, stsz's sample size.
let first_field_max = |mut a: Vec<u8>| {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
a
};
let sizes: Vec<u8> = (0..480u32).flat_map(|i| if i == 479 { u32::MAX } else { 2 }.to_be_bytes()).collect();
let per_sample = atom(b"stsz", &[&[0; 8][..], &480u32.to_be_bytes(), &sizes].concat());
// A header that starts inside its container is read whole, past the container's end.
let mdia = atom(b"mdia", &1u32.to_be_bytes());
let straddling = [&b"hdlr"[..], &(1u64 << 60).to_be_bytes(), &[0; 24]].concat();
let files = [
mp4(480, &pcm, |k, a| if k == b"hdlr" { huge(k, a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stts" { first_field_max(a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stsz" { first_field_max(a) } else { a }),
mp4(480, &pcm, |k, a| if k == b"stsz" { per_sample.clone() } else { a }),
[atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), atom(b"moov", &atom(b"trak", &mdia)), straddling].concat(),
];
for f in files {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
}
#[test]
fn atoms_running_past_their_parent_are_walked_once() {
// Each moov holds a trak that runs to the end of the file; symphonia stops after the first.
let unit = atom(b"moov", &[&u32::MAX.to_be_bytes()[..], b"trak"].concat());
let f = [atom(b"ftyp", b"M4A \0\0\0\0M4A isom"), unit.repeat(1 << 18)].concat();
assert!(decode(&f, None).is_err());
}
#[test]
fn packets_longer_than_any_codec_uses_are_refused() {
let be = |v: &[u32]| v.iter().flat_map(|x| x.to_be_bytes()).collect::<Vec<u8>>();
let v2 = |frames: u32| [&be(&[72])[..], &48_000f64.to_be_bytes(), &be(&[1, 0x7f00_0000, 16, 4, 2, frames])].concat();
let lpcm = |frames: u32| sample_entry(b"lpcm", 2, &v2(frames), &[]);
let config = |frames: u32| [&be(&[0, frames])[..], &[0, 16, 40, 10, 14, 1, 0, 255], &be(&[0, 0, 48_000])].concat();
let alac = |frames: u32| sample_entry(b"alac", 0, &[], &atom(b"alac", &config(frames)));
let (_, buf) = decode(&mp4(480, &lpcm(1), |_, a| a), None).unwrap();
assert_eq!(buf.frames(), 480);
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")));
}
}
#[test]
fn flac_metadata_fields_beyond_their_block_are_refused() {
let with_block = |kind: u8, body: &[u8]| {
let f = small(FileFormat::Flac, BitDepth::Int16, 1);
let head = ((0x80 | u32::from(kind)) << 24 | body.len() as u32).to_be_bytes();
[&f[..4], &[f[4] & 0x7f], &f[5..42], &head, body, &f[42..]].concat()
};
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
}
}
#[test]
fn an_id3_tag_before_the_audio_is_skipped_unread() {
let frame = [&b"TIT2"[..], &0x7fff_ffffu32.to_be_bytes(), &[0, 0, 3, b'h', b'i', 0]].concat();
let tag = [&b"ID3\x03\0\0\0\0\0"[..], &[frame.len() as u8], &frame].concat();
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
assert_eq!(decode(&[tag, flac].concat(), None).unwrap().1.frames(), 300);
}
/// Container magic inside an MP3 frame payload must not change detection or make probing reject the stream.
#[test]
fn a_valid_mp3_with_incidental_container_magic_still_probes() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
let mut f: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
f[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
f[at + 8..at + 12].copy_from_slice(b"kuki");
f[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
let info = probe(&f, None).expect("valid MP3 with incidental container magic");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
}
/// Symphonia 0.6's CAF/FLAC/MP4 `Scoreable` implementations are unconditional
/// (`Supported(255)`), so a container magic before the first audio frame *is* the reader the probe
/// selects; there is no API to ask the probe to skip it. The preflight must therefore keep
/// refusing an oversized CAF cookie instead of ignoring the marker and playing the MPEG frames
/// behind it (which would also let a real malformed container bypass the size guard).
#[test]
fn a_container_marker_before_the_first_audio_frame_is_the_probed_candidate() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]); // MPEG-1 Layer III, 128 kbps, 44.1 kHz, stereo
let mp3: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
for f in [[vec![0u8], malformed.clone(), mp3.clone()].concat(), [vec![0u8; 32], malformed.clone(), mp3.clone()].concat()] {
// The hint still names the raw stream, but the probe sees the CAF marker first.
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
assert!(matches!(probe(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, Some("mp3")), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
// Plain leading junk without format magic is tolerated, as before.
let f = [vec![0u8; 32], mp3].concat();
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
let info = probe(&f, Some("mp3")).expect("junk before a valid MP3");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
}
/// More false `0xFFEx` pairs than the preflight's scoring budget must not make a later container
/// magic inside the valid audio payload authoritative. Past the cap the scan is unchecked and the
/// probe selects the MPEG reader at the first real frame.
#[test]
fn an_exhausted_audio_score_budget_does_not_reject_the_mp3_behind_it() {
let mut frame = vec![0u8; 417];
frame[..4].copy_from_slice(&[0xFF, 0xFB, 0x90, 0x00]);
let mut audio: Vec<u8> = (0..8).flat_map(|_| frame.clone()).collect();
let at = 417 * 2 + 10; // Inside frame 2's payload, leaving every frame header intact.
audio[at..at + 8].copy_from_slice(b"caff\0\x01\0\0");
audio[at + 8..at + 12].copy_from_slice(b"kuki");
audio[at + 12..at + 20].copy_from_slice(&(1u64 << 50).to_be_bytes());
let false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
let f = [false_syncs, audio].concat();
assert_eq!(detect_format(&f, Some("mp3")), FileFormat::Mp3);
let info = probe(&f, Some("mp3")).expect("valid MP3 after an exhausted audio score budget");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Mp3, 44_100, 2));
let (decoded, buf) = decode(&f, Some("mp3")).expect("decode the same MP3");
assert_eq!((decoded.format, decoded.sample_rate, decoded.channels), (FileFormat::Mp3, 44_100, 2));
assert_eq!(decoded.frames, buf.frames() as u64);
}
/// Past the score budget the preflight does not run, so if the probe then selects a container
/// whose reader allocates from sizes in the file, the post-probe refusal still applies.
#[test]
fn a_container_selected_without_its_size_check_is_refused() {
let flac = small(FileFormat::Flac, BitDepth::Int16, 1);
// Exactly the cap of false candidates, then one more that the reader rejects (invalid
// bit-rate), so the preflight stops unchecked before reaching the FLAC marker behind it.
let mut false_syncs: Vec<u8> = (0..256).flat_map(|_| [0xFFu8, 0xE2]).collect();
false_syncs.extend_from_slice(&[0xFF, 0xFB, 0xF0, 0x00]);
let f = [false_syncs, flac].concat();
assert_eq!(detect_format(&f, None), FileFormat::Mp3);
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("was not size-checked")));
}
/// A zero-frame FLAC after arbitrary bytes or ID3 tags is still detected and reported as an empty stream, not a
/// decode failure.
#[test]
fn zero_frame_flac_after_a_prefix_is_still_empty() {
let flac =
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
.unwrap();
let tag = id3_tag(b"tag");
for (f, detected) in [
([vec![0x7A; 64], flac.clone()].concat(), FileFormat::Other),
([tag.clone(), vec![0x7A; 16], flac.clone()].concat(), FileFormat::Mp3),
([tag.clone(), tag, flac.clone()].concat(), FileFormat::Mp3),
] {
assert_eq!(detect_format(&f, None), detected);
let info = probe(&f, None).expect("empty FLAC after a prefix");
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
}
}
/// Build one MPEG-4 AAC-LC ADTS frame at 48 kHz stereo around `payload`, with the 13-bit length covering the 7-byte
/// header.
fn adts_frame(payload: &[u8]) -> Vec<u8> {
// MPEG-4 AAC-LC, 48 kHz, stereo; the 13-bit frame length includes this 7-byte header.
let len = 7 + payload.len();
let mut h = [0xFF, 0xF1, 0x4C, 0x00, 0x00, 0x1F, 0xFC];
h[3] = 0x80 | ((len >> 11) & 0x03) as u8; // stereo channel config low bits
h[4] = ((len >> 3) & 0xFF) as u8;
h[5] = (((len & 0x07) as u8) << 5) | 0x1F;
[&h[..], payload].concat()
}
/// False MPEG sync prefixes must not stop a following zero-frame FLAC from probing as empty.
#[test]
fn zero_frame_flac_after_a_false_mpeg_sync_is_still_empty() {
let flac =
encode(&AudioBuffer::new(48_000, 1, 0), &EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, dither: false, bwf: None })
.unwrap();
for junk in [vec![0xFF, 0xE0, 0x00], vec![0xFF, 0xF0, 0x00], vec![0xFF, 0xFB, 0xF0, 0x00]] {
let f = [junk, flac.clone()].concat();
let info = probe(&f, None).expect("empty FLAC after a false sync");
assert_eq!((info.format, info.sample_rate, info.channels, info.frames), (FileFormat::Flac, 48_000, 1, 0));
assert_eq!(decode(&f, None).unwrap().1.frames(), 0);
}
}
/// A false MPEG sync prefix must not let malformed CAF or MP4 size fields bypass the container preflight.
#[test]
fn a_false_sync_does_not_bypass_container_checks() {
let bad_caf = caf(&[
caf_desc(b"aac ", 0, 1024, 2, 0),
(b"pakt", 32, packet_table(1, 1024, &[0x82, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0])),
(b"data", -1, vec![0; 64]),
]);
assert!(matches!(probe(&bad_caf, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
let f = [vec![0xFF, 0xE0, 0x00], bad_caf].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
let pcm = sample_entry(b"sowt", 0, &[], &[]);
let bad_mp4 = mp4(480, &pcm, |kind, mut a| {
if kind == b"stsz" {
a[12..16].copy_from_slice(&u32::MAX.to_be_bytes());
}
a
});
assert!(probe(&bad_mp4, None).is_err());
let f = [vec![0xFF, 0xE0, 0x00], bad_mp4].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
/// Container magic inside a valid ADTS payload must not override AAC detection or make probing fail.
#[test]
fn a_valid_adts_stream_with_incidental_container_magic_still_probes() {
let payload = [0u8; 400];
let mut frames: Vec<Vec<u8>> = (0..8).map(|_| adts_frame(&payload)).collect();
let at = 100; // Inside frame 2's payload, leaving every frame header intact.
frames[2][7 + at..7 + at + 12].copy_from_slice(b"caff\0\x01\0\0kuki");
let f: Vec<u8> = frames.concat();
assert_eq!(detect_format(&f, None), FileFormat::Aac);
let info = probe(&f, None).expect("valid ADTS with incidental container magic");
assert_eq!((info.format, info.sample_rate, info.channels), (FileFormat::Aac, 48_000, 2));
}
/// A later valid CAF must not excuse an earlier malformed container that the preflight or probe rejects.
#[test]
fn an_earlier_malformed_container_is_not_excused_by_a_later_valid_one() {
let malformed = caf(&[caf_desc(b"aac ", 0, 1024, 2, 0), (b"kuki", 1i64 << 50, vec![0; 8])]);
let pcm: Vec<u8> = (0..32i16).flat_map(|i| (i * 50).to_be_bytes()).collect();
let data = [vec![0; 4], pcm].concat();
let valid = caf(&[caf_desc(b"lpcm", 2, 1, 1, 16), (b"data", data.len() as i64, data)]);
let f = [vec![0u8; 32], malformed, valid].concat();
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
/// Truncated ID3 bodies, declared tag sizes beyond the file, and a complete tag with no audio after it are malformed
/// for both probe and decode.
#[test]
fn truncated_or_oversized_id3_tags_are_malformed() {
let cases = vec![[&id3_header(100)[..], b"xx"].concat(), id3_header(0x0FFF_FFFF).to_vec(), id3_tag(b"tag")];
for f in cases {
assert!(matches!(probe(&f, None), Err(AudioError::Malformed(_))));
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(_))));
}
}
#[test]
fn wav_info_entries_beyond_the_file_are_refused() {
let alaw = |len: u32| {
let info = [&b"INFO"[..], b"INAM", &len.to_le_bytes(), b"hi"].concat();
riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])])
};
assert_eq!(decode(&alaw(2), None).unwrap().1.frames(), 800);
let mut f = alaw(0x7fff_fff0);
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = f.windows(4).position(|w| w == b"LIST").unwrap();
f[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn a_wave_behind_another_riff_form_is_checked() {
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
// The probe skips the AVI form and reads the WAVE behind it.
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let alaw = |extra: u16| (b"fmt ", [fmt(6, 1, 8_000, 8), extra.to_le_bytes().to_vec()].concat());
// A-law's extra bytes are read past its chunk; an odd list is padded twice.
let files =
[riff(&[alaw(12), (b"JUNK", [&[0; 4][..], &list].concat())]), [riff(&[alaw(0), (b"LIST", b"adtl\0".to_vec())]), vec![0], list].concat()];
for mut f in files {
f[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
}
#[test]
fn many_tiny_chunks_terminate() {
let mut chunks: Vec<(&[u8; 4], Vec<u8>)> = vec![(b"fmt ", fmt(1, 1, 8_000, 16))];
for _ in 0..200_000 {
chunks.push((b"pad ", Vec::new()));
}
let wav = riff(&chunks);
assert!(decode(&wav, None).is_err()); // no data chunk within the scan limit
}
#[test]
fn hand_built_wav_variants() {
// 8-bit unsigned.
let wav = riff(&[(b"fmt ", fmt(1, 1, 8_000, 8)), (b"data", vec![0x80, 0xFF, 0x00])]);
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Int8);
assert_eq!(buf.channels[0], vec![0.0, 127.0 / 128.0, -1.0]);
// 64-bit float, with NaN replaced by silence.
let mut data = Vec::new();
for v in [0.25f64, -0.5, f64::NAN] {
data.extend_from_slice(&v.to_le_bytes());
}
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 64)), (b"data", data)]);
let (info, buf) = decode(&wav, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Float64);
assert_eq!(buf.channels[0], vec![0.25, -0.5, 0.0]);
// Unsupported PCM width.
let wav = riff(&[(b"fmt ", fmt(3, 1, 8_000, 16)), (b"data", vec![0; 4])]);
assert!(matches!(decode(&wav, None), Err(AudioError::Unsupported(_))));
// No data chunk / no fmt chunk.
assert!(decode(&riff(&[(b"fmt ", fmt(1, 1, 8_000, 16))]), None).is_err());
assert!(decode(&riff(&[(b"data", vec![0; 4])]), None).is_err());
// Short fmt chunk.
assert!(decode(&riff(&[(b"fmt ", vec![1, 0, 1]), (b"data", vec![0; 4])]), None).is_err());
// Unknown codec tag goes to symphonia and fails cleanly.
let wav = riff(&[(b"fmt ", fmt(0x1234, 1, 8_000, 16)), (b"data", vec![0; 4])]);
assert!(decode(&wav, None).is_err());
}
#[test]
fn aiff_sowt_little_endian() {
let be = small(FileFormat::Aiff, BitDepth::Int16, 2);
let (_, expect) = decode(&be, None).unwrap();
// Rebuild as AIFC/sowt by hand.
let comm = be.windows(4).position(|w| w == b"COMM").unwrap();
let ssnd = be.windows(4).position(|w| w == b"SSND").unwrap();
let mut comm_body = be[comm + 8..comm + 26].to_vec();
comm_body.extend_from_slice(b"sowt");
comm_body.extend_from_slice(&[0, 0]); // empty pstring, padded
let mut ssnd_body = be[ssnd + 8..].to_vec();
for pair in ssnd_body[8..].as_chunks_mut::<2>().0 {
pair.swap(0, 1);
}
let mut body = b"AIFC".to_vec();
for (id, data) in [(b"COMM", &comm_body), (b"SSND", &ssnd_body)] {
body.extend_from_slice(id);
body.extend_from_slice(&(data.len() as u32).to_be_bytes());
body.extend_from_slice(data);
}
let mut f = b"FORM".to_vec();
f.extend_from_slice(&(body.len() as u32).to_be_bytes());
f.extend_from_slice(&body);
let (info, got) = decode(&f, None).unwrap();
assert_eq!(info.sample_format, SampleFormat::Int16);
assert_eq!(got, expect);
}
#[test]
fn detect_format_by_magic_and_hint() {
assert_eq!(detect_format(&small(FileFormat::Wav, BitDepth::Int16, 1), Some("mp3")), FileFormat::Wav);
assert_eq!(detect_format(&small(FileFormat::Aiff, BitDepth::Int16, 1), None), FileFormat::Aiff);
assert_eq!(detect_format(&small(FileFormat::Flac, BitDepth::Int16, 1), None), FileFormat::Flac);
assert_eq!(detect_format(b"OggS\0\x02rest", None), FileFormat::Ogg);
assert_eq!(detect_format(b"caff\0\x01\0\0", None), FileFormat::Caf);
assert_eq!(detect_format(b"ID3\x04\0\0\0\0\0\x00", None), FileFormat::Mp3);
let mut id3_flac = b"ID3\x04\0\0\0\0\0\x02xx".to_vec();
id3_flac.extend_from_slice(b"fLaC");
assert_eq!(detect_format(&id3_flac, None), FileFormat::Flac);
assert_eq!(detect_format(&[0xFF, 0xFB, 0x90, 0x00], None), FileFormat::Mp3);
assert_eq!(detect_format(&[0xFF, 0xF1, 0x50, 0x80], None), FileFormat::Aac);
let mut m4a = b"\0\0\0\x20ftypM4A ".to_vec();
m4a.extend_from_slice(b"....stsd\0\0\0\0\0\0\0\x01\0\0\0\x24alac");
assert_eq!(detect_format(&m4a, None), FileFormat::Alac);
assert_eq!(detect_format(b"\0\0\0\x20ftypM4A ", None), FileFormat::Aac);
assert_eq!(detect_format(b"", Some(".WAV")), FileFormat::Wav);
assert_eq!(detect_format(b"zz", Some("aif")), FileFormat::Aiff);
assert_eq!(detect_format(b"zz", Some("m4a")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("M4B")), FileFormat::Aac);
assert_eq!(detect_format(b"zz", Some("flac")), FileFormat::Flac);
assert_eq!(detect_format(b"zz", Some("xyz")), FileFormat::Other);
assert_eq!(detect_format(b"zz", None), FileFormat::Other);
assert_eq!(detect_format(b"", None), FileFormat::Other);
}
#[test]
fn garbage_in_compressed_containers_errors() {
let garbage: Vec<u8> = (0..4096u32).map(|i| (i.wrapping_mul(2_654_435_761) >> 13) as u8).collect();
for hint in [None, Some("mp3"), Some("ogg"), Some("flac"), Some("m4a"), Some("caf"), Some("aac"), Some("mkv"), Some("wav"), Some("aiff")] {
assert!(decode(&garbage, hint).is_err(), "{hint:?}");
assert!(probe(&garbage, hint).is_err(), "{hint:?}");
}
for magic in [&b"fLaC"[..], b"OggS", b"caff", b"ID3\x03\0\0\0\0\0\0", &[0xFF, 0xFB, 0x90, 0x00], b"\0\0\0\x20ftypM4A "] {
let mut f = magic.to_vec();
f.extend_from_slice(&garbage);
let _ = probe(&f, None);
assert!(decode(&f, None).map(|(_, b)| b.frames()).unwrap_or(0) < 1_000_000);
}
assert!(decode(&[], None).is_err());
}
/// A marker that passes its container check must not let the probe select a different container
/// without its own checks: the reader is refused when the probed format disagrees.
#[test]
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
// probe reads the WAVE before it.
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
match decode(&bytes, Some("flac")) {
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
Err(other) => panic!("unexpected error kind: {other:?}"),
Ok(_) => panic!("the mismatched container must be refused"),
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
#[test]
fn random_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..2048), hint in 0usize..6) {
let hint = [None, Some("wav"), Some("aiff"), Some("flac"), Some("mp3"), Some("ogg")][hint];
let _ = detect_format(&bytes, hint);
let _ = probe(&bytes, hint);
let _ = decode(&bytes, hint);
}
#[test]
fn random_bodies_behind_valid_headers_never_panic(body in proptest::collection::vec(any::<u8>(), 0..1024), which in 0usize..4) {
let mut f = match which {
0 => b"RIFF\xff\xff\xff\xffWAVE".to_vec(),
1 => b"RF64\xff\xff\xff\xffWAVE".to_vec(),
2 => b"FORM\xff\xff\xff\xffAIFC".to_vec(),
_ => b"FORM\xff\xff\xff\xffAIFF".to_vec(),
};
f.extend_from_slice(&body);
let _ = probe(&f, None);
let _ = decode(&f, None);
}
#[test]
fn random_audio_roundtrips_through_flac(samples in proptest::collection::vec(-1.2f32..1.2, 1..3000), channels in 1usize..4) {
let buf = AudioBuffer::from_interleaved(48_000, channels, &samples);
prop_assume!(buf.frames() > 0);
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int24, dither: false, bwf: None };
let (_, flac) = decode(&encode(&buf, &opts).unwrap(), None).unwrap();
let (_, wav) = decode(&encode(&buf, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap(), None).unwrap();
prop_assert_eq!(flac, wav);
}
}
/// An extensible `fmt ` chunk: cbSize, valid bits, mask, then the 16-byte subtype GUID.
fn extensible_fmt(cb_size: u16, valid_bits: u16, guid: &[u8]) -> Vec<u8> {
let mut f = fmt(0xFFFE, 1, 44_100, 16);
f.extend_from_slice(&cb_size.to_le_bytes());
f.extend_from_slice(&valid_bits.to_le_bytes());
f.extend_from_slice(&4u32.to_le_bytes());
f.extend_from_slice(guid);
f
}
const PCM_GUID: [u8; 16] = [1, 0, 0, 0, 0, 0, 0x10, 0, 0x80, 0, 0, 0xAA, 0, 0x38, 0x9B, 0x71];
#[test]
fn extensible_fmt_is_checked_in_full() {
let data = vec![0u8; 8];
let good = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID)), (b"data", data.clone())]);
assert_eq!(probe(&good, None).unwrap().sample_format, SampleFormat::Int16);
// Subtype GUID cut short after the format code.
let cut = riff(&[(b"fmt ", extensible_fmt(22, 16, &PCM_GUID[..4])), (b"data", data.clone())]);
assert!(matches!(probe(&cut, None), Err(AudioError::Malformed(_))));
assert!(matches!(decode(&cut, None), Err(AudioError::Malformed(_))));
// cbSize too small for the extension.
let small_cb = riff(&[(b"fmt ", extensible_fmt(0, 16, &PCM_GUID)), (b"data", data.clone())]);
assert!(matches!(probe(&small_cb, None), Err(AudioError::Malformed(_))));
// More valid bits than the 16-bit container holds.
let wide = riff(&[(b"fmt ", extensible_fmt(22, 24, &PCM_GUID)), (b"data", data.clone())]);
assert!(matches!(probe(&wide, None), Err(AudioError::Malformed(_))));
// PCM-like prefix but a foreign GUID tail is not read as native PCM.
let mut foreign = PCM_GUID;
foreign[15] ^= 0xFF;
let foreign = riff(&[(b"fmt ", extensible_fmt(22, 16, &foreign)), (b"data", data)]);
assert!(!matches!(probe(&foreign, None), Ok(i) if i.sample_format == SampleFormat::Int16));
// The Ambisonic PCM subtype is PCM.
let ambi = [1, 0, 0, 0, 0x21, 0x07, 0xD3, 0x11, 0x86, 0x44, 0xC8, 0xC1, 0xCA, 0, 0, 0];
let ambi = riff(&[(b"fmt ", extensible_fmt(22, 16, &ambi)), (b"data", vec![0u8; 8])]);
assert_eq!(probe(&ambi, None).unwrap().sample_format, SampleFormat::Int16);
}
-61
View File
@@ -1,61 +0,0 @@
//! The length `probe` reports for AAC in MP4 is the length `decode` produces (issue #1113).
use soundcraft_audio_io::{decode, probe};
fn atom(kind: &[u8; 4], body: &[u8]) -> Vec<u8> {
[&(8 + body.len() as u32).to_be_bytes()[..], kind, body].concat()
}
/// A full atom (version 0, no flags) whose body is `fields` as big-endian u32s.
fn full(kind: &[u8; 4], fields: &[u32]) -> Vec<u8> {
let body: Vec<u8> = [0].iter().chain(fields).flat_map(|v: &u32| v.to_be_bytes()).collect();
atom(kind, &body)
}
/// An MPEG-4 descriptor with a one-byte length.
fn descriptor(tag: u8, body: &[u8]) -> Vec<u8> {
[&[tag, body.len() as u8][..], body].concat()
}
/// A silent AAC-LC raw frame for one channel: an SCE with `max_sfb` 0, then ID_END.
const SILENT_SCE: [u8; 4] = [0x00, 0xC8, 0x00, 0x07];
/// A mono 48 kHz AAC-LC MP4 without an edit list: `packets` frames, the last lasting `last_dur` in stts.
fn aac_mp4(packets: u32, last_dur: u32) -> Vec<u8> {
// AudioSpecificConfig: AAC-LC, 48 kHz, one channel.
let dsi = descriptor(5, &[0x11, 0x88]);
let dcd = descriptor(4, &[&[0x40, 0x15, 0, 0, 0][..], &[0; 8], &dsi].concat());
let es = descriptor(3, &[&[0, 1, 0][..], &dcd, &descriptor(6, &[2])].concat());
let esds = atom(b"esds", &[&[0; 4][..], &es].concat());
let base = [0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 16, 0, 0, 0, 0];
let entry = atom(b"mp4a", &[&base[..], &(48_000u32 << 16).to_be_bytes(), &esds].concat());
let total = (packets - 1) * 1024 + last_dur;
let moov = |offset: u32| {
let stsd = atom(b"stsd", &[&[0, 0, 0, 0, 0, 0, 0, 1][..], &entry].concat());
let stbl = [
stsd,
full(b"stts", &[2, packets - 1, 1024, 1, last_dur]),
full(b"stsc", &[1, 1, packets, 1]),
full(b"stsz", &[SILENT_SCE.len() as u32, packets]),
full(b"stco", &[1, offset]),
];
let minf = [full(b"smhd", &[0]), atom(b"stbl", &stbl.concat())].concat();
let mdia = [full(b"mdhd", &[0, 0, 48_000, total, 0]), full(b"hdlr", &[0, u32::from_be_bytes(*b"soun"), 0, 0, 0, 0]), atom(b"minf", &minf)];
let trak = [full(b"tkhd", &[0; 20]), atom(b"mdia", &mdia.concat())].concat();
atom(b"moov", &[full(b"mvhd", &[[0, 0, 48_000, total, 1 << 16].as_slice(), &[0; 18], &[2]].concat()), atom(b"trak", &trak)].concat())
};
let ftyp = atom(b"ftyp", b"M4A \0\0\0\0M4A isom");
let offset = (ftyp.len() + moov(0).len() + 8) as u32;
[ftyp, moov(offset), atom(b"mdat", &SILENT_SCE.repeat(packets as usize))].concat()
}
#[test]
fn aac_in_mp4_probe_length_matches_the_decoded_length() {
// A last sample lasting until the track end (longer than a packet), and a control with a short one.
for last_dur in [1145, 546] {
let file = aac_mp4(10, last_dur);
let (_, buf) = decode(&file, Some("m4a")).unwrap();
assert_eq!(buf.frames(), 10 * 1024, "decoded frames, last stts duration {last_dur}");
assert_eq!(probe(&file, Some("m4a")).unwrap().frames, buf.frames() as u64, "probed frames, last stts duration {last_dur}");
}
}
-328
View File
@@ -1,328 +0,0 @@
use soundcraft_audio_io::{
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
};
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
let mut b = AudioBuffer::new(sr, channels, frames);
for (c, ch) in b.channels.iter_mut().enumerate() {
let f = 220.0 * (c as f32 + 1.0);
for (i, s) in ch.iter_mut().enumerate() {
*s = 0.8 * (2.0 * std::f32::consts::PI * f * i as f32 / sr as f32 + c as f32).sin();
}
}
b
}
fn tolerance(depth: BitDepth, dither: bool) -> f32 {
let lsb = match depth {
BitDepth::Int16 => 1.0 / 32768.0,
BitDepth::Int24 => 1.0 / 8_388_608.0,
BitDepth::Int32 => 1e-7,
BitDepth::Float32 => 0.0,
};
lsb * if dither { 1.6 } else { 0.6 } + 1e-7
}
fn max_err(a: &AudioBuffer, b: &AudioBuffer) -> f32 {
assert_eq!(a.num_channels(), b.num_channels());
assert_eq!(a.frames(), b.frames());
a.channels.iter().zip(&b.channels).flat_map(|(x, y)| x.iter().zip(y).map(|(p, q)| (p - q).abs())).fold(0.0, f32::max)
}
const DEPTHS: [BitDepth; 4] = [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32, BitDepth::Float32];
fn expected_sf(d: BitDepth) -> SampleFormat {
match d {
BitDepth::Int16 => SampleFormat::Int16,
BitDepth::Int24 => SampleFormat::Int24,
BitDepth::Int32 => SampleFormat::Int32,
BitDepth::Float32 => SampleFormat::Float32,
}
}
#[test]
fn wav_and_aiff_roundtrip_all_depths_and_layouts() {
for format in [FileFormat::Wav, FileFormat::Aiff] {
for depth in DEPTHS {
for channels in [1, 2, 6] {
for dither in [false, true] {
let src = sine(48_000, channels, 4801);
let opts = EncodeOptions { format, bit_depth: depth, dither, bwf: None };
let bytes = encode(&src, &opts).unwrap();
assert_eq!(detect_format(&bytes, None), format);
let info = probe(&bytes, None).unwrap();
assert_eq!(info.format, format);
assert_eq!(info.sample_rate, 48_000);
assert_eq!(info.channels as usize, channels);
assert_eq!(info.frames, 4801);
assert_eq!(info.sample_format, expected_sf(depth));
let (dinfo, out) = decode(&bytes, None).unwrap();
assert_eq!(dinfo, info);
assert_eq!(out.sample_rate, 48_000);
let err = max_err(&src, &out);
assert!(err <= tolerance(depth, dither), "{format:?} {depth:?} {channels}ch dither={dither}: err {err}");
}
}
}
}
}
#[test]
fn flac_roundtrip_is_lossless_vs_wav() {
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
for channels in [1, 2, 6] {
// Spans several blocks plus a short final block.
let src = sine(44_100, channels, 4096 * 2 + 17);
let wav = encode(&src, &EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: None }).unwrap();
let flac = encode(&src, &EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither: false, bwf: None }).unwrap();
assert_eq!(detect_format(&flac, None), FileFormat::Flac);
let info = probe(&flac, None).unwrap();
assert_eq!(info.format, FileFormat::Flac);
assert_eq!((info.sample_rate, info.channels as usize, info.frames), (44_100, channels, src.frames() as u64));
assert_eq!(info.sample_format, expected_sf(depth));
let (_, from_wav) = decode(&wav, None).unwrap();
let (finfo, from_flac) = decode(&flac, None).unwrap();
assert_eq!(finfo.frames, src.frames() as u64);
assert_eq!(from_flac, from_wav, "{depth:?} {channels}ch");
if depth != BitDepth::Int32 {
assert!(flac.len() < wav.len(), "{depth:?} {channels}ch: flac {} >= wav {}", flac.len(), wav.len());
}
}
}
}
#[test]
fn flac_handles_silence_noise_full_scale_and_odd_rates() {
let frames = 10_000;
let mut b = AudioBuffer::new(37_123, 3, frames);
let mut seed = 1u32;
for i in 0..frames {
seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
b.channels[1][i] = (seed as f32 / u32::MAX as f32) * 2.0 - 1.0; // white noise
b.channels[2][i] = if i % 2 == 0 { 1.5 } else { -1.5 }; // clipped full-scale square
}
for depth in [BitDepth::Int16, BitDepth::Int24, BitDepth::Int32] {
for dither in [false, true] {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: depth, dither, bwf: None };
let flac = encode(&b, &opts).unwrap();
let (info, out) = decode(&flac, None).unwrap();
assert_eq!(info.sample_rate, 37_123);
let wav = encode(&b, &EncodeOptions { format: FileFormat::Wav, ..opts }).unwrap();
let (_, expect) = decode(&wav, None).unwrap();
assert_eq!(out, expect, "{depth:?} dither={dither}");
}
}
}
#[test]
fn flac_rejects_unsupported_layouts() {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Float32, dither: false, bwf: None };
assert!(encode(&sine(48_000, 2, 10), &opts).is_err());
let opts = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
assert!(encode(&sine(48_000, 9, 10), &opts).is_err());
assert!(encode(&sine(700_000, 1, 10), &opts).is_err());
// Tiny buffers still encode and decode.
let one = sine(48_000, 2, 1);
let (_, out) = decode(&encode(&one, &opts).unwrap(), None).unwrap();
assert_eq!(out.frames(), 1);
}
#[test]
fn bwf_roundtrip() {
let bwf = BwfInfo {
description: "Scene 12 take 3".into(),
originator: "SoundCraft".into(),
originator_reference: "SC-0001".into(),
origination_date: "2026-10-06".into(),
origination_time: "12:34:56".into(),
time_reference: 0x1_2345_6789, // > 32 bits
};
let src = sine(96_000, 2, 1000);
for depth in DEPTHS {
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: depth, dither: false, bwf: Some(bwf.clone()) };
let bytes = encode(&src, &opts).unwrap();
let info = probe(&bytes, None).unwrap();
assert_eq!(info.bwf.as_ref(), Some(&bwf));
let (dinfo, _) = decode(&bytes, None).unwrap();
assert_eq!(dinfo.bwf, Some(bwf.clone()));
// RF64 layout reads back identically.
let rf64 = encode_wav_rf64(&src, &opts).unwrap();
assert_eq!(&rf64[..4], b"RF64");
let (rinfo, rbuf) = decode(&rf64, None).unwrap();
assert_eq!(rinfo.bwf, Some(bwf.clone()));
assert_eq!(rinfo.frames, 1000);
assert_eq!(rbuf, decode(&bytes, None).unwrap().1);
}
// Overlong strings are truncated to the field width, not an error.
let long = BwfInfo { description: "x".repeat(400), ..BwfInfo::default() };
let bytes = encode(&src, &EncodeOptions { bwf: Some(long), ..EncodeOptions::default() }).unwrap();
assert_eq!(probe(&bytes, None).unwrap().bwf.unwrap().description.len(), 256);
}
#[test]
fn wav_header_layout() {
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { bit_depth: BitDepth::Int16, ..EncodeOptions::default() }).unwrap();
// Plain PCM for 16-bit stereo; extensible otherwise.
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 1);
let b = encode(&sine(44_100, 2, 10), &EncodeOptions::default()).unwrap();
let fmt = b.windows(4).position(|w| w == b"fmt ").unwrap();
assert_eq!(u16::from_le_bytes([b[fmt + 8], b[fmt + 9]]), 0xFFFE);
let riff = u32::from_le_bytes([b[4], b[5], b[6], b[7]]) as usize;
assert_eq!(riff + 8, b.len());
// Odd data length (24-bit mono, odd frames) gets a pad byte.
let b = encode(&sine(44_100, 1, 3), &EncodeOptions::default()).unwrap();
assert_eq!(b.len() % 2, 0);
assert_eq!(decode(&b, None).unwrap().1.frames(), 3);
}
#[test]
fn aiff_float_is_aifc() {
let b =
encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap();
assert_eq!(&b[8..12], b"AIFC");
assert!(b.windows(4).any(|w| w == b"fl32"));
let b = encode(&sine(44_100, 2, 10), &EncodeOptions { format: FileFormat::Aiff, ..EncodeOptions::default() }).unwrap();
assert_eq!(&b[8..12], b"AIFF");
let form = u32::from_be_bytes([b[4], b[5], b[6], b[7]]) as usize;
assert_eq!(form + 8, b.len());
}
#[test]
fn unusual_sample_rates_roundtrip() {
for sr in [1, 8_000, 11_025, 22_050, 44_100, 88_200, 176_400, 192_000, 352_800, 384_000, 768_000] {
for format in [FileFormat::Wav, FileFormat::Aiff] {
let bytes = encode(&sine(sr, 1, 8), &EncodeOptions { format, ..EncodeOptions::default() }).unwrap();
assert_eq!(probe(&bytes, None).unwrap().sample_rate, sr, "{format:?}");
}
}
}
#[test]
fn encode_rejects_bad_buffers_and_formats() {
assert!(encode(&AudioBuffer::default(), &EncodeOptions::default()).is_err());
assert!(encode(&AudioBuffer::new(0, 2, 10), &EncodeOptions::default()).is_err());
for f in [FileFormat::Mp3, FileFormat::Ogg, FileFormat::Aac, FileFormat::Alac, FileFormat::Caf, FileFormat::Other] {
assert!(encode(&sine(48_000, 1, 10), &EncodeOptions { format: f, ..EncodeOptions::default() }).is_err());
}
// Non-finite samples are sanitized; only integer output clips finite samples.
let mut b = AudioBuffer::new(48_000, 1, 4);
b.channels[0] = vec![f32::NAN, f32::INFINITY, -5.0, 0.5];
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for depth in DEPTHS {
if format == FileFormat::Flac && depth == BitDepth::Float32 {
continue;
}
let bytes = encode(&b, &EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() }).unwrap();
let (_, out) = decode(&bytes, None).unwrap();
assert_eq!(out.channels[0][0], 0.0);
assert!(out.channels[0][1] > 0.99 && out.channels[0][1] <= 1.0);
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
}
}
// Ragged buffers encode the common length.
let ragged = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10], vec![0.2; 7]] };
let (_, out) = decode(&encode(&ragged, &EncodeOptions::default()).unwrap(), None).unwrap();
assert_eq!(out.frames(), 7);
}
#[test]
fn audio_buffer_helpers() {
let b = AudioBuffer::from_interleaved(48_000, 2, &[0.1, -0.2, 0.3, -0.4, 0.5]);
assert_eq!(b.frames(), 2);
assert_eq!(b.num_channels(), 2);
assert_eq!(b.interleaved(), vec![0.1, -0.2, 0.3, -0.4]);
assert!((b.peak() - 0.4).abs() < 1e-7);
assert!((b.duration_secs() - 2.0 / 48_000.0).abs() < 1e-12);
assert_eq!(AudioBuffer::from_interleaved(1, 0, &[1.0]).num_channels(), 0);
assert_eq!(AudioBuffer::new(0, 1, 5).duration_secs(), 0.0);
assert_eq!(AudioBuffer::default().frames(), 0);
}
#[test]
fn serde_types_roundtrip() {
let opts = EncodeOptions { bwf: Some(BwfInfo { time_reference: 42, ..BwfInfo::default() }), ..EncodeOptions::default() };
let s = serde_json::to_string(&opts).unwrap();
assert_eq!(serde_json::from_str::<EncodeOptions>(&s).unwrap(), opts);
let info = probe(&encode(&sine(48_000, 1, 4), &opts).unwrap(), None).unwrap();
let s = serde_json::to_string(&info).unwrap();
assert_eq!(serde_json::from_str::<soundcraft_audio_io::AudioInfo>(&s).unwrap(), info);
}
#[test]
fn multichannel_wav_speaker_masks() {
let buf = |n: usize| AudioBuffer { sample_rate: 48_000, channels: (0..n).map(|c| vec![0.01 * c as f32; 64]).collect() };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: None };
for (n, mask) in [(6usize, 0x3Fu32), (8, 0x63F), (12, 0x2_D63F)] {
let bytes = soundcraft_audio_io::encode(&buf(n), &opts).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(mask), "{n} channels");
assert_eq!(soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap().1.channels.len(), n);
}
// Explicit masks: 7.0 (sides + rears, no LFE) and Ambisonics (no positions).
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(7), &opts, 0x637).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x637));
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(4), &opts, 0).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0));
// Plain 16-bit stereo stays a plain PCM header.
let o16 = EncodeOptions { bit_depth: BitDepth::Int16, ..opts };
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
}
#[test]
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
let take = sine(48_000, 2, 1_000);
let mut file = wav_float_header(2, 48_000, 0).unwrap();
let len = file.len();
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}
#[test]
fn zero_frame_flac_round_trips_like_wav_and_aiff() {
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
for depth in [BitDepth::Int16, BitDepth::Int24] {
for channels in [1, 2] {
let opts = EncodeOptions { format, bit_depth: depth, ..EncodeOptions::default() };
let bytes = encode(&AudioBuffer::new(48_000, channels, 0), &opts).unwrap();
let info = probe(&bytes, None).unwrap();
assert_eq!((info.format, info.sample_format, info.sample_rate, info.frames), (format, expected_sf(depth), 48_000, 0));
assert_eq!(usize::from(info.channels), channels);
let (info, buf) = decode(&bytes, None).unwrap();
assert_eq!((info.frames, buf.frames(), buf.num_channels()), (0, 0, channels));
}
}
}
}
#[test]
fn flac_claiming_frames_but_holding_none_still_errors() {
let opts = EncodeOptions { format: FileFormat::Flac, bit_depth: BitDepth::Int16, ..EncodeOptions::default() };
let mut bytes = encode(&AudioBuffer::new(48_000, 2, 0), &opts).unwrap();
bytes[25] = 1; // STREAMINFO total samples (low byte of the 36-bit field) = 1
assert!(decode(&bytes, None).is_err());
}
#[test]
fn float_wav_header_rejects_a_frame_count_whose_total_size_overflows() {
// 4 bytes per frame: the data length fits in u64, but adding the header to it does not.
let frames = u64::MAX / 4;
assert!(matches!(wav_float_header(1, 48_000, frames), Err(soundcraft_audio_io::AudioError::TooLarge(_))));
}
#[test]
fn bwf_truncation_keeps_utf8_boundaries() {
let src = sine(48_000, 1, 10);
let bwf = BwfInfo { originator: format!("{}é", "a".repeat(31)), description: format!("{}é", "d".repeat(255)), ..BwfInfo::default() };
let bytes = encode(&src, &EncodeOptions { format: FileFormat::Wav, bwf: Some(bwf), ..EncodeOptions::default() }).unwrap();
let out = probe(&bytes, None).unwrap().bwf.unwrap();
assert_eq!(out.originator, "a".repeat(31));
assert_eq!(out.description, "d".repeat(255));
}
-110
View File
@@ -1,110 +0,0 @@
use soundcraft_audio_io::{AudioBuffer, AudioError, BitDepth, BwfInfo, EncodeOptions, FileEncoder, FileFormat, encode, encode_with_channel_mask};
use std::io::{Cursor, Seek, SeekFrom, Write};
fn tone(channels: usize, frames: usize) -> AudioBuffer {
let channels = (0..channels).map(|c| (0..frames).map(|i| ((i * (c + 3)) as f32 * 0.013).sin() * 1.2).collect()).collect();
AudioBuffer { sample_rate: 44_100, channels }
}
fn streamed(buf: &AudioBuffer, opts: &EncodeOptions, mask: Option<u32>) -> Vec<u8> {
let mut out = Cursor::new(b"pre".to_vec());
out.set_position(3);
let mut enc = FileEncoder::new(out, opts, buf.channels.len(), buf.sample_rate, mask).unwrap();
let mut start = 0;
for len in [1, 7, 4095, 4097, 333].into_iter().cycle() {
if start >= buf.frames() {
break;
}
let len = len.min(buf.frames() - start);
let block: Vec<Vec<f32>> = buf.channels.iter().map(|c| c[start..start + len].to_vec()).collect();
enc.write(&block, len).unwrap();
start += len;
}
enc.finish().unwrap().into_inner()
}
#[test]
fn streamed_files_match_whole_buffer_encodes() {
let bwf = BwfInfo { description: "take".into(), time_reference: 123_456, ..Default::default() };
let cases = [
(FileFormat::Wav, BitDepth::Int24, true, None, None),
(FileFormat::Wav, BitDepth::Float32, false, None, None),
(FileFormat::Wav, BitDepth::Int16, true, Some(bwf), Some(0x60F)),
(FileFormat::Aiff, BitDepth::Int24, true, None, None),
(FileFormat::Aiff, BitDepth::Float32, false, None, None),
(FileFormat::Flac, BitDepth::Int16, true, None, None),
(FileFormat::Flac, BitDepth::Int24, false, None, None),
];
for (format, bit_depth, dither, bwf, mask) in cases {
let opts = EncodeOptions { format, bit_depth, dither, bwf };
for channels in [1, 6] {
let buf = tone(channels, 20_001);
let whole = match mask {
Some(m) => encode_with_channel_mask(&buf, &opts, m),
None => encode(&buf, &opts),
}
.unwrap();
assert_eq!(streamed(&buf, &opts, mask), [b"pre".as_slice(), &whole].concat(), "{opts:?}, {channels} channels");
}
}
}
/// A `Write + Seek` sink that accepts `budget` bytes in total and then fails every write, so
/// a `write_all` on it appends a prefix of the block and only then reports the error.
struct FailAfter {
inner: Cursor<Vec<u8>>,
budget: usize,
}
impl Write for FailAfter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
if self.budget == 0 {
return Err(std::io::Error::other("sink is full"));
}
let n = buf.len().min(self.budget);
self.budget -= n;
self.inner.write(&buf[..n])
}
fn flush(&mut self) -> std::io::Result<()> {
self.inner.flush()
}
}
impl Seek for FailAfter {
fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
self.inner.seek(pos)
}
}
#[test]
fn a_failed_write_poisons_the_encoder() {
let buf = tone(2, 20_001);
for format in [FileFormat::Wav, FileFormat::Aiff, FileFormat::Flac] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let sink = FailAfter { inner: Cursor::new(Vec::new()), budget: 256 };
let mut enc = FileEncoder::new(sink, &opts, 2, buf.sample_rate, None).unwrap();
// The sink accepts a prefix of the first block, then fails.
assert!(enc.write(&buf.channels, buf.frames()).is_err(), "{format:?}");
// Later calls must not claim success: the bytes on the sink no longer match the
// encoder's counters (and for FLAC the failed frame's samples are already gone).
assert!(matches!(enc.write(&buf.channels, 16), Err(AudioError::Encode(_))), "{format:?}");
assert!(matches!(enc.finish(), Err(AudioError::Encode(_))), "{format:?}");
}
}
#[test]
fn blocks_past_a_format_limit_are_refused_before_anything_is_written() {
let buf = tone(2, 100);
for (format, limit) in [(FileFormat::Aiff, 1u64 << 30), (FileFormat::Flac, 1u64 << 36)] {
let opts = EncodeOptions { format, bit_depth: BitDepth::Int16, dither: false, bwf: None };
let mut enc = FileEncoder::new(Cursor::new(Vec::new()), &opts, 2, buf.sample_rate, None).unwrap();
enc.write(&buf.channels, 100).unwrap();
assert!(enc.check_room(limit / 2).is_ok(), "{format:?}");
assert!(matches!(enc.check_room(limit), Err(AudioError::TooLarge(_))), "{format:?}");
if let Ok(frames) = usize::try_from(limit) {
assert!(matches!(enc.write(&[], frames), Err(AudioError::TooLarge(_))), "{format:?}");
}
assert_eq!(enc.finish().unwrap().into_inner(), encode(&buf, &opts).unwrap());
}
}
@@ -1,87 +0,0 @@
//! A WAV whose data chunk is empty and followed by metadata decodes to no audio; a recording
//! that was never finalised (RIFF and data sizes left at 0) still yields what was written (#466).
use soundcraft_audio_io::{decode, probe};
fn fmt16(channels: u16, sr: u32) -> Vec<u8> {
let align = channels * 2;
let mut f = Vec::new();
f.extend_from_slice(&1u16.to_le_bytes());
f.extend_from_slice(&channels.to_le_bytes());
f.extend_from_slice(&sr.to_le_bytes());
f.extend_from_slice(&(sr * u32::from(align)).to_le_bytes());
f.extend_from_slice(&align.to_le_bytes());
f.extend_from_slice(&16u16.to_le_bytes());
f
}
fn chunk(id: &[u8; 4], data: &[u8]) -> Vec<u8> {
let mut c = id.to_vec();
c.extend_from_slice(&(data.len() as u32).to_le_bytes());
c.extend_from_slice(data);
if data.len() % 2 == 1 {
c.push(0);
}
c
}
/// A finished file: correct RIFF size, chunks as given.
fn riff(chunks: &[Vec<u8>]) -> Vec<u8> {
let mut body = b"WAVE".to_vec();
for c in chunks {
body.extend_from_slice(c);
}
let mut out = b"RIFF".to_vec();
out.extend_from_slice(&(body.len() as u32).to_le_bytes());
out.extend_from_slice(&body);
out
}
fn list_info() -> Vec<u8> {
let mut l = b"INFO".to_vec();
l.extend_from_slice(&chunk(b"INAM", b"Silence"));
chunk(b"LIST", &l)
}
#[test]
fn an_empty_data_chunk_followed_by_metadata_has_no_frames() {
let file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[]), list_info(), chunk(b"bext", &[0u8; 602])]);
let info = probe(&file, Some("wav")).unwrap();
assert_eq!(info.frames, 0, "the LIST and bext chunks are not samples");
let (info, buf) = decode(&file, Some("wav")).unwrap();
assert_eq!((info.frames, buf.frames()), (0, 0));
// The same with the metadata first.
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), list_info(), chunk(b"data", &[])]);
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
// And with a single trailing chunk of odd size and no pad byte at the very end.
let mut file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &[]), chunk(b"note", b"abc")]);
file.pop();
let riff_size = (file.len() - 8) as u32;
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 0);
}
#[test]
fn a_recording_that_was_never_finalised_still_gives_its_audio() {
let samples: Vec<u8> = (0..400i16).flat_map(|i| (i * 40).to_le_bytes()).collect();
// RIFF size and data size both left at 0, as a recorder writes them until it finishes.
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
file.extend_from_slice(&samples);
file[4..8].copy_from_slice(&0u32.to_le_bytes());
let (info, buf) = decode(&file, Some("wav")).unwrap();
assert_eq!(info.frames, 200);
assert_eq!(buf.frames(), 200);
// Even if the RIFF size is the file's, audio that is not a run of chunks is still audio.
let mut file = riff(&[chunk(b"fmt ", &fmt16(2, 48_000)), chunk(b"data", &[])]);
file.extend_from_slice(&samples);
let riff_size = (file.len() - 8) as u32;
file[4..8].copy_from_slice(&riff_size.to_le_bytes());
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 200);
}
#[test]
fn a_data_chunk_with_audio_is_unchanged() {
let samples: Vec<u8> = (0..100i16).flat_map(|i| i.to_le_bytes()).collect();
let file = riff(&[chunk(b"fmt ", &fmt16(1, 44_100)), chunk(b"data", &samples), list_info()]);
assert_eq!(probe(&file, Some("wav")).unwrap().frames, 100);
}
+1 -1
View File
@@ -9,7 +9,7 @@ repository.workspace = true
[dependencies]
soundcraft-engine.workspace = true
soundcraft-audio-io.workspace = true
craft-audio.workspace = true
soundcraft-time.workspace = true
serde_json.workspace = true
log.workspace = true
+15
View File
@@ -317,4 +317,19 @@ mod tests {
assert!(r.call("session.inspect", json!({})).is_ok());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[test]
fn remote_returns_an_app_error_unchanged() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
std::thread::spawn(move || {
let Ok((mut sock, _)) = listener.accept() else { return };
let mut line = String::new();
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
});
let mut r = Remote::new(&addr);
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
assert_eq!(err, "transport.locate: nothing to reset");
}
}
+1 -2
View File
@@ -2,7 +2,6 @@
use crate::Backend;
use serde_json::{Value, json};
use soundcraft_audio_io::FileFormat;
use std::io::{BufRead, Write};
pub const PROTOCOL_VERSION: &str = "2025-06-18";
@@ -71,7 +70,7 @@ pub fn tool_definitions() -> Vec<Value> {
tool(
"bounce_mix",
"Render the mix to an audio file. Returns peak dBFS and integrated LUFS.",
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": FileFormat::writable_extensions()}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
json!({"path": {"type": "string"}, "format": {"type": "string", "enum": craft_audio::ENCODE_EXTENSIONS}, "bit_depth": {"type": "string"}, "start": {}, "end": {}, "normalize": {"type": "boolean"}}),
&["path"],
),
tool("parity", "Feature-parity report against the incumbent's menu catalog.", json!({}), &[]),
@@ -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]
+1 -1
View File
@@ -25,7 +25,7 @@ harness = false
clap-sys = "0.5"
libloading = "0.9"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# The isolated unsafe helper crate: this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+1
View File
@@ -27,6 +27,7 @@ use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_p
use clap_sys::ext::params::{
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
};
use clap_sys::ext::render::{CLAP_EXT_RENDER, CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
use clap_sys::ext::state::{CLAP_EXT_STATE, clap_host_state, clap_plugin_state};
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
use clap_sys::fixedpoint::{CLAP_BEATTIME_FACTOR, CLAP_SECTIME_FACTOR};
+15 -2
View File
@@ -7,6 +7,7 @@ use super::{
clap_host, clap_plugin_audio_ports, clap_plugin_entry, clap_plugin_factory, clap_plugin_gui, clap_plugin_latency, clap_plugin_note_ports,
clap_plugin_params, clap_plugin_state, clap_version_is_compatible, read_cstr, scan,
};
use super::{CLAP_EXT_RENDER, clap_plugin_render};
// ---- loaded binaries -----------------------------------------------------------------------
@@ -42,6 +43,16 @@ fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
L.get_or_init(|| Mutex::new(HashMap::new()))
}
/// A libloading error with the system's reason. libloading's own text is only "dlopen failed"
/// (or "dlsym failed", "LoadLibraryExW failed"); the reason (`dlerror`, the Windows error) is its
/// source.
fn with_reason(e: &libloading::Error) -> String {
match std::error::Error::source(e) {
Some(why) => format!("{e}: {why}"),
None => e.to_string(),
}
}
impl Bundle {
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
@@ -59,12 +70,12 @@ impl Bundle {
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(with_reason(&e)))?;
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
Ok(sym) => *sym,
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
Err(e) => return Err(err(format!("not a CLAP plugin (no clap_entry): {}", with_reason(&e)))),
};
if entry.is_null() {
return Err(err("null clap_entry".into()));
@@ -178,6 +189,7 @@ impl Bundle {
latency: std::ptr::null(),
notes: std::ptr::null(),
state_ext: std::ptr::null(),
render: std::ptr::null(),
gui: Arc::new(GuiLink::default()),
raw: Vec::with_capacity(EVENT_CAP),
out_params: Vec::with_capacity(EVENT_CAP),
@@ -205,6 +217,7 @@ impl Bundle {
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
inst.state_ext = inst.extension::<clap_plugin_state>(CLAP_EXT_STATE);
inst.render = inst.extension::<clap_plugin_render>(CLAP_EXT_RENDER);
let gui = inst.extension::<clap_plugin_gui>(CLAP_EXT_GUI);
inst.gui = Arc::new(GuiLink {
inner: Mutex::new(GuiInner { plugin, gui, state: inst.state_ext, alive: true, created: false, visible: false, window: None }),
+3 -1
View File
@@ -17,9 +17,11 @@ pub(crate) fn on_main_thread() -> bool {
unsafe { pthread_main_np() == 1 }
}
/// True on the process's main thread: Rust names it "main", and the app runs its UI there (the
/// rule `hostwin` and the VST3 host use). CLAP makes `clap.gui` main-thread only on every platform.
#[cfg(not(target_os = "macos"))]
pub(crate) fn on_main_thread() -> bool {
true
std::thread::current().name() == Some("main")
}
/// The windowing API of this platform.
+12
View File
@@ -8,6 +8,7 @@ use super::{
clap_plugin, clap_plugin_audio_ports, clap_plugin_latency, clap_plugin_note_ports, clap_plugin_params, clap_plugin_state, clap_process,
read_name,
};
use super::{CLAP_RENDER_OFFLINE, CLAP_RENDER_REALTIME, clap_plugin_render};
// ---- audio buffers -------------------------------------------------------------------------
@@ -99,6 +100,7 @@ pub(crate) struct Instance {
pub(super) latency: *const clap_plugin_latency,
pub(super) notes: *const clap_plugin_note_ports,
pub(super) state_ext: *const clap_plugin_state,
pub(super) render: *const clap_plugin_render,
pub(super) gui: Arc<GuiLink>,
pub(super) raw: Vec<RawEvent>,
/// Parameter values the plugin reported through output events in the current `process`.
@@ -158,6 +160,16 @@ impl Instance {
unsafe { load(self.plugin, &stream) }
}
/// `clap_plugin_render.set`: offline (no realtime pressure) or realtime. `false` without the
/// extension or when the plugin can't switch.
pub fn set_render_mode(&self, offline: bool) -> bool {
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
let Some(set) = (unsafe { self.render.as_ref() }).and_then(|r| r.set) else { return false };
let mode = if offline { CLAP_RENDER_OFFLINE } else { CLAP_RENDER_REALTIME };
// SAFETY: valid instance, called from the thread that owns it.
unsafe { set(self.plugin, mode) }
}
/// Runs `on_main_thread` if the plugin asked for a callback.
pub fn service_callback(&mut self) {
if self.state.callback.swap(false, Ordering::Relaxed)
+39 -3
View File
@@ -5,7 +5,7 @@
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
//!
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
//! (segfault) takes the process down with it; we cannot sandbox native code.
@@ -222,10 +222,33 @@ mod tests {
assert!(plugin_info("reverb").is_none());
}
/// A new directory that this test alone created, readable only by its user on Unix. Creation
/// fails if the path already exists: the test then loads native libraries from it, so it must
/// never reuse a directory someone else could have put a library in. It lives next to the test
/// binary (in the build directory), not under the temp dir: `TMPDIR`/`TEMP` can name a shared
/// directory without a sticky bit or with inheritable write access, where another user could
/// rename or populate it. Anyone who can write to the build directory can already replace the
/// test binary itself, so this parent adds no new trust.
fn fresh_private_dir(prefix: &str) -> std::path::PathBuf {
let exe = std::env::current_exe().expect("test binary path");
let parent = exe.parent().expect("test binary has a parent directory");
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |t| t.as_nanos());
let d = parent.join(format!("{prefix}-{}-{nanos}", std::process::id()));
#[cfg(unix)]
let builder = {
let mut b = std::fs::DirBuilder::new();
std::os::unix::fs::DirBuilderExt::mode(&mut b, 0o700);
b
};
#[cfg(not(unix))]
let builder = std::fs::DirBuilder::new();
builder.create(&d).unwrap_or_else(|e| panic!("cannot create a fresh test directory {}: {e}", d.display()));
d
}
#[test]
fn garbage_files_are_errors_not_crashes() {
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
let d = fresh_private_dir("soundcraft-clap-garbage");
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
std::fs::write(d.join("empty.clap"), b"").unwrap();
@@ -239,4 +262,17 @@ mod tests {
assert!(instantiate("clap:does.not.exist").is_err());
let _ = std::fs::remove_dir_all(&d);
}
/// A binary the system refuses to load reports the system's reason (missing library, wrong
/// glibc, not an ELF file), not just "dlopen failed".
#[test]
fn a_binary_that_does_not_load_says_why() {
let d = fresh_private_dir("soundcraft-clap-reason");
let f = d.join("garbage.clap");
std::fs::write(&f, b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
let msg = load_bundle(&f).err().map(|e| e.to_string()).unwrap_or_default();
let reason = msg.split_once("failed: ").map_or("", |(_, r)| r.trim());
assert!(!reason.is_empty(), "no reason in: {msg}");
let _ = std::fs::remove_dir_all(&d);
}
}
+214 -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,
}
}
@@ -178,10 +272,17 @@ impl Plugin for ClapPlugin {
}
}
fn set_offline(&mut self, offline: bool) {
// `clap.render`: plugins without it render the same way either way.
self.inst.set_render_mode(offline);
}
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 {
@@ -282,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]) {
@@ -378,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");
}
}
+3 -1
View File
@@ -150,7 +150,9 @@ mod tests {
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
// Created exclusively: a folder still there after the cleanup (someone else's) would
// let a link inside it redirect the test's writes.
std::fs::create_dir(&d).unwrap();
d
}
@@ -5,6 +5,7 @@
mod common;
use soundcraft_dsp::Plugin;
use std::sync::Mutex;
const GAIN: &str = "clap:org.soundcraft.test.gain";
@@ -14,12 +15,31 @@ fn block(p: &mut Box<dyn Plugin>) -> Vec<Vec<f32>> {
io
}
/// The warnings logged, to tell which way the host went.
static WARNINGS: Mutex<Vec<String>> = Mutex::new(Vec::new());
struct Warnings;
impl log::Log for Warnings {
fn enabled(&self, m: &log::Metadata) -> bool {
m.level() <= log::Level::Warn
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
WARNINGS.lock().unwrap().push(r.args().to_string());
}
}
fn flush(&self) {}
}
fn main() {
// nextest lists custom-harness tests with `--list`; each binary here is one test.
if std::env::args().any(|a| a == "--list") {
println!("editor_main_thread: test");
return;
}
log::set_logger(&Warnings).expect("logger");
log::set_max_level(log::LevelFilter::Warn);
common::fixture_dir();
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
@@ -32,6 +52,26 @@ fn main() {
// block and is reported (in SoundCraft units) by idle.
let io = block(&mut p);
assert!((io[0][10] - 0.25).abs() < 1e-6, "{}", io[0][10]);
// Only the main thread drives the editor, on every platform (#901). Another thread, named or
// not, is refused: it neither takes the edit idle reports nor closes or reopens the editor.
for name in [None, Some("soundcraft-ui")] {
let mut other = p.editor().expect("editor handle");
let thread = match name {
Some(n) => std::thread::Builder::new().name(n.to_string()),
None => std::thread::Builder::new(),
};
let seen = thread
.spawn(move || {
let edits = other.idle();
other.close();
(edits, other.open().map_err(|e| e.contains("main thread")))
})
.expect("spawn")
.join()
.expect("no panic");
assert_eq!(seen, (Vec::new(), Err(true)), "{name:?}");
assert!(ed.is_open(), "{name:?}");
}
assert_eq!(ed.idle(), vec![("7".to_string(), 0.25)]);
assert!(ed.idle().is_empty(), "drained");
ed.close();
@@ -49,5 +89,17 @@ fn main() {
assert!(ed.open().is_err());
assert!(ed.idle().is_empty());
ed.close();
// #901: a plugin dropped on another thread with its editor open (an audio callback dropped
// by the device's thread) does not destroy the editor there, clap.gui being main-thread only:
// it warns and leaves it.
let mut p = soundcraft_clap_host::create(GAIN).expect("fixture");
p.prepare(48_000.0, 64, 2);
let mut ed = p.editor().expect("editor handle");
ed.open().expect("open on the main thread");
std::thread::Builder::new().name("audio".into()).spawn(move || drop(p)).expect("spawn").join().expect("no panic");
let left = "clap: editor still open while its plugin is destroyed off the main thread";
assert!(WARNINGS.lock().unwrap().iter().any(|w| w == left), "{:?}", WARNINGS.lock().unwrap());
assert!(!ed.is_open());
assert!(ed.open().is_err());
println!("editor_main_thread: ok");
}
+35 -10
View File
@@ -4,12 +4,13 @@
//! (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).
//! `process` writes 1.0 into every output channel it was given when the host's buffers match the
//! advertised layout, -1.0 when they do not.
//! advertised layout (2.0 once the host set the offline render mode through `clap.render`), -1.0
//! when they do not.
//!
//! The gain plugin also implements `clap.state` (gain and mode as two little-endian f64s) and a
//! floating-only `clap.gui` without a real window: showing it "edits" the gain to 0.25 in the GUI,
@@ -27,6 +28,7 @@ use clap_sys::ext::latency::*;
use clap_sys::ext::log::*;
use clap_sys::ext::note_ports::*;
use clap_sys::ext::params::*;
use clap_sys::ext::render::*;
use clap_sys::factory::plugin_factory::*;
use clap_sys::host::clap_host;
use clap_sys::id::clap_id;
@@ -100,8 +102,12 @@ struct Fx {
active: bool,
processing: bool,
gui_created: bool,
/// The thread that created the GUI (the host's main thread).
gui_thread: Option<std::thread::ThreadId>,
gui_visible: bool,
gui_edit_pending: bool,
/// `clap.render` set the offline mode (wide fixtures only).
offline: bool,
}
unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {
@@ -126,7 +132,10 @@ unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
}
unsafe extern "C" fn destroy(p: *const clap_plugin) {
assert!(!unsafe { fx(p) }.gui_created, "plugin destroyed with its GUI alive");
let f = unsafe { fx(p) };
// A host destroying the plugin off the GUI's thread can't destroy the GUI there (clap.gui is
// main-thread only), so it may leave it; on the GUI's thread it must destroy it first.
assert!(!f.gui_created || f.gui_thread != Some(std::thread::current().id()), "plugin destroyed with its GUI alive");
let data = unsafe { (*p).plugin_data } as *mut Fx;
drop(unsafe { Box::from_raw(data) });
}
@@ -157,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 {
@@ -189,8 +198,8 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
}
}
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
/// Fill every output channel with `fit` when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize, fit: f32) {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
@@ -200,7 +209,7 @@ unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
unsafe { *o.add(i) = if ok { fit } else { -1.0 } };
}
}
}
@@ -252,7 +261,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide {
unsafe { fill_wide(pr, ports, channels, n) };
unsafe { fill_wide(pr, ports, channels, n, if f.offline { 2.0 } else { 1.0 }) };
return CLAP_PROCESS_CONTINUE;
}
if pr.audio_outputs_count != 1 {
@@ -488,6 +497,7 @@ unsafe extern "C" fn gui_create(p: *const clap_plugin, _api: *const c_char, floa
let f = unsafe { fx(p) };
assert!(floating && !f.gui_created, "create");
f.gui_created = true;
f.gui_thread = Some(std::thread::current().id());
true
}
@@ -543,6 +553,17 @@ static GUI: clap_plugin_gui = clap_plugin_gui {
hide: Some(gui_hide),
};
unsafe extern "C" fn render_hard_realtime(_p: *const clap_plugin) -> bool {
false
}
unsafe extern "C" fn render_set(p: *const clap_plugin, mode: clap_plugin_render_mode) -> bool {
unsafe { fx(p) }.offline = mode == CLAP_RENDER_OFFLINE;
true
}
static RENDER: clap_plugin_render = clap_plugin_render { has_hard_realtime_requirement: Some(render_hard_realtime), set: Some(render_set) };
unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void {
let id = unsafe { CStr::from_ptr(id) };
let synth = unsafe { fx(p) }.synth;
@@ -558,6 +579,8 @@ unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) ->
&STATE as *const _ as *const c_void
} else if id == CLAP_EXT_GUI && !synth {
&GUI as *const _ as *const c_void
} else if id == CLAP_EXT_RENDER && unsafe { fx(p) }.wide.is_some() {
&RENDER as *const _ as *const c_void
} else {
std::ptr::null()
}
@@ -621,8 +644,10 @@ unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const
active: false,
processing: false,
gui_created: false,
gui_thread: None,
gui_visible: false,
gui_edit_pending: false,
offline: false,
});
let raw = Box::into_raw(b);
unsafe {
+59 -6
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();
@@ -130,6 +172,19 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
}
}
#[test]
fn offline_renders_set_the_render_mode() {
fixture_dir();
for (offline, want) in [(false, 1.0), (true, 2.0)] {
let mut p = soundcraft_clap_host::create("clap:org.soundcraft.test.wide16").unwrap();
p.set_offline(offline);
p.prepare(44_100.0, 64, 2);
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == want), "offline {offline}: {:?}", &io[0][..4]);
}
}
#[test]
fn many_instances_create_and_drop_cleanly() {
fixture_dir();
@@ -202,14 +257,12 @@ fn state_reader_reads_while_another_thread_processes() {
#[test]
fn editors_refuse_to_open_off_the_main_thread() {
fixture_dir();
// Test threads are never the process main thread.
// Test threads are never the process main thread, on any platform (#901).
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
let mut ed = p.editor().expect("the gain fixture has clap.gui");
if cfg!(target_os = "macos") {
assert!(ed.open().unwrap_err().contains("main thread"));
assert!(p.open_editor().is_err());
assert!(!ed.is_open());
}
assert!(ed.open().unwrap_err().contains("main thread"));
assert!(p.open_editor().is_err());
assert!(!ed.is_open());
ed.close();
p.close_editor();
}
+5
View File
@@ -195,6 +195,11 @@ pub struct Transport {
pub trait Plugin: Send {
fn info(&self) -> &'static PluginInfo;
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize);
/// The plugin renders offline (a bounce: faster or slower than realtime, no deadline) rather
/// than in realtime. Hosted plugins pass it on (VST3 `processMode`, CLAP `clap.render`): some
/// use better algorithms then, or wait for what they load instead of playing on without it.
/// Called before `prepare`; plugins are realtime until told otherwise.
fn set_offline(&mut self, _offline: bool) {}
fn reset(&mut self);
/// Sets a parameter (clamped into range; NaN becomes the default). `false` if `id` is unknown.
fn set_param(&mut self, id: &str, value: f32) -> bool;
+15 -2
View File
@@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
if c.is_empty() {
continue;
}
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
let m = mean as f32;
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
if n == 0 {
continue;
}
let m = (sum / n as f64) as f32;
c.iter_mut().for_each(|s| *s -= m);
}
}
@@ -889,4 +892,14 @@ mod tests {
assert_eq!(ch[0][50], 1.0);
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
}
#[test]
fn remove_dc_ignores_non_finite_samples_in_the_mean() {
let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]];
remove_dc(&mut ch);
assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]);
let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]];
remove_dc(&mut all_bad);
assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite());
}
}
+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,
+4 -1
View File
@@ -9,7 +9,7 @@ repository.workspace = true
[dependencies]
soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
craft-audio.workspace = true
soundcraft-midi.workspace = true
soundcraft-dsp.workspace = true
soundcraft-clap-host.workspace = true
@@ -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
+195 -17
View File
@@ -3,7 +3,7 @@
use crate::io::{Recording, open_session_linked, sanitize_name};
use crate::{Engine, EngineError, Result};
use soundcraft_audio_io::{BitDepth, EncodeOptions, FileFormat};
use craft_audio::{BitDepth, EncodeOptions};
use soundcraft_model::{Session, SourceAudio, SourceId};
use std::collections::BTreeMap;
use std::io::Write;
@@ -212,6 +212,11 @@ impl Autosave {
let audio_dir = self.dir.join("Audio Files");
let mut s = session.clone();
let mut copies = BTreeMap::new();
// Copies made in this write, by the audio they hold: sources sharing one decoded buffer
// (a session may name one file under many ids) share one file.
let mut written: BTreeMap<*const SourceAudio, String> = BTreeMap::new();
// Copies still to make, written once the size is checked; their numbers are taken then too.
let (mut fresh, mut next) = (Vec::new(), self.next);
for src in &mut s.sources {
let file = if Path::new(&src.path).is_absolute() { Some(PathBuf::from(&src.path)) } else { base.as_ref().map(|b| b.join(&src.path)) };
let audio = s.pool.get(src.id);
@@ -222,26 +227,37 @@ impl Autosave {
continue;
}
let Some(audio) = audio else { continue };
let name = match self.copies.remove(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name,
let name = match self.copies.get(&src.id) {
Some((copy, name)) if copy.upgrade().is_some_and(|a| Arc::ptr_eq(&a, audio)) => name.clone(),
_ if written.contains_key(&Arc::as_ptr(audio)) => written.get(&Arc::as_ptr(audio)).cloned().unwrap_or_default(),
_ => {
let name = format!("{} {}.wav", sanitize_name(&src.name), self.next);
self.next += 1;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
let name = format!("{} {}.wav", sanitize_name(&src.name), next);
next += 1;
fresh.push((name.clone(), Arc::clone(audio)));
name
}
};
src.path = audio_dir.join(&name).to_string_lossy().into_owned();
src.unsaved = true;
written.insert(Arc::as_ptr(audio), name.clone());
copies.insert(src.id, (Arc::downgrade(audio), name));
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// An autosave too large to recover is not written, nor its new copies: the last autosave
// stays as it was, and the failure is reported (#892).
crate::io::session_size("the autosave", text.len() as u64, true)?;
self.next = next;
for (name, audio) in &fresh {
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Float32);
let bytes = craft_audio::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
}
write_atomically(&self.dir.join(ORIGIN), origin.unwrap_or_default().as_bytes())?;
write_atomically(&self.dir.join(SESSION), text.as_bytes())?;
for (_, name) in std::mem::replace(&mut self.copies, copies).into_values() {
let _ = std::fs::remove_file(audio_dir.join(name));
if !self.copies.values().any(|(_, kept)| *kept == name) {
let _ = std::fs::remove_file(audio_dir.join(name));
}
}
if let Some(r) = recording {
let plan = serde_json::to_string(r).map_err(|err| EngineError::Io(err.to_string()))?;
@@ -290,9 +306,7 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
drop(lock);
match saved {
Ok(saved) if recording || origin_saved.is_none_or(|o| saved > o) => {
let text = std::fs::read_to_string(dir.join(SESSION)).unwrap_or_default();
let named = serde_json::from_str::<serde_json::Value>(&text).ok().and_then(|v| v.get("name")?.as_str().map(str::to_string));
let name = named.unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
let name = saved_name(&dir.join(SESSION)).unwrap_or_else(|| dir.file_name().unwrap_or_default().to_string_lossy().into_owned());
out.push(Leftover { dir, name, origin, saved, recording });
}
_ => {
@@ -304,6 +318,21 @@ pub fn leftovers(root: &Path) -> Vec<Leftover> {
out
}
/// The name of the session saved in `file`, read without keeping the rest of it; `None` when the
/// file is missing, not a session, or too large to open (#892).
fn saved_name(file: &Path) -> Option<String> {
use std::io::Read as _;
#[derive(serde::Deserialize)]
struct Named {
name: String,
}
if std::fs::metadata(file).ok()?.len() > crate::io::session_limit() {
return None;
}
let read = std::fs::File::open(file).ok()?.take(crate::io::session_limit());
serde_json::from_reader::<_, Named>(std::io::BufReader::new(read)).ok().map(|n| n.name)
}
/// Open `left` as an unsaved version of the session it came from, so Save writes it back there,
/// with the takes of a recording that never finished placed as Stop would have. Returns the
/// media that couldn't be loaded.
@@ -375,7 +404,7 @@ fn repair_take(path: &Path, folders: &[PathBuf], channels: usize, rate: u32) ->
if !folder.is_some_and(|d| folders.contains(&d)) {
return Err(refuse("not in a folder SoundCraft records into"));
}
let header = |frames| soundcraft_audio_io::wav_float_header(channels, rate, frames).map_err(std::io::Error::other);
let header = |frames| craft_audio::wav_float_header(rate, u16::try_from(channels).unwrap_or(u16::MAX), frames).map_err(std::io::Error::other);
let expected = header(0)?;
let mut f = std::fs::OpenOptions::new().read(true).write(true).open(path)?;
let mut head = vec![0u8; expected.len()];
@@ -425,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)?;
@@ -447,9 +502,49 @@ fn io_error(path: &Path, err: std::io::Error) -> EngineError {
mod tests {
use super::*;
use crate::io::open_session;
use craft_audio::AudioBuffer;
#[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};
use soundcraft_model::{ChannelFormat, Clip, FileFormat, TrackKind};
/// Recovery loads document edits without re-enabling live inputs from an autosave.
#[test]
fn recovered_live_input_states_start_disabled() {
let root = folder("live-input-recovery");
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let track = e.session().tracks[0].id;
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
let mut autosave = Autosave::create(&root, "Live inputs").unwrap();
autosave.write(e.session(), None, None).unwrap();
drop(autosave);
let mut recovered = Engine::default();
recover(&mut recovered, &leftovers(&root)[0]).unwrap();
let mixer = &recovered.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert_eq!(mixer.volume_db, -6.0);
std::fs::remove_dir_all(root).unwrap();
}
fn folder(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
@@ -507,6 +602,25 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn sources_sharing_one_buffer_share_one_copy() {
let dir = folder("autosave-aliases");
let mut e = Engine::default();
record(&mut e, 0.5);
// Nine more source ids over the same decoded audio, as a session file may declare.
let s = e.session_mut();
let (src, audio) = (s.sources[0].clone(), s.pool.get(s.sources[0].id).cloned().unwrap());
for _ in 0..9 {
let id = SourceId(s.alloc());
s.sources.push(soundcraft_model::Source { id, ..src.clone() });
s.pool.insert(id, audio.clone());
}
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
assert_eq!(std::fs::read_dir(a.dir.join("Audio Files")).unwrap().count(), 1, "one file for the ten ids");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_recorded_after_undo_gets_its_own_copy() {
let dir = folder("autosave-undo");
@@ -605,7 +719,7 @@ mod tests {
let take = root.join(sanitize_name(&e.session().name)).join("Audio Files/Vox_01.wav");
std::fs::create_dir_all(take.parent().unwrap()).unwrap();
let audio: Vec<u8> = (0..480).flat_map(|i| (i as f32 / 1000.0).to_le_bytes()).collect();
std::fs::write(&take, [soundcraft_audio_io::wav_float_header(1, 48_000, 100).unwrap(), audio].concat()).unwrap();
std::fs::write(&take, [craft_audio::wav_float_header(48_000, 1, 100).unwrap(), audio].concat()).unwrap();
let plan = Recording { start: 48_000, pass: None, punch: None, rate: 48_000, takes: vec![Take { track, path: take, channels: 1 }] };
let clips = |e: &Engine| e.session().track(track).unwrap().clips().iter().map(|c| (c.start, c.length)).collect::<Vec<_>>();
let w = Autosaver::new(root.clone());
@@ -636,7 +750,7 @@ mod tests {
soundcraft_model::TrackId(e.execute("track.new", &json!({"name": "Vox", "format": "mono"})).unwrap()["tracks"][0].as_u64().unwrap());
let takes = root.join(sanitize_name(&e.session().name)).join("Audio Files");
std::fs::create_dir_all(&takes).unwrap();
let wav = [soundcraft_audio_io::wav_float_header(1, 48_000, 0).unwrap(), vec![0; 4_000]].concat();
let wav = [craft_audio::wav_float_header(48_000, 1, 0).unwrap(), vec![0; 4_000]].concat();
let text = b"not audio at all, but long enough to be mistaken for a header if nobody looked at it ......".repeat(4);
let files =
[(takes.join("notes.wav"), text.clone()), (dir.join("Elsewhere/Vox_01.wav"), wav.clone()), (takes.join("../Vox_02.wav"), wav.clone())];
@@ -673,6 +787,70 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A test folder, removed when the test ends, passed or not.
struct Gone(PathBuf);
impl Drop for Gone {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn an_autosave_too_large_to_open_writes_nothing_and_keeps_the_last() {
// #892, with the limit lowered for this test: a refused autosave makes no audio copies
// and keeps the last autosave and its copies as they were.
let dir = Gone(folder("autosave-too-large"));
let mut e = Engine::default();
record(&mut e, 0.5);
let mut a = Autosave::create(&dir.0.join("Autosave"), "Song").unwrap();
a.write(e.session(), None, None).unwrap();
let state = |a: &Autosave| {
let mut files: Vec<String> =
std::fs::read_dir(a.dir.join("Audio Files")).unwrap().map(|f| f.unwrap().file_name().to_string_lossy().into_owned()).collect();
files.sort();
let copies: Vec<(SourceId, String)> = a.copies.iter().map(|(id, (_, name))| (*id, name.clone())).collect();
(files, copies, a.next, std::fs::read_to_string(a.dir.join(SESSION)).unwrap())
};
let before = state(&a);
record(&mut e, 0.25);
{
let _limit = crate::io::SessionLimit::set(before.3.len() as u64);
for _ in 0..2 {
let err = a.write(e.session(), None, None).unwrap_err().to_string();
assert!(err.starts_with("the autosave is too large to save"), "{err}");
assert!(state(&a) == before);
}
// The writer thread reports it as it reports any failed autosave.
let failure = Arc::new(Mutex::new(None));
let pending = Some((e.session_arc(), None, None));
let mut w = Writer { root: dir.0.join("Writer"), failure: Arc::clone(&failure), folder: None, pending, last: None, written: Ok(()) };
w.write_pending();
let reported = failure.lock().unwrap().take().unwrap_or_default();
assert!(reported.starts_with("the autosave is too large to save"), "{reported}");
}
a.write(e.session(), None, None).unwrap();
let after = state(&a);
assert_eq!((after.0.len(), after.1.len()), (2, 2));
assert!(after.0.contains(&before.0[0]));
}
#[test]
fn a_leftover_is_named_from_its_session_without_reading_a_huge_one() {
// #892: the name is read on its own, and an autosave too large to open is not read.
let dir = Gone(folder("autosave-names"));
std::fs::create_dir_all(&dir.0).unwrap();
let file = dir.0.join(SESSION);
let s = Session { name: "Song".into(), ..Session::default() };
std::fs::write(&file, s.to_json().unwrap()).unwrap();
assert_eq!(saved_name(&file).as_deref(), Some("Song"));
std::fs::write(&file, r#"{"name": 5}"#).unwrap();
assert_eq!(saved_name(&file), None);
std::fs::File::create(&file).unwrap().set_len(crate::io::MAX_SESSION_BYTES + 1).unwrap();
assert_eq!(saved_name(&file), None);
assert_eq!(saved_name(&dir.0.join("none")), None);
}
#[test]
fn sessions_started_together_get_folders_of_their_own() {
// #579: the folder name has millisecond resolution, so two sessions created back to back
+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);
}
}
}
+108 -12
View File
@@ -5,8 +5,9 @@
//! as 32-bit float WAVs in a `<name> Audio/` folder next to it.
use crate::{Engine, EngineError, Result};
use craft_audio::EncodeOptions;
use serde::{Deserialize, Serialize};
use soundcraft_audio_io::{EncodeOptions, FileFormat};
use soundcraft_model::FileFormat;
use soundcraft_model::{Clip, ClipContent, ClipId, SourceId, TrackId};
use soundcraft_time::Samples;
use std::collections::BTreeMap;
@@ -65,8 +66,8 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
{
let rel = format!("{audio_dir_name}/{}_{}.wav", crate::io::sanitize_name(&src.name), source.0);
crate::io::make_dir(dir.join(&audio_dir_name))?;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(craft_audio::BitDepth::Float32);
let bytes = craft_audio::encode(&buf.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
crate::io::write_path(dir.join(&rel), bytes)?;
slot.insert(rel);
}
@@ -85,21 +86,32 @@ fn decode_group_audio(
dir: &Path,
rate: u32,
cmd_id: &str,
) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
) -> Result<(Vec<(String, craft_audio::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);
if !full.starts_with(dir) || rel.contains("..") {
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {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(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 (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).map_err(|err| EngineError::Io(format!("{rel}: {err}")))?;
let bytes = crate::io::read_file_inside(&full, &root)?;
let (_, buf) = craft_audio::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
@@ -233,6 +245,90 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_accepts_audio_names_with_consecutive_dots_but_not_parent_dirs() {
let dir = std::env::temp_dir().join(format!("sc-grp-dots-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
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();
for (i, rel) in file["audio"].as_object_mut().unwrap().values_mut().enumerate() {
let old = dir.join(rel.as_str().unwrap());
let new = format!("Wait... what_{i}.wav");
std::fs::rename(&old, dir.join(&new)).unwrap();
*rel = json!(new);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(r.is_ok(), "{r:?}");
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("../escape.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(..))), "{r:?}");
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(dir.join("Wait... what_0.wav").to_string_lossy());
}
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()));
@@ -319,8 +415,8 @@ mod tests {
fn a_file_named_by_many_ids_becomes_one_source() {
let dir = std::env::temp_dir().join(format!("sc-grp-shared-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let audio = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("k.wav"), soundcraft_audio_io::encode(&audio, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let audio = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 480]] };
std::fs::write(dir.join("k.wav"), craft_audio::encode(&audio, &craft_audio::EncodeOptions::default()).unwrap()).unwrap();
let clip = |id: u64| json!({"id": id, "name": "k", "start": id * 1000, "length": 480, "content": {"Audio": {"source": id, "offset": 0}}});
let audio_paths = json!({"1": "k.wav", "2": "./k.wav", "3": "k.wav"});
let file = json!({"format": "soundcraft.clipgroup", "version": 1, "sample_rate": 48000, "length": 4000, "lanes": [[clip(1), clip(2), clip(3)]], "audio": audio_paths});
+10 -3
View File
@@ -324,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
}
/// Range from `start`/`end` params, else the edit selection.
/// Range from `start`/`end`/`length` params. A missing `start` defaults to the edit selection start;
/// with neither `end` nor `length` either, the whole edit selection is used.
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
let st = position_param(e, cmd, p, "start")?;
let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? };
let en = position_param(e, cmd, p, "end")?;
let len = position_param(e, cmd, p, "length")?;
if st.is_none() && (en.is_some() || len.is_some()) {
st = Some(e.session().edit.selection.start);
}
match (st, en, len) {
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
@@ -343,7 +347,10 @@ pub fn clip_id(p: &Value, cmd: &str) -> Result<ClipId> {
pub fn clip_ids_param(e: &Engine, p: &Value) -> Vec<ClipId> {
if let Some(arr) = p.get("clips").and_then(Value::as_array) {
return arr.iter().filter_map(Value::as_u64).map(ClipId).collect();
// Each clip once, in the order given: a repeated id would otherwise be processed (and,
// for a render, re-rendered into another source) once per repeat.
let mut seen = std::collections::HashSet::new();
return arr.iter().filter_map(Value::as_u64).map(ClipId).filter(|id| seen.insert(*id)).collect();
}
if let Some(n) = p.get("clip").and_then(Value::as_u64) {
return vec![ClipId(n)];
+2 -2
View File
@@ -47,7 +47,7 @@ fn audio_as_midi(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use craft_audio::AudioBuffer;
use soundcraft_model::{ChannelFormat, TrackKind};
#[test]
@@ -59,7 +59,7 @@ mod tests {
a.extend(tone(330.0));
let frames = a.len() as i64;
let s = e.session_mut();
let src = crate::io::add_source(s, "mel", AudioBuffer { sample_rate: 48_000, channels: vec![a] }, None, soundcraft_audio_io::FileFormat::Wav);
let src = crate::io::add_source(s, "mel", AudioBuffer { sample_rate: 48_000, channels: vec![a] }, None, soundcraft_model::FileFormat::Wav);
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let cid = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(cid, "mel", src, 0, 0, frames));
+34 -3
View File
@@ -3,9 +3,10 @@ use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_pa
use soundcraft_time::Range;
use crate::{Engine, Result, cmd};
use craft_audio::AudioBuffer;
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_dsp::offline::{SpeedRampChange, SpeedRampSelection};
use soundcraft_model::FileFormat;
use soundcraft_model::{Clip, ClipContent, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
@@ -14,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
"AudioSuite Process",
[],
None,
"{process: <plugin id> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
"{process: <built-in plugin id (engine.plugins)> | normalize | reverse | invert | gain | duplicate | time_stretch | pitch_shift | varispeed | speed_ramp | signal_generator, params?: {..}, clips?, tracks?, start?, end?} — signal_generator fills start..end (default: the selection) with a new clip on each audio track in `tracks` (default: the selected tracks); speed_ramp takes change (0 slow down, 1 speed up), selection (0 fit to, 1 extend) and fades (1 on, 0 off)",
has_selection,
process
)]
@@ -55,6 +56,9 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
| "speed_ramp"
| "dc_offset_removal"
);
if !known && soundcraft_mix::is_third_party(&name) {
return Err(bad("audiosuite.process", format!("`{name}` is a hosted plugin; AudioSuite runs built-in plugins only (engine.plugins)")));
}
if !known {
return Err(bad("audiosuite.process", format!("unknown process `{name}`")));
}
@@ -137,6 +141,7 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
nc.clamp_fades();
// The render already has the clip's stretch applied; keeping it would stretch the new file again.
nc.stretch = 1.0;
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
@@ -210,7 +215,7 @@ fn short(n: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use craft_audio::AudioBuffer;
use soundcraft_model::{ChannelFormat, Session, TrackKind};
fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
@@ -227,6 +232,17 @@ mod tests {
(crate::Engine::new(s), id)
}
#[test]
fn hosted_plugin_ids_are_refused_with_the_reason() {
let (mut e, id) = gained_clip();
for plugin in ["clap:com.example.reverb", "vst3:00112233445566778899AABBCCDDEEFF", "au:aufx:dely:appl"] {
let err = e.execute("audiosuite.process", &json!({"process": plugin, "clips": [id.0]})).unwrap_err().to_string();
assert!(err.contains("built-in"), "{plugin}: {err}");
}
let spec = specs().into_iter().find(|s| s.id == "audiosuite.process").unwrap();
assert!(spec.params.contains("<built-in plugin id"), "the help says which ids it takes: {}", spec.params);
}
#[test]
fn invert_and_duplicate_keep_clip_gain() {
let (mut e, id) = gained_clip();
@@ -246,6 +262,21 @@ mod tests {
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
#[test]
fn shortening_process_clamps_fades_to_the_new_length() {
let (mut e, id) = gained_clip();
{
let c = e.session_mut().find_clip_mut(id).unwrap();
c.fade_in.len = 60;
c.fade_out.len = 40;
}
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 0.25}, "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!(c.length < 64, "{}", c.length);
assert!(c.fade_in.len <= c.length, "fade in {} > length {}", c.fade_in.len, c.length);
assert_eq!(c.fade_out.len, 40.min(c.length));
}
#[test]
fn processed_clip_does_not_keep_the_stretch_it_baked() {
for stretch in [0.5, 2.0] {
+5 -5
View File
@@ -425,7 +425,7 @@ fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session();
let moves: Vec<(soundcraft_model::ClipId, i64)> =
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false)))).collect();
ids.iter().filter_map(|id| s.find_clip(*id).map(|(_, c)| (*id, crate::edit::grid_delta(s, c, 0, false, &ids)))).collect();
let s = e.session_mut();
for (id, d) in &moves {
crate::edit::move_clips(s, &[*id], *d, &[]);
@@ -464,8 +464,8 @@ mod tests {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Elastic"));
let samples = (0..source_len).map(|i| if i >= tone_at { (i as f32 * 0.2).sin() * 0.4 } else { 0.0 }).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![samples] };
let src = crate::io::add_source(&mut s, "Tone", buf, None, soundcraft_model::FileFormat::Wav);
let cid = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Clip", src, 0, 0, clip_len));
let mut e = crate::Engine::new(s);
@@ -552,8 +552,8 @@ mod tests {
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Dialog"));
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.1; 10_000]] };
let src = crate::io::add_source(&mut s, "Line", buf, None, soundcraft_model::FileFormat::Wav);
let ids = [0, 1, 2].map(|k| {
let cid = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(cid, "Line", src, 0, k * 12_000, 10_000));
+2 -1
View File
@@ -66,7 +66,8 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use craft_audio::AudioBuffer;
use soundcraft_model::FileFormat;
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
let s = e.session();
+21 -8
View File
@@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
fn effects_set(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?;
let vals: Vec<(String, f64)> =
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
let vals: Vec<(String, f64)> = params
.iter()
.filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x)))
.map(|(k, v)| {
let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v };
(k, v)
})
.collect();
let s = e.session_mut();
for c in &ids {
for (k, v) in &vals {
@@ -316,6 +322,13 @@ mod tests {
(Engine::new(s), t, ids)
}
#[test]
fn effects_set_caps_gain() {
let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]);
e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap();
assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0));
}
#[test]
fn unloop_removes_copies() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
@@ -498,8 +511,8 @@ mod tests {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..12_000).map(|i| (i as f32 * 0.07).sin() * 0.5).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
let id = s.new_clip_id();
let mut c = Clip::audio(id, "tone", src, 0, 0, 18_000);
c.stretch = 1.5;
@@ -531,8 +544,8 @@ mod tests {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
s.edit.selected_clips = vec![id];
@@ -562,8 +575,8 @@ mod tests {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
s.edit.selected_clips = vec![id];
+23 -16
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}))
@@ -301,6 +301,7 @@ fn cut_copy(e: &mut Engine, p: &Value, cut: bool, id: &str) -> Result<Value> {
cut_track(s, *t, range, sh, follow);
}
s.edit.selection = soundcraft_time::Range::point(range.start);
cb.cut_from = Some(range.start);
}
let n: usize = cb.tracks.iter().map(Vec::len).sum();
e.clipboard = cb;
@@ -328,6 +329,7 @@ pub(super) fn clipboard_for_paste(e: &mut Engine) -> Clipboard {
cb
}
/// Pastes the clipboard at an explicit position or at the current edit insertion point.
fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.paste", p)?;
let given = position_param(e, "edit.paste", p, "at")?;
@@ -335,9 +337,10 @@ fn paste(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let at = match given {
Some(at) => at,
None if s.edit.edit_mode == EditMode::GridRelative => {
s.edit.grid.line_at_or_before(s.edit.selection.start, s.sample_rate, &s.tempo, s.frame_rate).saturating_add(cb.grid_offset)
None if s.edit.edit_mode == EditMode::GridRelative && s.edit.selection.is_empty() && cb.cut_from == Some(s.edit.selection.start) => {
s.edit.selection.start
}
None if s.edit.edit_mode == EditMode::GridRelative => s.grid_line_at_or_before(s.edit.selection.start).saturating_add(cb.grid_offset),
None => s.edit.selection.start,
};
let sh = shuffle(s) && !cb.automation_only;
@@ -376,13 +379,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut n = 0;
if range.is_empty() && !ids.is_empty() {
for t in &mut s.tracks {
if let Some(pl) = t.playlist_mut() {
let before = pl.clips.len();
pl.clips.retain(|c| !ids.contains(&c.id));
n += before - pl.clips.len();
}
}
n = ops::remove_clips(s, &ids, sh);
s.edit.selected_clips.clear();
} else {
for t in &tracks {
@@ -674,11 +671,16 @@ fn separate(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"separated": n}))
}
/// Separates selected clips at every active Grid boundary in the requested range.
fn separate_on_grid(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.separate_on_grid", p)?;
let r = range_param(e, "edit.separate_on_grid", p)?;
let s = e.session_mut();
let lines = s.edit.grid.lines(r.start, r.end, s.sample_rate, &s.tempo, s.frame_rate, 10_000);
let max = if s.edit.grid_clips_markers { usize::MAX } else { 10_001 };
let lines = s.grid_lines(r.start, r.end, max);
if lines.len() > 10_000 {
return Err(bad("edit.separate_on_grid", "the range contains more than 10,000 grid boundaries"));
}
let mut n = 0;
for t in &tracks {
for at in &lines {
@@ -962,6 +964,7 @@ fn nudge(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"moved": n, "by": step}))
}
/// Same-track placement at the existing start is a no-op; quantization is a separate command.
fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
if ids.is_empty() {
@@ -984,17 +987,21 @@ fn move_clips(e: &mut Engine, p: &Value) -> Result<Value> {
};
let mode = s.edit.edit_mode;
let delta = match first {
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative),
Some(_) if mode.is_grid() && p.get("to").is_some() && delta == 0 && dest.iter().all(|(from, to)| from == to) => 0,
Some(c) if mode.is_grid() && p.get("to").is_some() => ops::grid_delta(s, c, delta, mode == EditMode::GridRelative, &ids),
_ => delta,
};
let effective_delta = first.map_or(delta, |clip| delta.max(clip.start.saturating_neg()));
let s = e.session_mut();
let n = if mode == EditMode::Shuffle {
ops::shuffle_clips(s, &ids, delta, &dest).map_err(|why| bad("edit.move_clips", why))?
} else if effective_delta == 0 && dest.iter().all(|(from, to)| from == to) {
0
} else {
ops::move_clips(s, &ids, delta, &dest)
ops::move_clips(s, &ids, effective_delta, &dest)
};
s.edit.selected_clips = ids;
Ok(json!({"moved": n, "by": delta}))
Ok(json!({"moved": n, "by": if mode == EditMode::Shuffle { delta } else { effective_delta }}))
}
fn trim_clip(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -1221,8 +1228,8 @@ mod tests {
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![vec![0.25; 60_000]] };
let src = crate::io::add_source(&mut s, "src", buf, None, soundcraft_model::FileFormat::Wav);
let mut ids = Vec::new();
for &(start, len, off) in clips {
let id = s.new_clip_id();
+2 -2
View File
@@ -1158,8 +1158,8 @@ mod tests {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..24_000).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_model::FileFormat::Wav);
let id = s.new_clip_id();
ops::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 24_000));
s.edit.selected_tracks = vec![t];
+48 -3
View File
@@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
let r = range_param(e, id, p)?;
let s = e.session();
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
if t1 <= t0 {
return tempo(e, p);
}
let bpm = f64_or(p, "bpm", f64::NAN);
let s = e.session_mut();
if t1 <= t0 {
// An empty range sets the tempo at its start, which may come from the selection.
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
return Ok(json!({"tick": t0, "bpm": bpm}));
}
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
let after = s.tempo.tempo_at_tick(t1);
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
@@ -152,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 {
@@ -183,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) {
@@ -427,6 +432,46 @@ mod tests {
use crate::demo::demo_engine;
use serde_json::json;
#[test]
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
use soundcraft_time::Range;
let mut e = demo_engine();
e.session_mut().edit.selection = Range::point(96_000);
let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap();
let s = e.session();
let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate);
assert_eq!(r["tick"], tick);
assert_eq!(s.tempo.tempo_at_tick(tick), 90.0);
}
/// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply.
#[test]
fn insert_and_cut_time_without_start_use_selection_start() {
use soundcraft_time::Range;
let mut e = demo_engine();
let a = e.session().edit.selection.start;
let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| {
e.session_mut().edit.selection = sel;
e.execute(id, &params).unwrap()
};
// Point selection.
assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700);
// A non-empty selection must not override the given `end`/`length`.
let sel = Range::new(a, a + 100);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000);
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
// A null `start` takes the same path.
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900);
// An explicit `start` still wins over the selection.
let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000}));
assert_eq!(r["removed"], 2000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500);
}
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
#[test]
fn tempo_change_at_position_is_applied() {
+16 -2
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}",
@@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result<Valu
fn tempo_stretch(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.tempo_stretch";
let (r, t0, t1) = selection_ticks(e, p, id)?;
// Here `length` is the new length of the selection, not part of the range.
let mut range_params = p.clone();
if let Some(m) = range_params.as_object_mut() {
m.remove("length");
}
let (r, t0, t1) = selection_ticks(e, &range_params, id)?;
let factor = match position_param(e, id, p, "length")? {
Some(l) if l > 0 => l as f64 / r.len() as f64,
_ => f64_or(p, "factor", f64::NAN),
@@ -901,6 +906,15 @@ mod tests {
}
}
/// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range.
#[test]
fn tempo_stretch_length_keeps_selection_range() {
let mut e = Engine::default();
e.session_mut().edit.selection = Range::new(0, 96_000);
e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0);
}
#[test]
fn tempo_curves() {
let mut e = Engine::default();

Some files were not shown because too many files have changed in this diff Show More