Compare commits

...
114 Commits
Author SHA1 Message Date
Dylan M. Taylor a52bbdccbe Merge tested main for v0.20.1
# Conflicts:
#	Cargo.lock
#	Cargo.toml
2026-11-11 19:39:02 -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 7e58c3a617 Release: SoundCraft v0.20.0 (main c25b12ba46) 2026-11-11 23:11:56 +01:00
FredApps 89bd8d86ff Release: SoundCraft v0.20.0 2026-11-11 23:11:55 +01:00
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 487b5ebde0 Release 0.19.1: repair Flatpak update checks and self-updates (#1505)
* Release 0.19.1: fix Flatpak update permissions

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

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

Fixes #1359

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

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

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

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

---------

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

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

Fixes #1354.

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

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

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

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

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

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

---------

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

* docs(engine): add missing docstring on trim_end_to

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

---------

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

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

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

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

* test(mix): verify rendered instrument latency alignment

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 02:41:54 +00:00
Zinadin Zidan 6c917ea378 fix(ui): a MIDI editor behind another tab leaves the arrows to the Edit window (review)
The piano roll's rect stayed recorded after its tab went behind another
one, so with the pointer over the panel now in front, Left and Right
still went to the hidden piano roll and the Edit window did not scroll.
The rect is now recorded only by a frame that draws the MIDI panel.
2026-11-07 02:02:43 +06:00
Zinadin Zidan c05dfc0a8e fix(engine): Export Clips names a run of same-named clips in one pass (review)
Each clip looked for its file name from the start again, checking the
disk for every earlier name, so exporting many clips of one name took
time growing with the square of their number. The export now remembers
the next suffix to try for each name. Files already in the folder are
still skipped.
2026-11-07 02:01:24 +06:00
Zinadin Zidan 6f54446423 fix(ui): the Sub counter opens its format menu, as the Main counter does (#1374)
Clicking the Sub counter (its label, value or the new small triangle) opens
the time formats and runs view.sub_counter. The Main counter's menu moved
into format_menu(), which both counters use, and view.sub_counter leaves the
list of commands with no way in from the UI.
2026-11-07 00:27:10 +06:00
Zinadin Zidan 24451d5c42 fix(ui): Left and Right arrows centre the Edit window on the selection (#1376)
With a selection in the Edit window, plain Left scrolls the view so the
selection's start is in the middle of the timeline, and Right its end.
Without a selection the keys do nothing, as before. With the pointer over
the MIDI editor the arrows keep moving notes there.
2026-11-07 00:27:09 +06:00
Zinadin Zidan 7d508e2e44 fix(engine): Bounce Stems and Export Clips work in the web build (#1409)
Both made a folder first, which fails in the browser. Making a folder is
now a no-op on wasm32, where each file is delivered by name, and Export
Clips writes through write_file, the path bounce and export already use,
instead of std::fs. Two clips with the same name no longer get the same
file name when nothing is on disk. Native behaviour is unchanged.
2026-11-07 00:27:09 +06:00
FredApps bb0e070ab9 Merge pull request #1407 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 14:59:25 +01:00
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
Sly LaMenace dbcbcbac00 Merge upstream/main into feat/grid-nudge-time-scales 2026-11-03 11:52:00 +00: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
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
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
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 381832447c Merge remote-tracking branch 'upstream/main' into feat/grid-nudge-time-scales 2026-11-01 00:09:45 +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
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
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
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
207 changed files with 8917 additions and 6098 deletions
+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
+4
View File
@@ -27,6 +27,10 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
+4
View File
@@ -27,6 +27,10 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
+14
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,7 +19,14 @@ concurrency:
cancel-in-progress: false
jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release:
needs: credits
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
@@ -23,9 +34,11 @@ jobs:
# 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 }}
@@ -41,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 }}
+59 -5
View File
@@ -1,11 +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).
## 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 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.
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.
@@ -30,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 |
@@ -45,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
@@ -89,3 +141,5 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
- 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.
-1
View File
@@ -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 -10
View File
@@ -1,10 +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.
## 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.
@AGENTS.md
Generated
+81 -62
View File
@@ -1061,20 +1061,32 @@ dependencies = [
"libc",
]
[[package]]
name = "craft-audio"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
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=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1097,15 +1109,16 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
dependencies = [
"serde",
"thiserror 2.0.21",
]
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1126,7 +1139,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
dependencies = [
"chrono",
"dunce",
@@ -1136,6 +1149,7 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -1422,6 +1436,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"
@@ -1475,6 +1502,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"
@@ -4297,10 +4344,11 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"chrono",
"clap",
"craft-audio",
"craft-update",
"eframe",
"egui",
@@ -4310,7 +4358,6 @@ dependencies = [
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"soundcraft-audio-io",
"soundcraft-automation",
"soundcraft-engine",
"soundcraft-playback",
@@ -4321,7 +4368,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"log",
"serde",
@@ -4329,26 +4376,14 @@ dependencies = [
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-audio-io"
version = "0.17.0"
dependencies = [
"log",
"proptest",
"serde",
"serde_json",
"symphonia",
"thiserror 2.0.21",
]
[[package]]
name = "soundcraft-automation"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"clap",
"craft-audio",
"log",
"serde_json",
"soundcraft-audio-io",
"soundcraft-engine",
"soundcraft-time",
"thiserror 2.0.21",
@@ -4356,7 +4391,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4369,16 +4404,16 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.17.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",
@@ -4387,7 +4422,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"log",
"proptest",
@@ -4398,15 +4433,15 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.17.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",
@@ -4416,12 +4451,13 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"winsafe",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"proptest",
"serde",
@@ -4431,12 +4467,12 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"log",
"rayon",
"soundcraft-au-host",
"soundcraft-audio-io",
"soundcraft-clap-host",
"soundcraft-dsp",
"soundcraft-midi",
@@ -4447,12 +4483,12 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"craft-audio",
"proptest",
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-midi",
"soundcraft-time",
"thiserror 2.0.21",
@@ -4460,13 +4496,13 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"cpal",
"craft-audio",
"log",
"serde",
"serde_json",
"soundcraft-audio-io",
"soundcraft-dsp",
"soundcraft-mix",
"soundcraft-model",
@@ -4475,7 +4511,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"proptest",
"serde",
@@ -4485,18 +4521,19 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.17.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",
@@ -4514,7 +4551,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"craft-media",
"image",
@@ -4526,7 +4563,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4542,15 +4579,15 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.17.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",
@@ -6190,7 +6227,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.17.0"
version = "0.20.1"
dependencies = [
"serde_json",
]
@@ -6422,7 +6459,6 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
@@ -6437,18 +6473,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"
@@ -6510,11 +6534,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"
+15 -7
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.17.0"
version = "0.20.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -13,7 +13,6 @@ 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,23 +42,27 @@ 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 = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5" }
# Audio file formats, codecs and waveform overviews, shared with the other craft apps. It shares
# types with craft-media, so both stay on one rev.
craft-audio = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", features = ["decode", "lossless-encode", "peaks", "serde"] }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["serde"] }
egui = { version = "0.37" }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
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"
@@ -86,6 +89,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"
+4 -4
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
@@ -228,7 +228,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
@@ -244,7 +245,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 |
+1 -1
View File
@@ -20,7 +20,7 @@ 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
+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(" > "));
}
}
+12 -10
View File
@@ -14,7 +14,7 @@ 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 })?;
let source_rate = buf.sample_rate;
if let Some(rate) = rate {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
@@ -28,17 +28,17 @@ 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());
};
// A BWF source keeps its `bext` (written for WAV output only). The time reference counts
// samples, so it moves to the output rate.
let bwf = info.bwf.map(|mut b| {
let mut opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(dither);
opts.bwf = info.bwf.map(|mut b| {
b.time_reference = at_rate(b.time_reference, source_rate, buf.sample_rate);
b
});
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf };
let bytes = soundcraft_audio_io::encode(&buf, &opts).map_err(|source| CliError::Audio { path: output.clone(), source })?;
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(())
@@ -75,13 +75,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;
@@ -91,8 +93,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,
}
}
@@ -102,7 +104,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));
+2 -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,6 @@ 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 },
}
+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(())
+15 -18
View File
@@ -5,7 +5,7 @@
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, BwfInfo, 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"]);
@@ -99,18 +99,15 @@ 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 {
description: "take 3".into(),
originator: "recorder".into(),
origination_date: "2026-11-03".into(),
origination_time: "10:00:00".into(),
time_reference: 172_800_000,
..Default::default()
};
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 { format: FileFormat::Wav, bit_depth: BitDepth::Int24, dither: false, bwf: Some(bwf.clone()) };
std::fs::write(&src, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let bext = |p: &Path| soundcraft_audio_io::probe(&std::fs::read(p).unwrap(), None).unwrap().bwf;
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"]);
@@ -118,6 +115,6 @@ fn convert_keeps_the_bwf_time_stamp() {
// 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(BwfInfo { time_reference: 158_760_000, ..bwf }));
assert_eq!(bext(&out), Some(bwf.with_time_reference(158_760_000)));
let _ = std::fs::remove_dir_all(&dir);
}
+9 -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.
@@ -186,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)?;
+1 -1
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
+5 -4
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()) {
+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);
-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()))
}
}
-98
View File
@@ -1,98 +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 = pos + 12;
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 == u64::MAX => 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 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"desc" | b"pakt" | b"chan" => None,
_ => Some(size),
};
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
}
#[cfg(test)]
mod tests {
#[test]
fn rejects_cookie_size_above_signed_range() {
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
assert!(super::check(&bytes, 0).is_err());
}
}
-563
View File
@@ -1,563 +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 vendor string or comment in a (little-endian) Vorbis comment block claims more than
/// the block holds.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
if vendor as usize > end - (body + 4) {
return true;
}
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}");
}
}
}
-280
View File
@@ -1,280 +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: f64,
}
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() { f64::from(s) * f64::from(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 f64).sqrt() as f32
})
})
.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 finite_large_samples_keep_finite_rms() {
let samples = vec![1e20_f32; BLOCK];
let peaks = Peaks::build(&samples);
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
}
#[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));
}
}
-574
View File
@@ -1,574 +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 byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".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(&byte_rate.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)
}
#[cfg(test)]
mod tests {
use super::header;
#[test]
fn rejects_byte_rate_overflow() {
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
assert!(result.is_err(), "overflowing byte rate was accepted");
}
}
-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;
-821
View File
@@ -1,821 +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 aiff_negative_channel_count_is_malformed() {
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0x80;
aiff[comm + 9] = 0;
assert!(probe(&aiff, None).is_err());
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
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")));
}
}
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
#[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();
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
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
+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]
+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.
+209 -16
View File
@@ -10,6 +10,10 @@ use std::sync::atomic::Ordering;
const MAX_BLOCK: usize = 1 << 16;
/// Most channels a host strip may ask for.
const MAX_CHANNELS: usize = 64;
/// Queue slots that only releases from `note_off` may take: one per key, so a queue that
/// parameter changes, note-ons or other MIDI filled still has room to release every held key on
/// a reset (#1464).
const RELEASE_RESERVE: usize = 128;
#[derive(Debug, Clone, Copy, PartialEq)]
enum NoteMode {
@@ -79,14 +83,104 @@ impl ClapPlugin {
self.queue.retain(|e| !matches!(e, Event::Param { .. }));
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
/// Queues an event other than a note release; see [`push_event`].
fn push(&mut self, e: Event) {
if self.queue.len() >= self.queue.capacity() {
return;
push_event(&mut self.queue, e, false);
}
}
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread). Only a `release` may take the last [`RELEASE_RESERVE`] slots.
/// Returns whether the event was queued.
fn push_event(queue: &mut Vec<Event>, e: Event, release: bool) -> bool {
let room = if release { queue.capacity() } else { queue.capacity().saturating_sub(RELEASE_RESERVE) };
if queue.len() >= room {
return false;
}
let pos = queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
queue.insert(pos, e);
true
}
/// Queues the release of `key` at `time`. The key stays held when the queue has no room, so a
/// later reset still releases it (#1464).
fn queue_release(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode, time: u32, key: u8) {
if let Some(e) = release_event(notes, time, key)
&& push_event(queue, e, true)
&& let Some(h) = held.get_mut(usize::from(key))
{
*h = false;
}
}
/// The release of `key` in the note port's dialect (`None` without a note port).
fn release_event(notes: NoteMode, time: u32, key: u8) -> Option<Event> {
match notes {
NoteMode::Clap => Some(Event::NoteOff { time, key }),
NoteMode::Midi => Some(Event::Midi { time, data: [0x80, key, 0] }),
NoteMode::None => None,
}
}
/// The queue after a reset: [`keep_releases`], then a release at the start of the next block for
/// every key still `held` that has none yet, so each key is released once. Clears `held`.
///
/// It never runs out of room: events other than `note_off` releases fill at most
/// `capacity - RELEASE_RESERVE` slots, and the releases left are one per key.
fn release_on_reset(queue: &mut Vec<Event>, held: &mut [bool; 128], notes: NoteMode) {
keep_releases(queue);
for e in queue.iter() {
let key = match *e {
Event::NoteOff { key, .. } => Some(key),
// Channel 1, where `note_off` sends them.
Event::Midi { data: [status, key, velocity], .. } if status & 0x0F == 0 && is_midi_note_off(status, velocity) => Some(key),
_ => None,
};
if let Some(h) = key.and_then(|k| held.get_mut(usize::from(k))) {
*h = false;
}
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
self.queue.insert(pos, e);
}
for key in 0..128u8 {
if held.get(usize::from(key)).copied().unwrap_or(false)
&& let Some(e) = release_event(notes, 0, key)
{
let _ = push_event(queue, e, true);
}
}
*held = [false; 128];
}
/// What a reset keeps of the pending events: parameter changes, and the releases (CLAP note-offs
/// and MIDI note-offs, one per channel and key), all moved to the start of the next block. Note-ons
/// and other MIDI belong to the position being left. One release per key leaves room in the queue
/// for `all_notes_off`.
fn keep_releases(queue: &mut Vec<Event>) {
let mut clap_off = [false; 128];
let mut midi_off = [[false; 128]; 16];
queue.retain(|e| match *e {
Event::Param { .. } => true,
Event::NoteOff { key, .. } => first_release(clap_off.get_mut(usize::from(key))),
Event::Midi { data: [status, key, velocity], .. } if is_midi_note_off(status, velocity) => {
first_release(midi_off.get_mut(usize::from(status & 0x0F)).and_then(|c| c.get_mut(usize::from(key))))
}
_ => false,
});
for e in queue.iter_mut() {
e.set_time(0);
}
}
/// Marks a key's release as seen; true only the first time (false for a key out of range).
fn first_release(seen: Option<&mut bool>) -> bool {
seen.is_some_and(|s| !std::mem::replace(s, true))
}
/// A MIDI 1.0 note-off: status 0x8n, or a note-on (0x9n) with velocity 0.
fn is_midi_note_off(status: u8, velocity: u8) -> bool {
match status & 0xF0 {
0x80 => true,
0x90 => velocity == 0,
_ => false,
}
}
@@ -185,8 +279,10 @@ impl Plugin for ClapPlugin {
fn reset(&mut self) {
self.inst.reset();
self.queue.retain(|e| matches!(e, Event::Param { .. }));
self.held = [false; 128];
// Keep pending releases and release whatever is still held: CLAP asks `reset` to kill
// voices, but some plugins' doesn't (Surge XT's), and they would sound on with nothing
// left to release them (#1464).
release_on_reset(&mut self.queue, &mut self.held, self.notes);
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
@@ -287,14 +383,7 @@ impl Plugin for ClapPlugin {
return;
}
let time = u32::try_from(offset).unwrap_or(u32::MAX);
match self.notes {
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
NoteMode::None => return,
}
if let Some(h) = self.held.get_mut(note as usize) {
*h = false;
}
queue_release(&mut self.queue, &mut self.held, self.notes, time, note);
}
fn midi(&mut self, offset: usize, msg: [u8; 3]) {
@@ -383,3 +472,107 @@ impl PluginEditor for ClapEditor {
self.link.host().dirty.swap(false, Ordering::Relaxed)
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A reset keeps parameter changes and one release per key, at the start of the next block,
/// and drops note-ons and other MIDI.
#[test]
fn reset_keeps_params_and_one_release_per_key() {
let mut q = vec![
Event::Param { time: 0, id: 4, value: 0.5 },
Event::NoteOn { time: 3, key: 60, velocity: 1.0 },
Event::NoteOff { time: 5, key: 60 },
Event::NoteOff { time: 9, key: 60 },
Event::NoteOff { time: 12, key: 64 },
Event::Midi { time: 20, data: [0x90, 62, 90] },
Event::Midi { time: 21, data: [0xB0, 64, 127] },
Event::Midi { time: 22, data: [0x80, 62, 0] },
Event::Midi { time: 23, data: [0x80, 62, 40] },
Event::Midi { time: 24, data: [0x83, 62, 0] },
Event::Midi { time: 25, data: [0x91, 67, 0] },
Event::Midi { time: 26, data: [0x80, 200, 0] },
];
keep_releases(&mut q);
assert_eq!(
q,
vec![
Event::Param { time: 0, id: 4, value: 0.5 },
Event::NoteOff { time: 0, key: 60 },
Event::NoteOff { time: 0, key: 64 },
Event::Midi { time: 0, data: [0x80, 62, 0] },
Event::Midi { time: 0, data: [0x83, 62, 0] },
Event::Midi { time: 0, data: [0x91, 67, 0] },
]
);
}
/// A queue that parameter changes filled still releases every held key on a reset, once
/// each, and keeps the parameter changes.
#[test]
fn a_full_queue_still_releases_every_held_key_on_reset() {
for notes in [NoteMode::Clap, NoteMode::Midi] {
let mut q = Vec::with_capacity(EVENT_CAP);
for id in 0..EVENT_CAP as u32 {
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
}
assert_eq!(q.len(), EVENT_CAP - RELEASE_RESERVE, "parameter changes leave the reserve free");
// Keys 0..4 already have a release queued, and are held again after it.
for key in 0..4u8 {
let _ = push_event(&mut q, release_event(notes, 7, key).unwrap(), true);
}
let mut held = [true; 128];
release_on_reset(&mut q, &mut held, notes);
assert_eq!(held, [false; 128]);
assert_eq!(q.iter().filter(|e| matches!(e, Event::Param { .. })).count(), EVENT_CAP - RELEASE_RESERVE, "{notes:?}");
for key in 0..128u8 {
let want = release_event(notes, 0, key).unwrap();
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
}
assert_eq!(q.len(), EVENT_CAP);
}
}
/// A release that finds the queue full (parameter changes plus duplicate releases) leaves its
/// key held, so the reset that follows still releases it, as the mix's `all_notes_off`
/// then `reset` does on Stop.
#[test]
fn a_release_the_full_queue_drops_is_sent_by_the_next_reset() {
for notes in [NoteMode::Clap, NoteMode::Midi] {
let mut q = Vec::with_capacity(EVENT_CAP);
let mut held = [false; 128];
for id in 0..EVENT_CAP as u32 {
let _ = push_event(&mut q, Event::Param { time: 0, id, value: 0.0 }, false);
}
// Key 0 pressed and released over and over fills the reserve with its releases.
for t in 0..RELEASE_RESERVE as u32 {
held[0] = true;
queue_release(&mut q, &mut held, notes, t, 0);
}
assert_eq!(q.len(), EVENT_CAP, "{notes:?}: the queue is full");
held[5] = true;
queue_release(&mut q, &mut held, notes, 0, 5);
assert!(held[5], "{notes:?}: a release that was not queued leaves its key held");
release_on_reset(&mut q, &mut held, notes);
for key in [0u8, 5] {
let want = release_event(notes, 0, key).unwrap();
assert_eq!(q.iter().filter(|e| **e == want).count(), 1, "{notes:?}: key {key} is released once");
}
assert_eq!(held, [false; 128]);
}
}
/// A queue full of releases still has room to release every held key after a reset.
#[test]
fn reset_keeps_room_to_release_every_held_key() {
let mut q = Vec::with_capacity(EVENT_CAP);
for i in 0..EVENT_CAP {
q.push(Event::NoteOff { time: i as u32, key: (i % 4) as u8 });
}
keep_releases(&mut q);
let want: Vec<Event> = (0..4u8).map(|key| Event::NoteOff { time: 0, key }).collect();
assert_eq!(q, want, "one release per key remains");
}
}
+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");
}
+12 -5
View File
@@ -4,7 +4,7 @@
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
//! to the velocity of the held note (0 when none), switching at the event's sample offset. A
//! MIDI CC 7 (volume) sets the constant to its value / 127.
//! MIDI CC 7 (volume) sets the constant to its value / 127. `reset` leaves the note sounding.
//!
//! - `org.soundcraft.test.wide16`, `.wide17ports`, `.wide65ch`: effects advertising wide layouts
//! (16 ports of 64 channels, 17 ports of 2 channels, one port of 65 channels per direction).
@@ -102,6 +102,8 @@ 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).
@@ -130,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) });
}
@@ -161,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 {
@@ -492,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
}
@@ -638,6 +644,7 @@ 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,
+46 -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();
@@ -215,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();
}
+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);
}
}
}
+69 -11
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,25 +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);
let escapes = |rel: &str| EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}"));
if !full.starts_with(dir)
|| Path::new(rel)
.components()
.any(|c| matches!(c, std::path::Component::ParentDir | std::path::Component::RootDir | std::path::Component::Prefix(_)))
{
return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
return Err(escapes(rel));
}
let key = crate::io::canonical_path(&full)?;
if !key.starts_with(&root) {
return Err(escapes(rel));
}
let key = crate::io::file_key(&full);
let n = match index.get(&key) {
Some(n) => *n,
None => {
let bytes = crate::io::read_file(&full)?;
let (_, 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
@@ -268,9 +276,59 @@ mod tests {
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "absolute paths are refused: {r:?}");
// A link inside the folder that leads out of it is refused once resolved.
#[cfg(unix)]
{
let outside = std::env::temp_dir().join(format!("sc-grp-outside-{}.wav", std::process::id()));
std::fs::copy(dir.join("Wait... what_0.wav"), &outside).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("link.wav")).unwrap();
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!("link.wav");
}
std::fs::write(&path, file.to_string()).unwrap();
let r = e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}));
assert!(matches!(r, Err(crate::EngineError::BadParams(..))), "links out of the folder are refused: {r:?}");
let _ = std::fs::remove_file(&outside);
}
let _ = std::fs::remove_dir_all(&dir);
}
/// Under a read root, a link in the group folder to a folder outside the root is refused the same way
/// whether or not the file asked for exists there, so the group cannot be used to probe for names.
#[cfg(unix)]
#[test]
fn links_out_of_the_read_root_are_refused_alike_for_existing_and_missing_files() {
let base = std::env::temp_dir().join(format!("sc-grp-root-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
let (dir, outside) = (base.join("group"), base.join("outside"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::create_dir_all(&outside).unwrap();
let path = dir.join("drums.scgrp").to_string_lossy().to_string();
let mut e = crate::demo::demo_engine();
let kick = e.session().tracks.iter().find(|t| t.name == "Kick").map(|t| t.id).unwrap();
let ids: Vec<_> = e.session().track(kick).unwrap().clips().iter().take(1).map(|c| c.id.0).collect();
e.execute("file.export_clip_groups", &json!({"path": path, "clips": ids})).unwrap();
let mut file: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
let exported = file["audio"].as_object().unwrap().values().next().unwrap().as_str().unwrap().to_string();
std::fs::copy(dir.join(&exported), outside.join("real.wav")).unwrap();
std::os::unix::fs::symlink(&outside, dir.join("out")).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[&dir], &[]).unwrap().unwrap();
let mut refusals = Vec::new();
for target in ["out/real.wav", "out/missing.wav"] {
for rel in file["audio"].as_object_mut().unwrap().values_mut() {
*rel = json!(target);
}
std::fs::write(&path, file.to_string()).unwrap();
let r = crate::file_access::confine(Some(&roots), || {
e.execute("file.import_clip_groups", &json!({"path": path, "at": 1000, "track": "Bass"}))
});
refusals.push(r.unwrap_err().to_string());
}
assert!(refusals[0].contains("outside the read root"), "{}", refusals[0]);
assert_eq!(refusals[0].replace("real.wav", "X"), refusals[1].replace("missing.wav", "X"), "existing and missing names are refused alike");
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn imported_clips_carry_no_auto_rename_scheme() {
let dir = std::env::temp_dir().join(format!("sc-grp-names-{}", std::process::id()));
@@ -357,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});
+4 -1
View File
@@ -347,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));
+3 -2
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> {
@@ -214,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) {
+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();
+6 -6
View File
@@ -511,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;
@@ -544,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];
@@ -575,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];
+22 -9
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;
@@ -668,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 {
@@ -956,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() {
@@ -978,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> {
@@ -1215,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];
+3
View File
@@ -154,6 +154,7 @@ fn insert_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::insert_silence(s, t, r);
if let Some(tr) = s.track_mut(t) {
for l in &mut tr.automation {
@@ -185,6 +186,8 @@ fn cut_time(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let ids: Vec<_> = s.tracks.iter().map(|t| t.id).collect();
for t in ids {
crate::edit::separate_all_at(s, t, r.start);
crate::edit::separate_all_at(s, t, r.end);
crate::edit::clear_range(s, t, r, false);
crate::edit::shift_from(s, t, r.end, -r.len());
if let Some(tr) = s.track_mut(t) {
+1 -1
View File
@@ -128,7 +128,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
cmd!(
"event.beat_detective",
"Beat Detective",
"Beat Analysis",
["Event"],
None,
"{tracks?, start?, end?, sensitivity?: 0.5, separate?: false, set_tempo?: false}",
+22 -15
View File
@@ -45,6 +45,8 @@ pub fn specs() -> Vec<CommandSpec> {
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
// A template too large to open is not saved (#892).
crate::io::session_size(file.display(), text.len() as u64, true)?;
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
@@ -92,16 +94,21 @@ pub fn specs() -> Vec<CommandSpec> {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
let r = range_param(e, "file.bounce_stems", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let format = str_param(p, "format")
.and_then(soundcraft_audio_io::FileFormat::from_write_extension)
.unwrap_or(soundcraft_audio_io::FileFormat::Wav);
let format_name = match p.get("format") {
None => "wav",
Some(value) => value.as_str().ok_or_else(|| bad("file.bounce_stems", "`format` must be a string"))?,
};
let format = craft_audio::encode_format_for(format_name).ok_or_else(|| {
let supported = craft_audio::ENCODE_EXTENSIONS.join(", ");
bad("file.bounce_stems", format!("cannot write `{format_name}` files (supported: {supported})"))
})?;
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
Some("16") => craft_audio::BitDepth::Int16,
Some("32") | Some("32f") => craft_audio::BitDepth::Float32,
_ => craft_audio::BitDepth::Int24,
};
let fold = fold_down_param(p, "file.bounce_stems")?;
let files = crate::io::bounce_stems_with(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None }, fold)?;
let files = crate::io::bounce_stems_with(e, &dir, r, &craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(bool_or(p, "dither", true)), fold)?;
Ok(json!({"files": files}))
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
@@ -142,10 +149,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::save_session(&mut tmp, &path)?;
Ok(json!({"path": path}))
}),
cmd!(noundo "file.export_sibelius", "Sibelius...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
cmd!(noundo "file.export_sibelius", "MusicXML...", ["File", "Export"], None, "{path, tracks?} — MIDI tracks as MusicXML (.musicxml)", always, |e, p| {
write_score(e, p, "file.export_sibelius", false)
}),
cmd!(noundo "file.send_to_sibelius", "Sibelius", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
cmd!(noundo "file.send_to_sibelius", "MusicXML", ["File", "Send To..."], None, "{path?, tracks?} — writes MusicXML for a notation program (to the temp folder when no `path`)", always, |e, p| {
write_score(e, p, "file.send_to_sibelius", false)
}),
cmd!(noundo "file.print_score", "Print Score...", ["File"], Some("Cmd+P"), "{path?, tracks?} — engraves the MIDI tracks as a printable SVG page", always, |e, p| {
@@ -311,16 +318,16 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::FileFormat::from_write_extension(format_name).ok_or_else(|| {
let supported = soundcraft_audio_io::FileFormat::writable_extensions().join(", ");
let format = craft_audio::encode_format_for(format_name).ok_or_else(|| {
let supported = craft_audio::ENCODE_EXTENSIONS.join(", ");
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {supported})"))
})?;
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
Some("16") => soundcraft_audio_io::BitDepth::Int16,
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
_ => soundcraft_audio_io::BitDepth::Int24,
Some("16") => craft_audio::BitDepth::Int16,
Some("32") | Some("32f") => craft_audio::BitDepth::Float32,
_ => craft_audio::BitDepth::Int24,
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
let opts = craft_audio::EncodeOptions::new(format).with_bit_depth(bit_depth).with_dither(bool_or(p, "dither", true));
let normalize = bool_or(p, "normalize", false);
let fold = fold_down_param(p, "file.bounce_mix")?;
let bus = bounce_bus(e, p)?;
+69 -4
View File
@@ -1,12 +1,12 @@
use super::*;
use crate::Engine;
use soundcraft_audio_io::{AudioBuffer, EncodeOptions};
use craft_audio::{AudioBuffer, EncodeOptions};
use soundcraft_model::ChannelFormat;
fn tone_wav(dir: &std::path::Path, name: &str) -> String {
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.1).sin() * 0.2).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let bytes = craft_audio::encode(&buf, &EncodeOptions::default()).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
@@ -118,6 +118,71 @@ fn import_audio_honours_new_tracks() {
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn bounce_stems_rejects_unsupported_formats_before_writing() {
let dir = std::env::temp_dir().join(format!("sc-stems-unsupported-format-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let stems = dir.join("stems");
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "not-a-format", "start": 0, "end": 1}));
let created_output = stems.exists();
// Own this directory exclusively; remove any baseline output before the assertion can fail.
std::fs::remove_dir_all(&dir).unwrap();
let err = result.map(|_| ()).unwrap_err();
assert!(err.to_string().contains("cannot write `not-a-format`"), "{err}");
assert!(err.to_string().contains("supported: wav"), "{err}");
assert!(!created_output, "an unsupported format must not create the output directory");
}
#[test]
fn bounce_stems_rejects_non_string_formats_before_writing() {
let dir = std::env::temp_dir().join(format!("sc-stems-format-type-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
let results: Vec<_> = [json!(null), json!(123), json!(false), json!([]), json!({})]
.into_iter()
.enumerate()
.map(|(index, format)| {
let stems = dir.join(index.to_string());
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": format, "start": 0, "end": 1}));
(result, stems.exists())
})
.collect();
std::fs::remove_dir_all(&dir).unwrap();
for (result, created_output) in results {
let err = result.map(|_| ()).unwrap_err();
assert!(err.to_string().contains("`format` must be a string"), "{err}");
assert!(!created_output, "a non-string format must not create the output directory");
}
}
#[test]
fn bounce_stems_accepts_explicit_wav_format() {
let dir = std::env::temp_dir().join(format!("sc-stems-explicit-wav-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir(&dir).unwrap();
let mut e = Engine::default();
let wav = tone_wav(&dir, "tone.wav");
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stems = dir.join("stems");
let result = e.execute("file.bounce_stems", &json!({"dir": stems, "format": "wav", "start": 0, "end": 32, "dither": false})).unwrap();
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 1);
let path = std::path::Path::new(files[0].as_str().unwrap());
assert_eq!(path.parent(), Some(stems.as_path()));
assert_eq!(path.extension().and_then(|extension| extension.to_str()), Some("wav"));
let (info, audio) = craft_audio::decode(&std::fs::read(path).unwrap(), None).unwrap();
assert_eq!(info.container, craft_audio::Container::Wav);
assert_eq!(audio.frames(), 32);
assert_eq!(audio.channels.len(), e.session().main_format().channels());
assert!(audio.channels.iter().flatten().any(|sample| sample.abs() > 0.01));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn bounce_refuses_formats_it_cannot_write() {
let mut e = Engine::default();
@@ -224,7 +289,7 @@ fn tone_engine() -> (Engine, u64) {
let track = e.session().tracks[0].id;
let tone: Vec<f32> = (0..4_800).map(|i| (i as f32 * 0.05).sin() * 0.5).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let src = crate::io::add_source(e.session_mut(), "tone", buf, None, soundcraft_model::FileFormat::Wav);
let id = e.session_mut().new_clip_id();
crate::edit::place_clip(e.session_mut(), track, soundcraft_model::Clip::audio(id, "tone", src, 0, 0, 4_800));
e.session_mut().edit.selected_tracks = vec![track];
@@ -327,7 +392,7 @@ fn midi_export_keeps_controllers_and_honours_tracks() {
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let bytes = craft_audio::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
+1 -1
View File
@@ -96,7 +96,7 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use craft_audio::AudioBuffer;
use soundcraft_model::{Clip, ClipId, SourceAudio, SourceId};
use std::sync::Arc;
+20 -2
View File
@@ -7,6 +7,7 @@ use crate::{Engine, EngineError, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
/// Commands for mixer controls, with live input toggles kept outside document history.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!("mix.volume", "Set Volume", [], None, "{tracks?, db: -144..12 | delta_db}", has_selection, volume),
@@ -42,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({}))
}),
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
cmd!(noundo "mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
e,
p,
"mix.record_arm",
@@ -51,7 +52,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
|tr| tr.mixer.record_arm
)),
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
cmd!(noundo "mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
e,
p,
"mix.input_monitor",
@@ -218,6 +219,23 @@ fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, b
Ok(json!({"value": v}))
}
/// Toggle live input flags without marking the saved document as changed.
fn toggle_runtime(
e: &mut Engine,
p: &Value,
cmd: &str,
set: fn(&mut Session, TrackId, bool),
get: fn(&soundcraft_model::Track) -> bool,
) -> Result<Value> {
let tracks = tracks_required(e, cmd, p)?;
let s = e.runtime_session_mut();
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
for t in &tracks {
set(s, *t, v);
}
Ok(json!({"value": v}))
}
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "mix.volume", p)?;
let abs = p.get("db").and_then(Value::as_f64);
+107 -1
View File
@@ -1,5 +1,5 @@
use super::*;
use soundcraft_audio_io::AudioBuffer;
use craft_audio::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
@@ -378,3 +378,109 @@ fn clip_gain_to_volume_with_a_large_envelope_stays_fast() {
let lane = e.session().track(t).unwrap().lane(&AutoParam::Volume).unwrap();
assert!(lane.points.len() as i64 >= n);
}
/// Live input toggles stay outside document history in both enabled and disabled states.
#[test]
fn live_input_states_are_transient_and_survive_document_history() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let track = e.session().tracks[0].id;
e.mark_clean();
let history = e.undo_history();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(!e.is_dirty(), "live input state is not a document change");
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
// resurrect the live state that happened to be present when that snapshot was taken.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
assert!(e.is_dirty());
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(!e.is_dirty(), "undo returned to the saved document");
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.is_dirty());
// Live states that are enabled after another document edit survive both directions too.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
}
/// Returning to a saved point stays clean with independently enabled live flags on each track.
#[test]
fn live_input_states_keep_saved_history_points_clean() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
e.mark_clean();
e.execute("mix.volume", &json!({"tracks": [a.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
assert!(e.undo());
assert!(!e.is_dirty(), "live flags cannot dirty the saved point reached by Undo");
for redo in [true, false, true] {
assert!(if redo { e.redo() } else { e.undo() });
let a = &e.session().track(a).unwrap().mixer;
let b = &e.session().track(b).unwrap().mixer;
assert!(a.record_arm && !a.input_monitor);
assert!(!b.record_arm && b.input_monitor);
assert_eq!(e.is_dirty(), redo);
}
e.mark_clean();
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": false})).unwrap();
assert!(!e.is_dirty());
assert!(e.undo());
assert!(e.is_dirty());
assert!(e.redo());
assert!(!e.is_dirty(), "Redo to a saved armed snapshot stays clean after disarming");
}
/// Restoring a missing track never resurrects its historical arm or monitoring flags.
#[test]
fn live_input_states_of_restored_tracks_start_disabled() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 2})).unwrap();
let (a, b) = (e.session().tracks[0].id, e.session().tracks[1].id);
e.execute("mix.record_arm", &json!({"tracks": [a.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [b.0], "value": true})).unwrap();
e.execute("track.delete", &json!({"tracks": [a.0]})).unwrap();
assert!(e.undo());
let mixer = &e.session().track(a).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.session().track(b).unwrap().mixer.input_monitor);
assert!(e.redo());
assert!(e.undo());
assert!(!e.session().track(a).unwrap().mixer.record_arm);
e.execute("track.new", &json!({})).unwrap();
let c = e.session().tracks.last().unwrap().id;
e.execute("mix.record_arm", &json!({"tracks": [c.0], "value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [c.0], "value": true})).unwrap();
assert!(e.undo());
assert!(e.session().track(c).is_none());
assert!(e.redo());
let mixer = &e.session().track(c).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.session().track(b).unwrap().mixer.input_monitor);
}
+3 -2
View File
@@ -3,8 +3,9 @@
use super::bad;
use crate::{Engine, Result};
use craft_audio::AudioBuffer;
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::FileFormat;
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
use soundcraft_time::Samples;
@@ -227,7 +228,7 @@ mod tests {
use soundcraft_model::{ChannelFormat, TrackKind};
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Long"));
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5]; 2] };
let src = crate::io::add_source(&mut s, "One", buf, None, FileFormat::Wav);
let cid = s.new_clip_id();
let mut clip = Clip::audio(cid, "Clip", src, 0, 0, length);
+1 -1
View File
@@ -19,7 +19,7 @@ macro_rules! toggle {
pub fn specs() -> Vec<CommandSpec> {
vec![
toggle!("options.loop_record", "Loop Record", ["Options"], Some("Alt+L"), loop_record),
toggle!("options.quickpunch", "QuickPunch", ["Options"], Some("Cmd+Shift+P"), quickpunch),
toggle!("options.quickpunch", "Punch Recording", ["Options"], Some("Cmd+Shift+P"), quickpunch),
toggle!("options.pre_post_roll", "Pre/Post-Roll", ["Options"], Some("Cmd+K"), pre_post_roll),
toggle!("options.loop_playback", "Loop Playback", ["Options"], Some("Cmd+Shift+L"), loop_playback),
toggle!("options.link_timeline_edit", "Link Timeline and Edit Selection", ["Options"], Some("Shift+/"), link_timeline_edit),
+12 -12
View File
@@ -70,15 +70,15 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
}
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let (file, buf) = craft_audio::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
let buf = crate::io::at_rate(buf, e.session().sample_rate.hz(), MAX_SAMPLE_VALUES)
.ok_or_else(|| EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")))?;
.map_err(|r| EngineError::Io(format!("{name}: {}", r.message("too long for a sample (ten minutes of 192 kHz stereo at most)"))))?;
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, name, buf, path, format.format);
let src = crate::io::add_source(s, name, buf, path, soundcraft_model::FileFormat::of(&file));
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
@@ -110,8 +110,8 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
use crate::Engine;
use craft_audio::{AudioBuffer, BitDepth, EncodeFormat, EncodeOptions};
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, TrackId};
use soundcraft_time::{Range, TICKS_PER_QUARTER};
@@ -130,8 +130,8 @@ mod tests {
let n = (rate as f32 * secs) as usize;
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
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 instrument_track(e: &mut Engine) -> TrackId {
@@ -232,11 +232,11 @@ mod tests {
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
let mut e = Engine::default();
let t = instrument_track(&mut e);
for format in [FileFormat::Flac, FileFormat::Aiff] {
let ext = format.default_extension();
for format in [EncodeFormat::Flac, EncodeFormat::Aiff] {
let ext = format.extension();
let path = d.join(format!("tone.{ext}"));
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let opts = EncodeOptions::new(format);
std::fs::write(&path, craft_audio::encode(&buf, &opts).unwrap()).unwrap();
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["channels"], 2, "{ext}");
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
@@ -320,8 +320,8 @@ mod tests {
let junk = d.join("junk.wav");
std::fs::write(&junk, b"definitely not audio").unwrap();
let empty = d.join("empty.wav");
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
let opts = EncodeOptions::new(craft_audio::EncodeFormat::Wav).with_bit_depth(BitDepth::Int16);
std::fs::write(&empty, craft_audio::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
let wav = d.join("ok.wav");
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
let mut e = Engine::default();
+3 -3
View File
@@ -472,7 +472,7 @@ fn midi_inputs(e: &mut Engine, p: &Value) -> Result<Value> {
fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "file.import_session_data";
let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(id, "`path` required"))?.to_string();
let text_v = crate::io::read_text(&path)?;
let text_v = crate::io::read_session_text(&path)?;
let other = Session::from_json(&text_v).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let names: Option<Vec<String>> =
p.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect());
@@ -761,8 +761,8 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("hit.wav");
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let buf = craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, craft_audio::encode(&buf, &craft_audio::EncodeOptions::default()).unwrap()).unwrap();
let mut donor = Engine::default();
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
+5 -5
View File
@@ -145,7 +145,7 @@ fn main_format(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::{Clip, SourceAudio, SourceId};
use std::sync::Arc;
@@ -211,13 +211,13 @@ mod tests {
let ps = path.to_string_lossy().to_string();
e.execute("file.bounce_mix", &json!({"path": ps, "start": 0, "end": 4800, "bit_depth": "32f"})).unwrap();
let bytes = std::fs::read(&path).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), Some(0x63F));
let (_, buf) = soundcraft_audio_io::decode(&bytes, Some("wav")).unwrap();
assert_eq!(craft_audio::probe(&bytes, None).unwrap().layout.mask(), Some(0x63F));
let (_, buf) = craft_audio::decode(&bytes, Some("wav")).unwrap();
assert_eq!(buf.channels.len(), 8);
assert!((buf.channels[2][1000] - 0.25).abs() < 1e-5, "front centre lands on C");
// ITU stereo fold-down.
e.execute("file.bounce_mix", &json!({"path": ps, "start": 0, "end": 4800, "bit_depth": "32f", "fold_down": "stereo"})).unwrap();
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&path).unwrap(), Some("wav")).unwrap();
let (_, buf) = craft_audio::decode(&std::fs::read(&path).unwrap(), Some("wav")).unwrap();
assert_eq!(buf.channels.len(), 2);
assert!((buf.channels[0][1000] - 0.25 * std::f32::consts::FRAC_1_SQRT_2).abs() < 1e-4);
assert!(e.execute("file.bounce_mix", &json!({"path": ps, "fold_down": "quad"})).is_err());
@@ -225,7 +225,7 @@ mod tests {
let sdir = dir.join("stems");
let r = e.execute("file.bounce_stems", &json!({"dir": sdir.to_string_lossy(), "start": 0, "end": 4800})).unwrap();
let f = r["files"][0].as_str().unwrap().to_string();
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&f).unwrap(), Some("wav")).unwrap();
let (_, buf) = craft_audio::decode(&std::fs::read(&f).unwrap(), Some("wav")).unwrap();
assert_eq!(buf.channels.len(), 8);
let _ = std::fs::remove_dir_all(&dir);
}
+2 -1
View File
@@ -175,9 +175,10 @@ fn analysed(s: &Session, t: TrackId, around: Range) -> Vec<Samples> {
#[cfg(test)]
mod tests {
use crate::Engine;
use craft_audio::AudioBuffer;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::FileFormat;
use soundcraft_model::{ChannelFormat, Clip, Session, TrackId, TrackKind};
use soundcraft_time::{SampleRate, TICKS_PER_QUARTER};
+4 -4
View File
@@ -100,8 +100,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.session_mut();
cmd!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.runtime_session_mut();
let mut n = 0;
for t in &mut s.tracks {
if t.mixer.record_arm {
@@ -483,11 +483,11 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
.ok_or_else(|| bad("track.split_into_mono", "split would copy too much audio"))?;
// Keep the full channel: the cloned clips retain their offsets, stretch,
// gain and fades, and every reader sees genuine mono media (including saves).
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
let buf = craft_audio::AudioBuffer { sample_rate: s.sample_rate.hz(), channels: vec![samples.clone()] };
let original = s.source(*source);
let name = format!("{}.{suffix}.wav", original.map_or(c.name.as_str(), |source| source.name.as_str()));
let time_reference = original.map_or(0, |source| source.time_reference);
let id = crate::io::add_source(s, &name, buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = crate::io::add_source(s, &name, buf, None, soundcraft_model::FileFormat::Wav);
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
source.time_reference = time_reference;
}
+11 -4
View File
@@ -267,6 +267,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"path": path, "track": first}))
}
/// Load persistent mixer settings while retaining destination routing and live input flags.
fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "track.load_preset";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
@@ -283,15 +284,17 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
// Routing is session-specific: keep the track's own input/output, VCA and arm state.
let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone());
// Routing and live input state are track-specific: a preset must not replace them.
let keep =
(tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.input_monitor, tr.mixer.pan.clone());
tr.mixer = mixer.clone();
tr.mixer.input = keep.0;
tr.mixer.output = keep.1;
tr.mixer.vca = keep.2;
tr.mixer.record_arm = keep.3;
if tr.mixer.pan.len() != keep.4.len() {
tr.mixer.pan = keep.4;
tr.mixer.input_monitor = keep.4;
if tr.mixer.pan.len() != keep.5.len() {
tr.mixer.pan = keep.5;
}
if tr.kind == TrackKind::Instrument && instrument.is_some() {
tr.instrument = instrument.clone();
@@ -602,6 +605,7 @@ mod tests {
assert_eq!(e.session().track(pad).unwrap().folder, Some(verb));
}
/// A preset transfers persistent mix settings without replacing live destination states.
#[test]
fn preset_round_trip() {
let dir = std::env::temp_dir().join(format!("sc-preset-{}", std::process::id()));
@@ -611,10 +615,13 @@ mod tests {
e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap();
e.execute("track.save_preset", &json!({"path": path})).unwrap();
e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap();
e.execute("mix.record_arm", &json!({"value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"value": true})).unwrap();
e.execute("track.load_preset", &json!({"path": path})).unwrap();
let hats = e.session().track_by_name("Hats").unwrap();
assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor"));
assert_eq!(hats.mixer.volume_db, -5.0);
assert!(hats.mixer.record_arm && hats.mixer.input_monitor, "loading a preset keeps live input state");
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -135,7 +135,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap();
let out = dir.join("mix.wav");
e.execute("file.bounce_mix", &json!({"path": out.to_string_lossy(), "start": 0, "end": 96_000, "dither": false})).unwrap();
let (_, buf) = soundcraft_audio_io::decode(&std::fs::read(&out).unwrap(), Some("wav")).unwrap();
let (_, buf) = craft_audio::decode(&std::fs::read(&out).unwrap(), Some("wav")).unwrap();
assert!(buf.channels.iter().flatten().all(|x| *x == 0.0));
let _ = std::fs::remove_dir_all(dir);
}
+166 -13
View File
@@ -3,9 +3,9 @@ use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_time::TimeFormat;
use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
use soundcraft_time::{FeetFramesValue, GridScale, GridValue, MAX_POSITION, NoteValue, TimeFormat, TimecodeValue};
/// Returns commands that control saved view, counter, Grid, and Nudge state.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
@@ -65,7 +65,18 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
let f = str_param(p, "format").and_then(TimeFormat::from_id).ok_or_else(|| bad("view.main_counter", "unknown format"))?;
e.session_mut().edit.main_counter = f;
let edit = &mut e.session_mut().edit;
edit.main_counter = f;
let scale = grid_scale(f);
if edit.grid_follow_main && edit.grid.scale() != scale {
edit.grid = GridValue::for_scale(scale);
}
if edit.grid_follow_main {
edit.grid_clips_markers = false;
}
if edit.nudge_follow_main && edit.nudge.scale() != scale {
edit.nudge = GridValue::for_scale(scale);
}
Ok(json!({"format": f}))
}),
cmd!(noundo "view.sub_counter", "Sub Counter", [], None, "{format?: … | null}", always, |e, p| {
@@ -73,23 +84,58 @@ pub fn specs() -> Vec<CommandSpec> {
e.session_mut().edit.sub_counter = f;
Ok(json!({"format": f}))
}),
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value: '1/16' | '1 bar' | '1/8t' | '1/4.' | {seconds} | {frames} | {samples}, or step: ±n values larger or smaller; lines?: bool}", always, |e, p| {
let g = value_or_step(p, e.session().edit.grid).ok_or_else(|| bad("view.grid", "unknown grid value"))?;
cmd!(noundo "view.grid", "Grid Value", [], Some("Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples|clips_markers, follow_main?: bool, lines?: bool}", always, |e, p| {
let current = e.session().edit.grid;
let requested_scale = str_param(p, "format").and_then(GridScale::from_id);
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.grid", "unknown grid value or format"))?;
let lines = p.get("lines").and_then(Value::as_bool);
let follow = p.get("follow_main").and_then(Value::as_bool);
if follow == Some(true) {
let scale = grid_scale(e.session().edit.main_counter);
if g.scale() != scale {
g = GridValue::for_scale(scale);
}
}
let s = e.session_mut();
s.edit.grid = g;
if p.get("value").is_some() || p.get("step").is_some() {
s.edit.grid_clips_markers = false;
}
if let Some(scale) = requested_scale {
s.edit.grid_clips_markers = scale == GridScale::ClipsMarkers;
}
if follow == Some(true) {
s.edit.grid_clips_markers = false;
}
s.edit.grid_follow_main = follow.unwrap_or(
s.edit.grid_follow_main && !s.edit.grid_clips_markers && g.scale() == grid_scale(s.edit.main_counter),
);
if let Some(l) = lines { s.edit.grid_lines = l }
Ok(json!({"grid": g.label()}))
let format = if s.edit.grid_clips_markers { GridScale::ClipsMarkers } else { g.scale() };
Ok(json!({"grid": g.label(), "format": format.id(), "follow_main": s.edit.grid_follow_main}))
}),
cmd!(noundo "view.grid_lines", "Show Grid Lines", [], None, "{value?: bool}", always, |e, p| {
let s = e.session_mut();
s.edit.grid_lines = p.get("value").and_then(Value::as_bool).unwrap_or(!s.edit.grid_lines);
Ok(json!({"value": s.edit.grid_lines}))
}),
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value or step: like grid}", always, |e, p| {
let g = value_or_step(p, e.session().edit.nudge).ok_or_else(|| bad("view.nudge", "unknown nudge value"))?;
e.session_mut().edit.nudge = g;
Ok(json!({"nudge": g.label()}))
cmd!(noundo "view.nudge", "Nudge Value", [], Some("Alt+Shift+=/-"), "{value?, step?, format?: bars_beats|min_secs|timecode|feet_frames|samples, follow_main?: bool}", always, |e, p| {
let current = e.session().edit.nudge;
if str_param(p, "format").and_then(GridScale::from_id) == Some(GridScale::ClipsMarkers) {
return Err(bad("view.nudge", "Clips/Markers is available only for Grid"));
}
let mut g = requested_grid_value(p, current).ok_or_else(|| bad("view.nudge", "unknown nudge value or format"))?;
let follow = p.get("follow_main").and_then(Value::as_bool);
if follow == Some(true) {
let scale = grid_scale(e.session().edit.main_counter);
if g.scale() != scale {
g = GridValue::for_scale(scale);
}
}
let edit = &mut e.session_mut().edit;
edit.nudge = g;
edit.nudge_follow_main = follow.unwrap_or(edit.nudge_follow_main && g.scale() == grid_scale(edit.main_counter));
Ok(json!({"nudge": g.label(), "format": g.scale().id(), "follow_main": edit.nudge_follow_main}))
}),
]
}
@@ -137,7 +183,33 @@ fn value_or_step(p: &Value, current: GridValue) -> Option<GridValue> {
Some((0..step.unsigned_abs().min(16)).fold(current, |g, _| g.stepped(step > 0)))
}
/// Parse grid/nudge values.
/// Maps a counter format to the corresponding Grid/Nudge scale.
fn grid_scale(format: TimeFormat) -> GridScale {
match format {
TimeFormat::BarsBeats => GridScale::BarsBeats,
TimeFormat::MinSecs => GridScale::MinSecs,
TimeFormat::Timecode => GridScale::Timecode,
TimeFormat::FeetFrames => GridScale::FeetFrames,
TimeFormat::Samples => GridScale::Samples,
}
}
/// Resolves an explicit value, a step, or a scale change while preserving the current increment.
fn requested_grid_value(p: &Value, current: GridValue) -> Option<GridValue> {
if p.get("value").is_some() || p.get("step").is_some() {
return value_or_step(p, current);
}
let scale = str_param(p, "format").and_then(GridScale::from_id);
match scale {
Some(GridScale::ClipsMarkers) => Some(current),
Some(scale) if current.scale() == scale => Some(current),
Some(scale) => Some(GridValue::for_scale(scale)),
None if p.get("follow_main").is_some() || p.get("lines").is_some() => Some(current),
None => None,
}
}
/// Parse grid/nudge values; legacy `1` and `1/1` mean a bar, while `whole` means four quarter notes.
pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
let v = p.get(key)?;
if let Some(sec) = v.get("seconds").and_then(Value::as_f64) {
@@ -146,15 +218,23 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
if let Some(f) = v.get("frames").and_then(Value::as_i64) {
return Some(GridValue::Frames(f.clamp(1, 100_000)));
}
if let Some(value) = v.get("timecode").and_then(Value::as_str).and_then(TimecodeValue::from_id) {
return Some(GridValue::Timecode(value));
}
if let Some(value) = v.get("feet_frames").and_then(Value::as_str).and_then(FeetFramesValue::from_id) {
return Some(GridValue::FeetFrames(value));
}
if let Some(n) = v.get("samples").and_then(Value::as_i64) {
return Some(GridValue::Samples(n.clamp(1, i64::from(u32::MAX))));
}
let s = v.as_str()?.trim().to_ascii_lowercase();
let triplet = s.ends_with('t') || s.contains("triplet");
let dotted = s.ends_with('.') || s.contains("dotted");
let core: String = s.chars().take_while(|c| c.is_ascii_digit() || *c == '/' || *c == ' ' || c.is_ascii_alphabetic() && *c != 't').collect();
let core = s.replace("triplet", "").replace("dotted", "");
let core = core.trim().trim_end_matches(['t', '.']);
let value = match core.trim() {
"1 bar" | "bar" | "1/1" | "1" => NoteValue::Bar,
"1" | "1/1" | "1 bar" | "bar" => NoteValue::Bar,
"whole" | "whole note" => NoteValue::Whole,
"1/2" => NoteValue::Half,
"1/4" => NoteValue::Quarter,
"1/8" => NoteValue::Eighth,
@@ -170,15 +250,59 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
mod tests {
use super::*;
use soundcraft_time::Samples;
/// Grid values accept compact and word-form musical modifiers plus every absolute scale.
#[test]
fn grid_values_parse() {
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": "1/8t"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
assert_eq!(grid_param(&json!({"v": "1/8 triplet"}), "v"), Some(GridValue::Note { value: NoteValue::Eighth, dotted: false, triplet: true }));
assert_eq!(grid_param(&json!({"v": "1/4."}), "v"), Some(GridValue::Note { value: NoteValue::Quarter, dotted: true, triplet: false }));
for alias in ["1", "1/1"] {
assert_eq!(grid_param(&json!({"v": alias}), "v"), Some(GridValue::Note { value: NoteValue::Bar, dotted: false, triplet: false }));
}
assert_eq!(grid_param(&json!({"v": "whole"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": "whole note"}), "v"), Some(GridValue::Note { value: NoteValue::Whole, dotted: false, triplet: false }));
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": {"timecode": "quarter_frame"}}), "v"), Some(GridValue::Timecode(TimecodeValue::QuarterFrame)));
assert_eq!(grid_param(&json!({"v": {"feet_frames": "foot"}}), "v"), Some(GridValue::FeetFrames(FeetFramesValue::Foot)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
/// Explicit values and formats retain Follow Main only when the selected scale still matches.
#[test]
fn explicit_grid_and_nudge_scales_keep_follow_state_consistent() {
for command in ["view.grid", "view.nudge"] {
let mut e = Engine::default();
e.execute("view.main_counter", &json!({"format": "min_secs"})).unwrap();
e.execute(command, &json!({"follow_main": true})).unwrap();
let result = e.execute(command, &json!({"value": {"seconds": 0.1}})).unwrap();
assert_eq!(result["follow_main"], true);
let result = e.execute(command, &json!({"value": {"feet_frames": "foot"}})).unwrap();
assert_eq!(result["follow_main"], false);
e.execute(command, &json!({"follow_main": true})).unwrap();
let result = e.execute(command, &json!({"format": "bars_beats"})).unwrap();
assert_eq!(result["follow_main"], false);
}
let mut e = Engine::default();
e.execute("view.grid", &json!({"format": "clips_markers"})).unwrap();
assert!(!e.session().edit.grid_follow_main);
e.execute("view.grid", &json!({"value": {"samples": 100}})).unwrap();
assert!(!e.session().edit.grid_clips_markers);
assert_eq!(e.session().edit.grid, GridValue::Samples(100));
}
/// Historical bar aliases retain meter-dependent duration; the explicit whole note remains distinct.
#[test]
fn legacy_bar_aliases_and_whole_notes_remain_distinct_in_odd_meter() {
let mut e = Engine::default();
e.session_mut().tempo = soundcraft_time::TempoMap::new(120.0, 3, 4).unwrap();
for (value, expected) in [("1", 72_000), ("1/1", 72_000), ("whole", 96_000)] {
e.execute("view.grid", &json!({"value": value, "follow_main": false})).unwrap();
let s = e.session();
assert_eq!(s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate), expected);
}
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
@@ -241,6 +365,7 @@ mod tests {
assert_eq!(state(&e), before);
}
/// Grid and Nudge steps drive edit movement while preserving their selected increments.
#[test]
fn grid_and_nudge_values_step_and_nudges_can_use_the_next_value() {
let mut e = Engine::default();
@@ -262,4 +387,32 @@ mod tests {
assert_eq!(e.session().edit.selection.start, 6_000);
assert_eq!(e.session().edit.nudge.label(), "1/16 note");
}
/// Grid and Nudge expose independent scale-following state, while Clips/Markers remains Grid-only.
#[test]
fn grid_and_nudge_choose_scales_follow_the_main_counter_and_keep_clips_markers_grid_only() {
let mut e = Engine::default();
e.execute("view.grid", &json!({"format": "timecode", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::Frame));
e.execute("view.grid", &json!({"value": {"timecode": "quarter_frame"}})).unwrap();
e.execute("view.grid", &json!({"format": "clips_markers", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::Timecode(TimecodeValue::QuarterFrame));
assert!(e.session().edit.grid_clips_markers);
assert!(e.execute("view.nudge", &json!({"format": "clips_markers"})).is_err());
e.execute("view.grid", &json!({"follow_main": true})).unwrap();
e.execute("view.nudge", &json!({"follow_main": true})).unwrap();
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Seconds(1.0), GridValue::Seconds(1.0)));
assert!(!e.session().edit.grid_clips_markers);
e.execute("view.main_counter", &json!({"format": "samples"})).unwrap();
assert_eq!((e.session().edit.grid, e.session().edit.nudge), (GridValue::Samples(100), GridValue::Samples(100)));
assert!(e.session().edit.grid_follow_main && e.session().edit.nudge_follow_main);
e.execute("view.grid", &json!({"format": "bars_beats", "follow_main": false})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::default());
assert!(!e.session().edit.grid_follow_main);
e.execute("view.main_counter", &json!({"format": "timecode"})).unwrap();
assert_eq!(e.session().edit.grid, GridValue::default());
assert_eq!(e.session().edit.nudge, GridValue::Timecode(TimecodeValue::Frame));
}
}
+1 -1
View File
@@ -184,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
flag!("view.transport_counters", "Counters", ["View", "Transport"], "view.transport.counters"),
flag!("view.transport_midi_controls", "MIDI Controls", ["View", "Transport"], "view.transport.midi_controls"),
flag!("view.transport_synchronization", "Synchronization", ["View", "Transport"], "view.transport.synchronization"),
flag!("view.transport_ableton_link", "Ableton Link", ["View", "Transport"], "view.transport.ableton_link"),
flag!("view.transport_ableton_link", "Tempo Sync", ["View", "Transport"], "view.transport.ableton_link"),
flag!("view.transport_output_meters", "Output Meters", ["View", "Transport"], "view.transport.output_meters"),
flag!("view.transport_expanded", "Expanded", ["View", "Transport"], "view.transport.expanded"),
cmd!(query "view.flags", "View Flags", [], None, "{prefix?} — lists the view/option flags that are on", always, |e, p| {
+2 -1
View File
@@ -2,8 +2,9 @@
//! a 24-bar groove at 104 BPM with drums, bass, keys, pad, lead, a reverb aux and a master.
use crate::Engine;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use craft_audio::AudioBuffer;
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::FileFormat;
use soundcraft_model::{ChannelFormat, Clip, Fade, FadeShape, Insert, MarkerKind, Route, SendSlot, Session, TrackKind};
use soundcraft_time::{SampleRate, TICKS_PER_QUARTER};
+128 -8
View File
@@ -1,12 +1,15 @@
//! Borrowed document equality for the saved-state indicator.
use soundcraft_model::{EditState, Session};
use soundcraft_model::{EditState, Mixer, Session, Track};
/// Equal apart from the selection, playhead and zoom, without copying document data.
/// Equal apart from navigation and live input flags, without copying document data.
pub(super) fn same_document(saved: &Session, doc: &Session) -> bool {
document_values(saved, &saved.edit) == document_values(doc, &saved.edit)
&& saved.tracks.len() == doc.tracks.len()
&& saved.tracks.iter().zip(&doc.tracks).all(|(saved, doc)| track_values(saved) == track_values(doc))
}
/// Borrow persistent session fields; track fields are compared separately.
fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl PartialEq + 'a {
// No `..`: adding a document field must also decide how it affects saved-state equality.
let Session {
@@ -18,7 +21,7 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
timecode_start,
tempo,
key_signatures,
tracks,
tracks: _,
sources,
videos,
busses,
@@ -35,11 +38,66 @@ fn document_values<'a>(s: &'a Session, navigation: &'a EditState) -> impl Partia
} = s;
// Tuples implement PartialEq up to twelve elements. These contain only borrowed fields.
(
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, tracks, sources, videos, busses),
(format_version, name, sample_rate, bit_depth, frame_rate, timecode_start, tempo, key_signatures, sources, videos, busses),
(outputs, markers, groups, auto_names, edit_values(edit, navigation), next_id, comments, midi_originals, pool, folder),
)
}
/// Borrow persistent track fields without allocating or copying media.
fn track_values(track: &Track) -> impl PartialEq + '_ {
let Track {
id,
name,
kind,
format,
color,
height,
playlists,
active_playlist,
mixer,
automation,
view,
hidden,
inactive,
frozen,
comments,
folder,
folder_open,
instrument,
ticks_timebase,
elastic,
pinned,
} = track;
(
(id, name, kind, format, color, height, playlists, active_playlist, mixer_values(mixer), automation, view),
(hidden, inactive, frozen, comments, folder, folder_open, instrument, ticks_timebase, elastic, pinned),
)
}
/// Borrow persistent mixer fields without allocating or copying media.
fn mixer_values(mixer: &Mixer) -> impl PartialEq + '_ {
let Mixer {
volume_db,
pan,
mute,
solo,
solo_safe,
record_arm: _,
input_monitor: _,
phase_invert,
input,
output,
inserts,
sends,
automation_mode,
trim_db,
vca,
surround,
} = mixer;
((volume_db, pan, mute, solo, solo_safe, phase_invert, input, output, inserts, sends, automation_mode), (trim_db, vca, surround))
}
/// Borrow edit settings while normalizing transient navigation to the saved values.
fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl PartialEq + 'a {
let EditState {
tool,
@@ -52,6 +110,9 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
grid,
nudge,
grid_lines,
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
main_counter,
sub_counter,
zoom: _,
@@ -130,7 +191,17 @@ fn edit_values<'a>(edit: &'a EditState, navigation: &'a EditState) -> impl Parti
scrolling,
solo_mode,
),
(pre_fader_metering, delay_compensation, keyboard_focus, &navigation.timeline_selection, flags, values),
(
grid_follow_main,
nudge_follow_main,
grid_clips_markers,
pre_fader_metering,
delay_compensation,
keyboard_focus,
&navigation.timeline_selection,
flags,
values,
),
)
}
@@ -141,7 +212,7 @@ mod tests {
use soundcraft_time::{Range, TimeFormat};
use std::sync::Arc;
// The previous comparison is an oracle for preserving the saved-state policy.
/// The clone-based policy, extended to ignore live input flags, serves as a test oracle.
fn previous_same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
@@ -151,9 +222,14 @@ mod tests {
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
for (saved, doc) in saved.tracks.iter().zip(&mut doc.tracks) {
doc.mixer.record_arm = saved.mixer.record_arm;
doc.mixer.input_monitor = saved.mixer.input_monitor;
}
*saved == doc
}
/// Check both directions and compare against the normalized clone oracle.
fn check(saved: &Session, doc: &Session, expected: bool, field: &str) {
assert_eq!(same_document(saved, doc), expected, "{field}");
assert_eq!(same_document(doc, saved), expected, "{field}, reversed");
@@ -186,6 +262,9 @@ mod tests {
("grid", |e| e.grid = soundcraft_time::GridValue::Seconds(2.0)),
("nudge", |e| e.nudge = soundcraft_time::GridValue::Seconds(2.0)),
("grid_lines", |e| e.grid_lines = !e.grid_lines),
("grid_follow_main", |e| e.grid_follow_main = !e.grid_follow_main),
("nudge_follow_main", |e| e.nudge_follow_main = !e.nudge_follow_main),
("grid_clips_markers", |e| e.grid_clips_markers = !e.grid_clips_markers),
("main_counter", |e| e.main_counter = TimeFormat::Samples),
("sub_counter", |e| e.sub_counter = None),
("zoom_toggle", |e| e.zoom_toggle = Some(ZoomToggle { samples_per_px: 64.0, scroll: 0, heights: Vec::new() })),
@@ -234,8 +313,7 @@ mod tests {
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let clip = Clip::audio(saved.new_clip_id(), "Clip", SourceId(900), 0, 0, 100);
saved.track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
let audio =
Arc::new(soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
let audio = Arc::new(soundcraft_model::SourceAudio::new(craft_audio::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 100]] }));
saved.pool.insert(SourceId(900), audio);
check(&saved, &saved.clone(), true, "shared decoded audio");
let mut doc = saved.clone();
@@ -252,6 +330,48 @@ mod tests {
assert_eq!(same_document(&doc, &doc), previous_same_document(&doc, &doc), "NaN keeps the previous PartialEq behavior");
}
/// Only arm and monitoring are transient; every other mixer field still contributes to dirtiness.
#[test]
fn live_flags_do_not_hide_persistent_mixer_changes() {
use soundcraft_model::{AutomationMode, BusId, Insert, Route, SendSlot, SurroundPan};
let mut saved = Session::default();
let track = saved.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut live = saved.clone();
live.track_mut(track).unwrap().mixer.record_arm = true;
live.track_mut(track).unwrap().mixer.input_monitor = true;
check(&saved, &live, true, "live input flags");
let edits: &[(&str, fn(&mut Mixer))] = &[
("volume", |m| m.volume_db = -6.0),
("pan", |m| m.pan[0] = 0.5),
("mute", |m| m.mute = true),
("solo", |m| m.solo = true),
("solo_safe", |m| m.solo_safe = true),
("phase_invert", |m| m.phase_invert = true),
("input", |m| m.input = Route::Hardware("Mic".into())),
("output", |m| m.output = Route::Bus(BusId(900))),
("inserts", |m| m.inserts[0] = Some(Insert::new("eq7"))),
("sends", |m| m.sends[0] = Some(SendSlot::new(Route::Main))),
("automation_mode", |m| m.automation_mode = AutomationMode::Touch),
("trim_db", |m| m.trim_db = -3.0),
("vca", |m| m.vca = Some(900)),
("surround", |m| m.surround = Some(SurroundPan::default())),
];
for &(field, edit) in edits {
let mut doc = live.clone();
edit(&mut doc.track_mut(track).unwrap().mixer);
check(&saved, &doc, false, field);
}
let mut doc = live.clone();
doc.tracks.push(doc.tracks[0].clone());
check(&saved, &doc, false, "track count");
let mut doc = live.clone();
doc.tracks[0].id = TrackId(900);
check(&saved, &doc, false, "track identity");
let mut doc = live.clone();
doc.tracks[0].mixer.volume_db = f32::NAN;
assert!(!same_document(&doc, &doc), "persistent NaN keeps PartialEq semantics");
}
#[test]
fn non_finite_saved_zoom_keeps_the_previous_partial_eq_behavior() {
let mut saved = Session::default();
+76 -25
View File
@@ -78,9 +78,9 @@ fn rebase_midi_start(clip: &mut Clip, old_start: Samples, t: TimeCtx<'_>) {
}
}
/// Split every clip on a track's active playlist that spans `at`. The piece before `at` keeps the
/// clip's name. The piece after it is a new clip, and takes the next name of the clip's Auto Rename
/// scheme when it has one.
/// Split every clip on a track's active playlist that spans `at`, except time- or edit-locked ones.
/// The piece before `at` keeps the clip's name. The piece after it is a new clip, and takes the next
/// name of the clip's Auto Rename scheme when it has one.
pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
separate_at_if(s, track, at, |_| true)
}
@@ -88,11 +88,21 @@ pub fn separate_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
/// [`separate_at`], giving the piece after `at` the next Auto Rename name only when `auto_name`
/// returns true for it. Any other piece keeps the clip's name, and its scheme does not count up.
pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool) -> usize {
split_at(s, track, at, auto_name, true)
}
/// [`separate_at`], splitting time- and edit-locked clips too. For the Event time operations, which
/// move the whole timeline, locked clips included.
pub fn separate_all_at(s: &mut Session, track: TrackId, at: Samples) -> usize {
split_at(s, track, at, |_| true, false)
}
fn split_at(s: &mut Session, track: TrackId, at: Samples, auto_name: impl Fn(&Clip) -> bool, skip_locked: bool) -> usize {
let tc_tempo = s.tempo.clone();
let sr = s.sample_rate;
let t = TimeCtx { tempo: &tc_tempo, sr };
let mut ids = Vec::new();
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end()).count());
let n_split = s.track(track).map_or(0, |tr| tr.clips().iter().filter(|c| at > c.start && at < c.end() && !(skip_locked && locked(c))).count());
for _ in 0..n_split {
ids.push(s.new_clip_id());
}
@@ -106,6 +116,7 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
for c in pl.clips.drain(..) {
if at > c.start
&& at < c.end()
&& !(skip_locked && locked(&c))
&& let Some(id) = ids.pop()
&& let Some((l, mut r)) = split_clip(&c, at, id, t)
{
@@ -125,6 +136,45 @@ pub fn separate_at_if(s: &mut Session, track: TrackId, at: Samples, auto_name: i
count
}
/// Split every clip on a track's active playlist at each of `points`, in one pass over the
/// playlist: [`separate_at_if`] once per point with `|_| false` (no piece takes an Auto Rename
/// name here), without rebuilding the playlist per point. Returns the number of splits.
/// `points` are sorted and deduplicated first, so the source offsets of stretched audio can differ
/// from per-point calls given in another order (each trim rounds its own adjustment).
pub fn separate_at_points(s: &mut Session, track: TrackId, points: &[Samples]) -> usize {
let mut pts = points.to_vec();
pts.sort_unstable();
pts.dedup();
let tc_tempo = s.tempo.clone();
let t = TimeCtx { tempo: &tc_tempo, sr: s.sample_rate };
// The points strictly inside a clip.
let within = |c: &Clip| {
let (lo, hi) = (pts.partition_point(|p| *p <= c.start), pts.partition_point(|p| *p < c.end()));
lo..hi.max(lo)
};
let n_split: usize = s.track(track).map_or(0, |tr| tr.clips().iter().map(|c| within(c).len()).sum());
let mut ids: Vec<ClipId> = (0..n_split).map(|_| s.new_clip_id()).collect();
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut out = Vec::with_capacity(pl.clips.len() + n_split);
for c in pl.clips.drain(..) {
let mut rest = c;
for p in &pts[within(&rest)] {
let Some(id) = ids.pop() else { break };
match split_clip(&rest, *p, id, t) {
Some((l, r)) => {
out.push(l);
rest = r;
}
None => break,
}
}
out.push(rest);
}
pl.clips = out;
pl.sort();
n_split
}
/// Gives `c` the next name of its Auto Rename scheme, when it has one, and counts the scheme up.
fn take_auto_name(schemes: &mut std::collections::BTreeMap<u64, soundcraft_model::AutoName>, c: &mut Clip) {
if let Some(scheme) = c.auto_name.and_then(|k| schemes.get_mut(&k)) {
@@ -427,6 +477,10 @@ pub fn trim_to(s: &mut Session, track: TrackId, range: Range) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
// Locked clips keep their extent, like they don't move for nudge.
if locked(c) {
continue;
}
if let Some(i) = c.range().intersect(&range)
&& i != c.range()
{
@@ -448,6 +502,9 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
if locked(c) {
continue;
}
if c.range().contains(at) && at > c.start {
let old = c.start;
c.trim_start_to(at);
@@ -458,10 +515,14 @@ pub fn trim_start_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
n
}
/// Trim the end of clips after `at` to `at` (Trim End to Insertion).
pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
let mut n = 0;
for c in &mut pl.clips {
if locked(c) {
continue;
}
if at > c.start && at < c.end() {
c.trim_end_to(at);
n += 1;
@@ -528,9 +589,15 @@ fn join_gain_env(l: &mut Clip, r: &Clip) {
}
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
/// elsewhere (see [`destinations`]).
/// elsewhere (see [`destinations`]). Same-track moves clamped to zero leave all material intact.
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
// Limit the shift before removing any clips, so a stationary edit cannot overwrite overlaps.
let limit = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| ids.contains(&c.id) && !locked(c)).map(|c| c.start.saturating_neg()).max();
let delta = delta.max(limit.unwrap_or(0));
if delta == 0 && dest.iter().all(|(from, to)| from == to) {
return 0;
}
let mut moving: Vec<(TrackId, Clip)> = Vec::new();
for t in &mut s.tracks {
let tid = t.id;
@@ -546,8 +613,6 @@ pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(Trac
}
}
let n = moving.len();
// Limit the shift once, so the group stops at zero together and keeps its spacing.
let delta = delta.max(moving.iter().map(|(_, c)| c.start.saturating_neg()).max().unwrap_or(0));
let placed = moving.into_iter().map(|(tid, mut c)| {
c.start = c.start.saturating_add(delta);
(lands(dest, tid), c)
@@ -572,20 +637,7 @@ fn put_clips(s: &mut Session, placed: Vec<(TrackId, Clip)>) {
/// Whether the Edit window shows track `t`: not hidden, and not inside a closed folder at any level.
pub fn shown(s: &Session, t: &Track) -> bool {
!t.hidden && !folder_collapsed(s, t)
}
/// Hidden because a folder at any level above this track is closed.
fn folder_collapsed(s: &Session, track: &Track) -> bool {
let mut current = track.folder;
for _ in 0..64 {
let Some(folder) = current.and_then(|folder| s.track(folder)) else { return false };
if !folder.folder_open {
return true;
}
current = folder.folder;
}
false
s.track_shown(t)
}
/// Where the tracks of clips `ids` go when track `from` goes to `to`: each keeps its distance
@@ -790,12 +842,11 @@ pub fn shuffle_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(T
/// How far a Grabber drag by `raw` moves `clip` in Grid mode: Absolute puts its sync point (its
/// start when it has none inside the clip) on the nearest grid line; Relative moves it by whole
/// grid steps, keeping its offset from the grid.
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool) -> Samples {
let (g, sr, map, rate) = (&s.edit.grid, s.sample_rate, &s.tempo, s.frame_rate);
pub fn grid_delta(s: &Session, clip: &Clip, raw: Samples, relative: bool, moving: &[ClipId]) -> Samples {
let sync = if (0..clip.length).contains(&clip.sync_point) { clip.sync_point } else { 0 };
let anchor = clip.start.saturating_add(sync);
let from = if relative { g.line_at_or_before(anchor, sr, map, rate) } else { anchor };
g.snap(from.saturating_add(raw), sr, map, rate).saturating_sub(from)
let from = if relative { s.grid_line_at_or_before_excluding(anchor, moving) } else { anchor };
s.snap_to_grid_excluding(from.saturating_add(raw), moving).saturating_sub(from)
}
/// Trim a clip's edges to `start` and/or `end` like the Trim tool, never past its file. In
+4 -4
View File
@@ -11,7 +11,7 @@ fn session_with_clip() -> (Session, TrackId) {
channels: 1,
frames: 10_000,
sample_rate: 48_000,
format: soundcraft_audio_io::FileFormat::Wav,
format: soundcraft_model::FileFormat::Wav,
time_reference: 0,
unsaved: false,
});
@@ -330,9 +330,9 @@ fn grid_moves_snap_the_clip_start_or_keep_its_offset() {
let (s, t) = session_with_clip();
let step = s.edit.grid.step_samples(0, s.sample_rate, &s.tempo, s.frame_rate);
let c = s.track(t).unwrap().clips()[0].clone();
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true), 0);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, false, &[c.id]), step);
assert_eq!(c.start + grid_delta(&s, &c, step + 200, true, &[c.id]), step + 1000);
assert_eq!(grid_delta(&s, &c, step / 3, true, &[c.id]), 0);
}
#[test]

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