Compare commits

..
Author SHA1 Message Date
aiongg aa74bffac1 Merge pull request #1011 from aiongg/next
Integration batch 1: 36 contributor PRs (files, editing, sessions, CLI, mixer, MIDI, plug-ins, interface)
2026-10-24 08:38:37 -04:00
aiongg e884d6eb37 Integration: VST3 host: keep room for note releases after a reset 2026-10-24 09:16:19 +00:00
aiongg 4ab3bde39b Integration: MIDI real-time properties: reload the form when stored values change 2026-10-24 09:15:31 +00:00
aiongg 824cf5cecb Integration: import: keep a failed import's error on the status line 2026-10-24 09:14:21 +00:00
aiongg f6a27c2ccf Integration: automation: hold clip gain envelope ends to the clip edges 2026-10-24 09:13:19 +00:00
aiongg 08679d5a65 Integration: cli: run the inline --cmd=ID form 2026-10-24 09:11:44 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 93800e0764 refactor(video): use method paths instead of closures for first_chunk (S1612) (#1012)
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 04:30:47 -04:00
aiongg c29510af86 Integration: regenerate docs/parity-checklist.md 2026-10-24 06:56:33 +00:00
aiongg 1cc7b40e6a Integration: #631 find the New Tracks name field by its text-input role 2026-10-24 06:54:11 +00:00
aiongg 42f22369fb Integration: build the batch's new integration tests into each crate's tests/it binary 2026-10-24 06:54:11 +00:00
aiongg b47f544c7a Merge track r1-t02
# Conflicts:
#	contributors/people.toml
#	docs/plugin-parity.md
2026-10-24 06:54:04 +00:00
aiongg 7bf20dbbac Merge track r1-t01 2026-10-24 06:54:04 +00:00
aiongg de206f46b6 Merge PR #798: Fix saved audio trust test on Windows by joining path parts separately (#789) 2026-10-24 06:53:57 +00:00
aiongg 4f959bee40 Merge PR #587: Round negative timecode and feet+frames positions symmetrically 2026-10-24 06:53:57 +00:00
aiongg 0ca5dd74ab Merge PR #967: Name F12 in the Record button's tooltip 2026-10-24 06:53:57 +00:00
aiongg 848978a4af Merge PR #997: Fix the macOS build: import registry in au-host's api module 2026-10-24 06:53:57 +00:00
aiongg f3b897f532 Merge PR #662: fix(ui): capture live plugin state before saving track presets
# Conflicts:
#	docs/gaps.md
2026-10-24 06:53:57 +00:00
aiongg c13e55c7c3 Merge PR #484: VST3 host: IMessage and IAttributeList for plugins (Helix loads its database)
# Conflicts:
#	ROADMAP.md
2026-10-24 06:53:57 +00:00
aiongg 419e7726bb Merge PR #605: Release held VST3 notes on reset instead of discarding them 2026-10-24 06:53:57 +00:00
aiongg bc530b172a Merge PR #763: Mix tests: never load real plug-ins (cargo test hangs on machines with plug-ins installed) 2026-10-24 06:53:56 +00:00
aiongg fb9d4ac78b Merge PR #1008: Tracks: clip track and group names to their rows in the side lists (fixes #990) 2026-10-24 06:53:56 +00:00
aiongg 76bd7bc3af Merge PR #561: Keep deep Aux and Folder routing chains in dependency order
# Conflicts:
#	contributors/people.toml
2026-10-24 06:53:56 +00:00
aiongg c4fee770ca Merge PR #784: Fix empty SMF export re-importing as one phantom empty track 2026-10-24 06:53:56 +00:00
aiongg c0f72e5fe3 Merge PR #593: ui: MIDI Real-Time Properties submits only the property that changed (#444) 2026-10-24 06:53:56 +00:00
aiongg 7ec4216ba2 Merge PR #565: A Pencil click on a MIDI clip selects it, so the MIDI Editor follows 2026-10-24 06:53:56 +00:00
aiongg d33563ebe2 Merge PR #521: Keep MIDI note selection valid across clip, session and note-order changes (#443)
# Conflicts:
#	crates/engine/src/cmd/midi.rs
2026-10-24 06:53:56 +00:00
aiongg 95243c7698 Merge PR #1010: Add a built-in Ring Modulator plugin
# Conflicts:
#	ROADMAP.md
2026-10-24 06:40:52 +00:00
aiongg 4240873586 Merge PR #551: Bounce: a Dither option in the dialog and for stems (#522) 2026-10-24 06:40:52 +00:00
aiongg 9eb2077dd1 Merge PR #643: Resample through a polyphase bank: 5x faster rate-converting imports (#597) 2026-10-24 06:40:52 +00:00
aiongg 3fe53ee834 Integration: #595 pass BUNDLE_LIMITS in main's sources_sharing_one_file test 2026-10-24 06:19:20 +00:00
aiongg 9e7583d99b Merge PR #595: Save a session bundle only if it opens again (#433)
crates/engine/src/io.rs: kept both bundle tests.
2026-10-24 06:19:17 +00:00
aiongg 9f03eeb354 Merge PR #586: session: reject duplicate ids and an exhausted id space on load 2026-10-24 06:19:10 +00:00
aiongg ec7cfbb8ac Merge PR #519: Load older sessions whose track height is the bare Fit string (#447) 2026-10-24 06:19:09 +00:00
aiongg d2d6518a5f Merge PR #557: mix: clamp negative fractional source position in stretched-clip rendering 2026-10-24 06:19:09 +00:00
aiongg 045938728f Integration: #674 advance the source offset by delta/stretch on start trims and compare heal offsets in source time 2026-10-24 06:19:09 +00:00
aiongg 2e17315d5d Merge PR #674: Fix source offset when trimming the start of a stretched audio clip
crates/engine/src/edit.rs: kept main's gap-aware heal comparison and doc comment, the PR's source_len helper, and both sets of tests.
crates/model/src/clip.rs: kept main's clamped trim_start_to body (the stretch-aware offset follows in the Integration commit) and both tests.
2026-10-24 06:19:06 +00:00
aiongg 678cb0f57d Integration: #517 join_gain_env places the right envelope at the seam when heal bridges a gap 2026-10-24 06:18:49 +00:00
aiongg a1eafbd1c5 Merge PR #517: Heal keeps each clip's gain envelope and skips clips whose mute or stretch differ (#450)
crates/engine/src/edit.rs: heal combines main's gap-aware join (contiguous, gap, saturating adds) with the PR's same_playback check and join_gain_env call; both heal tests kept.
2026-10-24 06:18:46 +00:00
aiongg c8f44fbbef Merge PR #518: Keep unrelated overlapping clips untouched when deleting fades (#449)
crates/engine/src/edit.rs: kept both tests; delete_fades body merged without conflict.
2026-10-24 06:17:53 +00:00
aiongg fd07262983 Merge PR #760: Bounce the whole session when CLI --bounce has no range 2026-10-24 06:17:41 +00:00
aiongg 9719f955a4 Integration: #552 allow --dither and --no-dither in convert's options 2026-10-24 06:17:33 +00:00
aiongg 98aa5dd1b5 Merge PR #555: convert: dither only when the output loses resolution (#548)
apps/soundcraft-cli/src/main.rs: kept main's OsStr::to_str spelling and the PR's dither lines.
2026-10-24 06:17:33 +00:00
aiongg 9a5ab203d2 Merge PR #552: cli: reject unknown options and missing option values 2026-10-24 06:17:18 +00:00
aiongg 0cb3c692ac Merge PR #716: Keep the existing volume lane in automation.clip_gain_to_volume 2026-10-24 06:17:18 +00:00
aiongg 5bd755f604 Merge PR #617: Apply strip mute automation within each processing block
contributors/people.toml: kept both new entries.
2026-10-24 06:17:15 +00:00
aiongg b0e35aa988 Merge PR #792: Preserve finite headroom in Float32 WAV and AIFF-C encoding 2026-10-24 06:17:04 +00:00
aiongg bb990d9bfa Merge PR #663: Fix Split into Mono repeating the left channel
ROADMAP.md, docs/gaps.md: kept the newer header line, union of log, table and G-04 entries
crates/engine/src/cmd/track.rs: kept main's explicit imports, added ClipContent
2026-10-24 06:16:56 +00:00
aiongg 2adec7f128 Integration: #509 accept the Ambisonic WAVEX subtype GUIDs as PCM/float 2026-10-24 06:16:40 +00:00
aiongg 038c4c0ca7 Merge PR #636: Import audio off the UI thread (#597)
crates/engine/src/io.rs: kept the PR's decode_import_bytes split; main's OsStr::to_str spelling.
crates/engine/src/cmd/file.rs: kept the PR's take_import path; main's OsStr::to_str spelling.
2026-10-24 06:16:25 +00:00
aiongg fc87f5a5c6 Merge PR #346: Prevent track-name collisions after truncation
# Conflicts:
#	contributors/people.toml
2026-10-24 06:16:17 +00:00
aiongg a851761270 Merge PR #340: Preserve exact channel formats when committing tracks
contributors/people.toml: union of both entries
2026-10-24 06:16:10 +00:00
aiongg c84bdda2f1 Merge PR #509: Validate the full WAVE_FORMAT_EXTENSIBLE fmt chunk (#465) 2026-10-24 06:16:08 +00:00
aiongg 886969bd19 Merge PR #631: Give the New Tracks name field its own row so it stays wide (#626) 2026-10-24 06:16:01 +00:00
SusieeTheLinuxUserandGLM-5.3-Flash 6c03d7e8de Tracks: clip track and group names to their rows in the side lists (fixes #990)
Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
2026-10-24 07:08:18 +02:00
Brandon Thomas ef646cf626 Merge pull request #1009 from storytold/fix/996-followup
audiosuite: saturating render budget, and a sharper budget test (follow-up to #996)
2026-10-24 01:03:54 -04:00
Brandon Thomas aa06d87410 Merge pull request #982 from Silvertree2010/fix/918-audit-the-output-nan-guard-cover
Guard all playback output paths against NaN from interpolation overflow
2026-10-24 01:03:50 -04:00
Brandon Thomas c422084a4d Merge pull request #994 from SusieeTheLinuxUser/fix/928-h264-idct-overflow
Video: clamp H.264 IDCT inputs and temporal-direct mvs to the spec ranges (#928)
2026-10-24 01:03:47 -04:00
Brandon Thomas 1779610626 Merge remote-tracking branch 'origin/main' into m/982
# Conflicts:
#	crates/playback/src/lib.rs
2026-10-24 01:02:27 -04:00
Brandon Thomas 7cec02403e Merge remote-tracking branch 'origin/main' into m/994
# Conflicts:
#	crates/video/src/h264/slicedec.rs
2026-10-24 01:02:19 -04:00
Brandon ThomasandClaude Opus 5.5 7ae583895d audiosuite: saturating budget product; the budget test expects the total-budget error
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:02:01 -04:00
Brandon Thomas b7fe88a72e Merge pull request #996 from sbacaro/fix/896-offline-render-budget
Bound offline render commands: dedupe tracks and a total sample budget (fixes #896)
2026-10-24 01:01:52 -04:00
Brandon Thomas 16c3e222b7 Merge pull request #983 from Silvertree2010/fix/908-audit-panic-rollback-is-partial
Restore all engine side-effect state when a command panics
2026-10-24 01:01:49 -04:00
Brandon Thomas 04d014b662 Merge pull request #981 from Silvertree2010/fix/920-audit-a-punch-that-misses-the-ta
Don't register a source for a take that a punch range misses
2026-10-24 01:01:45 -04:00
Brandon Thomas ffd88ee73d Merge pull request #868 from oracola91/fix/autosave-folders-owner-only
Autosave: the autosave folders are owner-only on Unix
2026-10-24 01:01:42 -04:00
Brandon Thomas 279d32080e Merge pull request #960 from rollroyces/fix/soundcraft-907-trim-saturating
fix(model): saturate the arithmetic in trim_start_to / trim_end_to (#907)
2026-10-24 01:01:38 -04:00
Brandon Thomas 6db7d29218 Merge pull request #679 from marcoarnulfo/feat/scrollintoview
Scroll Into View in the track name menu: the track goes to the top of the Edit window and the left of the Mix
2026-10-24 01:01:34 -04:00
Brandon Thomas 11aa5b99b3 Merge pull request #946 from storytold/test/backfill-regression-tests
Regression test for #264
2026-10-24 01:01:31 -04:00
Brandon Thomas 567172f530 Merge pull request #979 from marcoarnulfo/feat/repeatable-automation-roots
MCP: --automation-read-root and --automation-write-root may each be given more than once
2026-10-24 01:01:28 -04:00
Brandon Thomas 6a4bcadf1e Merge pull request #986 from Silvertree2010/fix/889-audit-a-latency-change-reallocat
Keep delay-line storage when the compensated latency changes
2026-10-24 01:01:24 -04:00
Brandon Thomas cdc07eef35 Merge pull request #980 from Silvertree2010/fix/929-audit-file-export-session-text-o
Require replace: true for file.export_session_text to overwrite
2026-10-24 01:01:20 -04:00
Brandon Thomas 0a1b1b185d Merge pull request #940 from Silvertree2010/fix/h1335-automation-id-panics-with-intege
Fix automation_id overflow and bad ids for high send/plugin slots
2026-10-24 01:01:17 -04:00
Brandon Thomas a0f78c0a1f Merge pull request #875 from Silvertree2010/fix/h1264-seconds-and-frames-grids-round-t
Place Seconds and Frames grid lines at exact multiples of the step
2026-10-24 01:01:13 -04:00
Brandon Thomas 03183ae308 Merge pull request #867 from Silvertree2010/fix/h1222-copy-range-keeps-fades-of-the-or
Reset fades at cut edges in copy_range
2026-10-24 01:01:09 -04:00
Brandon Thomas 58b4208e1f Merge pull request #833 from Silvertree2010/fix/h1140-smf-export-import-changes-note-l
Resolve same-pitch overlapping notes when writing SMF
2026-10-24 01:01:06 -04:00
Brandon Thomas 2ac0200c11 Merge pull request #824 from Silvertree2010/fix/h928-remote-control-client-rejects-ho
Resolve hostnames in remote control client targets
2026-10-24 01:01:02 -04:00
Brandon Thomas a6963aaeea Merge pull request #780 from Silvertree2010/fix/772-heal-separation-missing-behavior
Heal separations left by a cut, not only splits
2026-10-24 01:00:58 -04:00
Brandon Thomas 9a9da473c6 Merge pull request #947 from marcoarnulfo/fix/save-session-bare-name
Save Session: a bare file name is checked as the working directory against the write root
2026-10-24 01:00:53 -04:00
Brandon Thomas b7dda1433a Merge pull request #957 from justinjohn0306/codex/issue-897-h264-skip-run
Prevent H.264 skip-run bounds overflow
2026-10-24 01:00:49 -04:00
Brandon Thomas a19b67eb58 Merge pull request #653 from marcoarnulfo/fix/plugin-edits-dirty
Plugin-only editor changes mark the session modified
2026-10-24 01:00:46 -04:00
Brandon Thomas 3903e0a894 Merge pull request #573 from DCubix/fix/resampler-block-edge
Fix stale sample read at the end of each mix block when device and session rates differ
2026-10-24 01:00:42 -04:00
Brandon Thomas 0982bdedee Merge pull request #941 from zuujiscom/freebsd/aarch64-cross-package
FreeBSD: package the aarch64 cross build (craft-common-actions#39)
2026-10-24 01:00:38 -04:00
Brandon Thomas 62d2a47878 Merge pull request #977 from MarekSpodBiedry/issue-951
Follow the desktop light/dark setting for the System theme on Linux (#951)
2026-10-24 01:00:34 -04:00
Brandon Thomas 8e8157f550 Merge pull request #805 from mrrobbiec/perf/large-session-drawing
Edit window: draw large edited sessions without per-clip work every frame
2026-10-24 01:00:30 -04:00
dotCooCoo c2da4cf912 Add a built-in Ring Modulator plugin
The built-in plugins had no ring modulator, and AudioSuite > Modulation >
Sci-Fi ran the Flanger instead. Ring Modulator (`ring_mod`, Modulation
category, also offline as AudioSuite) has four effects: Ring multiplies the
input by a sine carrier, Frequency Mod reads the input through a delay the
carrier swings by 1 ms, and Resonator + and Resonator - feed the output back
after one period of the frequency with a positive or a negative sign. At full
depth an LFO or sample and hold moves the frequency by up to two octaves
either way, and an envelope follower raises it by up to two octaves.
AudioSuite > Modulation > Sci-Fi now opens it.

The Total row in docs/plugin-parity.md now counts 25 processors and 3
instruments, the entries in the built-in registry.
2026-10-23 21:56:01 -07:00
Brandon ThomasandClaude Opus 5.5 0888ca8ac3 Playback: an infinite sample goes out as silence, not full scale
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:55:05 -04:00
Brandon ThomasandClaude Opus 5.5 77d96affc3 Panic rollback test: start from non-default side-effect state
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:55:05 -04:00
Brandon Thomas a18f86cc89 Merge remote-tracking branch 'origin/main' into m/983 2026-10-24 00:54:53 -04:00
Brandon Thomas ad7b9bcac6 Merge remote-tracking branch 'origin/main' into m/982 2026-10-24 00:54:41 -04:00
Brandon Thomas 19f1069109 Merge remote-tracking branch 'origin/main' into m/981
# Conflicts:
#	crates/engine/src/io.rs
2026-10-24 00:54:37 -04:00
Brandon Thomas 5088846d45 Merge remote-tracking branch 'origin/main' into m/868
# Conflicts:
#	crates/engine/src/autosave.rs
2026-10-24 00:54:17 -04:00
Brandon ThomasandClaude Opus 5.5 b0f07331a2 Clip::trim_start_to keeps the end when an extreme start would overflow the length
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:53:41 -04:00
Brandon Thomas 1053073302 Merge remote-tracking branch 'origin/main' into m/960 2026-10-24 00:53:02 -04:00
Brandon Thomas a50fdf9cc9 Merge remote-tracking branch 'origin/main' into m/947 2026-10-24 00:53:01 -04:00
Brandon ThomasandClaude Opus 5.5 99579a5421 Move the Scroll Into View test into the shared tests/it binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:51:56 -04:00
Brandon Thomas 0000566f5e Merge remote-tracking branch 'origin/main' into m/679 2026-10-24 00:51:38 -04:00
Brandon Thomas b8b9a90c6f Merge remote-tracking branch 'origin/main' into m/979 2026-10-24 00:50:13 -04:00
zuujiscomandClaude Opus 5.5 07b85277ee Merge main into freebsd/aarch64-cross-package
Brings in main's SonarCloud cleanup of package.sh and gives the cross-build changes the same style:
[[ ]] tests, and release_arch takes its argument as a named local and ends in an explicit return.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 19:08:24 -05:00
lucasegenandClaude Opus 5.5 1f72dc30be Import registry in au-host's macOS api module
#988 replaced `use super::*;` in the macOS `api` module with an explicit
list that leaves `registry` out, so soundcraft-au-host, and with it the
app, no longer builds on macOS: four E0433 errors. Linux CI compiles this
module out, so it stayed green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:29:51 +02:00
Samuel Bacaro 525a4e02fe Bound offline render commands: dedupe tracks and a total sample budget
AudioSuite-style commands looped over tracks/clips and kept every
result as a new pool source (which undo snapshots also hold). Each
render was capped per-track, but:

- tracks_param did not remove duplicates, so listing the same track id
  eight times rendered it eight times;
- audiosuite.process's signal_generator branch checked only per track,
  so many distinct tracks could exceed memory in one command.

Fixes:
- tracks_param now dedupes track ids with a HashSet.
- generate() subtracts frames*channels from a MAX_RENDER_SAMPLES budget
  across all tracks and refuses with "too much audio to generate in one
  command" before allocating.

Regression tests cover duplicate track ids and the cross-track budget.

Fixes #896
2026-10-23 19:28:45 -03:00
SusieeTheFemboyandClaude Opus 5.5 8b34dc533f Video: derive temporal-direct mvL1 from the unclamped mvL0
mvL1 = mvL0 - mvCol (8.4.1.2.3) was computed from mvL0 after it had been
clamped to the motion-vector range, so a crafted stream with dsf 512 and
mvCol 32767 got mvL1 = 0 instead of 32767. Both vectors are now derived
in i32 and only the results are clamped; in-range streams are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:00:00 +02:00
SusieeTheFemboyandGLM-5.3-Flash 4b54d72c23 Video: clamp H.264 IDCT inputs and temporal-direct mvs to the spec ranges
The audit's L-27 (issue #928): scale4/scale8 and the DC dequantisations
deliberately wrap (huge levels must not panic there), so the values reaching
idct4_add/idct8_1d can be any i32, where the plain + / - of the butterfly
arithmetic panicked in debug and overflow-checks builds on crafted streams.
The spec leaves the inverse-transform output unspecified when a dequantised r
falls outside -2^15 to 2^15 - 1 (8.5.12.1, likewise 8.5.13.1), so the IDCT
now clamps its input to that range: bit-exact for every stream whose r stays
in range, defined instead of panicking or wrapping to garbage otherwise.

Temporal direct (8.4.1.2.1) had the same class of bug one layer up: the
mvL0 - mvCol subtraction overflowed i16 for extreme co-located mvs (stored
mvs wrap by design). The derivation moves into temporal_direct_mvs, runs in
i32 and clamps to the spec's -2^15 to 2^15 - 1 motion-vector range.

Regression tests feed i32::MIN/MAX coefficients into idct4_add/idct8_add and
i16 extremes through temporal_direct_mvs; they panicked with overflow before
the fix. Decoded output for well-formed streams is unchanged.

Co-Authored-By: GLM-5.3-Flash <noreply@z.ai>
2026-10-23 23:49:14 +02:00
lucasegen e82de5cb97 Merge upstream main into fix/record-tooltip-f12 2026-10-23 22:22:02 +02:00
NetterSilverandClaude Opus 5.5 cc493b2d8b Keep delay-line storage when the compensated latency changes
Fixes #889

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:56:02 +00:00
NetterSilverandClaude Haiku 5.5 9ecf197aa7 Restore all engine side-effect state when a command panics
Fixes #908

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 19:06:48 +02:00
NetterSilverandClaude Haiku 5.5 072b4ff3da Guard all playback output paths against NaN from interpolation overflow
Fixes #918

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:59:45 +02:00
NetterSilverandClaude Haiku 5.5 0235d11d4c Don't register a source for a take that a punch range misses
Fixes #920

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:56:25 +02:00
NetterSilverandClaude Haiku 5.5 767feacf72 Require replace: true for file.export_session_text to overwrite
Fixes #929

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 18:47:27 +02:00
Marco Arnulfo d01abd0e4e MCP: --automation-read-root and --automation-write-root may each be given more than once (fixes #874) 2026-10-23 18:25:05 +02:00
MarekSpodBiedry 7c690b19e1 Follow the desktop light/dark setting for the System theme on Linux (#951) 2026-10-23 17:44:09 +02:00
lucasegenandClaude Opus 5.5 24fa4668ef Name F12 in the Record button's tooltip
The tooltip only offered Cmd+Space, which macOS gives to Spotlight unless
the user frees it, so on a stock Mac the advertised shortcut opens
Spotlight. F12 already records (shortcuts.rs), as on the incumbent; the
tooltip now names it first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:08:15 +02:00
Royce ce235c6f80 fix(model): saturate the arithmetic in trim_start_to / trim_end_to (#907)
`Samples` is `i64`, and these two public APIs did plain subtraction:

```rust
let new_start = new_start.min(self.end() - 1);   // end() - 1
let delta = new_start - self.start;               // extreme new_start vs extreme start
self.length -= delta;                            // -=
let new_end = new_end.max(self.start + 1);       // start + 1
self.length = new_end - self.start;               // extreme new_end vs extreme start
```

Command callers clamp to `MAX_POSITION` first, so nothing in the app trips
this today — but the methods are `pub`, and a clip read from a hostile file
can already hold any `i64` before `sanitize` runs. An extreme `new_start`
against an extreme `start` wraps in release and panics in debug.

Each site becomes a saturating operation followed by the same clamp
`sanitize` already applies, so the result is identical for every input a
command can produce.

Test: `trims_saturate_on_extreme_arguments` sweeps `i64::MIN`/`MAX`,
`±2 * MAX_POSITION` and the edges, in both directions, asserting positions
stay within `±MAX_POSITION` and lengths within `1..=MAX_POSITION`.
Verified against the unfixed arithmetic: the test fails with
`attempt to subtract with overflow`.

cargo test -p soundcraft-model: 41 passed, 0 failed.
cargo clippy -p soundcraft-model --lib --no-deps --tests clean.
2026-10-23 20:09:26 +08:00
justinjohn0306 811d78729d Prevent H264 skip-run bounds overflow 2026-10-23 15:55:15 +05:30
Marco Arnulfo 9049eb53c5 Save Session: a bare file name is checked as the working directory against the write root (fixes #869) 2026-10-23 02:08:38 +02:00
zuujiscom d9f2b070cb FreeBSD package.sh: accept only the two supported FreeBSD targets
Review follow-up. CARGO_BUILD_TARGET must be aarch64-unknown-freebsd or x86_64-unknown-freebsd,
the two the release builds; anything else, including made-up triples such as
aarch64-foo-unknown-freebsd, stops with exit 2.

Part of storytold/craft-common-actions#39.
2026-10-22 16:06:19 -05:00
zuujiscom 38d62661ce FreeBSD package.sh: never run a FreeBSD target's binary on another OS
Review follow-up. With CARGO_BUILD_TARGET set on a host that is not FreeBSD (say an x86_64 Mac
building x86_64-unknown-freebsd), the architectures match but the binary still cannot run, so
the --version run is skipped there and the file(1) check is used instead.

Part of storytold/craft-common-actions#39.
2026-10-22 15:31:37 -05:00
zuujiscom 8063aa7173 FreeBSD package.sh: accept only executables in the cross-build check
Review follow-up. The file(1) check for binaries that cannot run here now needs an executable
(file says "executable" or "pie executable") or an ELF with an interpreter, which is how an older
file(1) still shows a PIE it calls a shared object. FreeBSD libraries, object files and core files
for the target architecture are refused.

Part of storytold/craft-common-actions#39.
2026-10-22 14:49:35 -05:00
zuujiscom ce42466dd6 FreeBSD package.sh: accept only FreeBSD targets, check cross-built binaries
Review follow-up. CARGO_BUILD_TARGET must now be a *-unknown-freebsd triple, so a Linux or macOS
target can no longer be packaged under a FreeBSD name, and it is read before uname -m, so an
unsupported host does not block a valid cross build. When the binaries are for another
architecture and cannot run --version, file(1) must report each as a FreeBSD ELF executable
for the target architecture.

Part of storytold/craft-common-actions#39.
2026-10-22 14:32:12 -05:00
zuujiscom 79adead5bf FreeBSD package.sh: package the aarch64 cross build
The release workflow now cross-compiles FreeBSD aarch64 inside the x86_64 VM
(storytold/craft-common-actions#50) and sets CARGO_BUILD_TARGET=aarch64-unknown-freebsd.
package.sh now reads the binaries from target/<triple>/release when CARGO_BUILD_TARGET
is set, names the archive after the target instead of uname -m, and skips the --version
run when the binary is for another architecture than the machine. x86_64 packages are
unchanged.

Part of storytold/craft-common-actions#39.
2026-10-22 14:14:07 -05:00
NetterSilverandClaude Haiku 5.5 a966917c76 Fix automation_id overflow and bad ids for high send/plugin slots
Found while testing crates/ui-egui/src/edit_window.rs:2474-2477

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:13:14 +02:00
oracola91 d49e114d84 Autosave: the folder takes are written into is owner-only
arm_recorder made a never-saved session's take folder owner-only, but
the recorder then made Audio Files inside it with create_dir_all. In a
take folder an earlier version left with a wider mode, new takes were
readable. arm_recorder now makes the folder each take goes into
owner-only.
2026-10-22 13:48:00 +03:00
NetterSilverandClaude Opus 5.5 da26cf832a Place Seconds and Frames grid lines at exact multiples of the step
Found while testing crates/time/src/grid.rs:127-128,158-161

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 09:43:56 +00:00
oracola91 5029626f9e Autosave: the autosave folders are owner-only on Unix
Autosaves and the takes of a session never saved are unsaved work. Their
folders were made with create_dir_all, so on Unix they got the default
mode and other accounts on the machine could read them. The folders the
autosaver creates, and the folder a never-saved session records into,
are now 0o700. A folder that already exists keeps its mode.

The same change as storytold/pdfcraft#3320 and storytold/vectorcraft#3406.
2026-10-22 11:03:38 +03:00
NetterSilverandClaude Opus 5.5 590ac97e93 Reset fades at cut edges in copy_range
Found while testing crates/engine/src/edit.rs:157-174

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 09:54:38 +02:00
Paul G 8198d6e35a Merge remote-tracking branch 'upstream/main' into fix/597-import-off-ui-thread
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-21 04:14:28 -07:00
Limestudio90 1c1df8728d Merge remote-tracking branch 'origin/main' into vst3-host-messages 2026-10-21 11:53:38 +02:00
Marco Arnulfo 52253e89c5 Autosave a plugin editor gesture that is still open
The state of a plugin whose editor gesture hasn't ended is not in the
session yet, so the session looked clean: the two-minute autosave and
closing the session skipped it. Both now count a pending editor state as
a change to keep, and read the plugins' states as they do for a modified
session.
2026-10-21 11:30:15 +02:00
Marco Arnulfo 63eacf115d Read a plugin editor's state once per gesture, not every frame
The state of an instrument edited in its editor (or of a plugin that
marked its state dirty) was read and stored on every frame with a change,
so a drag serialized the plugin and resent the session each frame. Each
plugin's state now waits until its gesture ends: the first frame its
editor reports no change, or its editor closing. A plugin that keeps
changing doesn't hold back the others.
2026-10-21 10:50:19 +02:00
Marco Arnulfo 76bac6495e Scroll Into View: a Mix request lasts one frame, so a hidden track doesn't scroll the Mix when it comes back 2026-10-21 10:23:03 +02:00
Marco Arnulfo 16371a8502 Scroll Into View is a UI command, view.scroll_into_view, and the track name menu dispatches it 2026-10-21 10:23:03 +02:00
Marco Arnulfo 717ae97af5 Scroll Into View in the track name menu: the track goes to the top of the Edit window and the left of the Mix 2026-10-21 10:17:23 +02:00
NetterSilverandClaude Haiku 5.5 187ee51ae0 Resolve same-pitch overlapping notes when writing SMF
Found while testing crates/midi/src/smf.rs:486-493

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 02:30:10 +02:00
NetterSilverandClaude Haiku 5.5 797aaac058 Resolve hostnames in remote control client targets
Found while testing crates/automation/src/lib.rs:100

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 18:43:18 +02:00
mrrobbiecandClaude Opus 5.5 09c2e71141 Edit window: draw large edited sessions without per-clip work every frame
On a 63,424-clip drum edit the UI thread spent ~187 ms per frame, so with a plugin editor open
(a frame every 30 ms) it never went idle and the editor windows, which Windows only paints
when the thread's queue is empty, stopped redrawing. Now ~11 ms of CPU per frame:

- Overlap shadows: each drawn clip checked every clip of its track; on a sorted track the
  clips starting inside it are found by binary search (sortedness is checked once per track
  and frame, and an unsorted track is still scanned).
- Fade curves: a fade took at least 65 points, even under a pixel wide. A narrow one takes
  ~4 * its width in physical pixels; the chord error stays under a quarter pixel (tested).
- Clip List: rows are listed and filtered once per document revision and filter text, and
  only the rows in view are laid out (show_rows).
- Universe: each track's clips are merged into same-colour runs covering exactly the same
  pixels, cached per revision and area (5,000 clips become a handful of rects).
- Session::content_end, which walks every clip, is worked out once per revision.
- Alternate playlist lanes skip clips outside the view, like the main lane, and clip names
  are not laid out where no glyph would show.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:01:25 +10:00
Diego LopesandClaude Opus 5.5 dfde8a6724 Add regression test for the resampler block edge
Render a long DC block, then short blocks of silence with the device
rate below the session rate. The interpolation must not read the stale
frame past pending_len. Also correct the comment on the edge hold.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:36:00 -04:00
Diego Lopes 0d8a92f53b Merge branch 'main' into fix/resampler-block-edge 2026-10-19 23:31:07 -04:00
MarekSpodBiedry b5bcf577b2 Fix saved audio trust test on Windows by joining path parts separately (#789) 2026-10-19 22:27:44 +02:00
Matthew Burlage 4eee6b17a8 Preserve finite Float32 headroom during audio encoding 2026-10-19 12:58:35 -04:00
Limestudio90andClaude Opus 5.5 82616dcfb8 vst3-host: restore "crate" in the ffi module doc
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:01:38 +02:00
Limestudio90 5ddd4b115f Merge remote-tracking branch 'origin/main' into vst3-host-messages
# Conflicts:
#	ROADMAP.md
#	docs/plugin-parity.md
2026-10-19 16:54:24 +02:00
Gert Pellin c8f8fc45eb convert: dither only when the output loses resolution (#548)
soundcraft-cli convert always passed dither: true, so a same-depth or
wider conversion still added ±1 LSB TPDF noise and digital silence came
out as noise. It now dithers only when writing integer samples that
cannot hold the source exactly: fewer bits than the source, a float or
lossy source, or samples changed by --rate/--normalize. 32-bit float
output never dithers. --dither and --no-dither override the choice,
like the Bounce option in #551.
2026-10-19 16:34:26 +02:00
NetterSilverandClaude Haiku 5.5 3c04a3c36f Fix empty SMF export re-importing as one phantom empty track
Found while testing crates/midi/src/smf.rs:207

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 11:04:51 +00:00
NetterSilverandClaude Haiku 5.5 278b41b8ff Heal separations left by a cut, not only splits
Fixes #772

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 08:18:28 +00:00
NetterSilverandClaude Haiku 5.5 c09dab9fef fix: heal split halves of stretched audio clips
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 07:29:53 +00:00
Derek Wright 4110f5ff10 Mix tests: never load real plug-ins
stored_state_comes_first_then_session_params ends with an offline render_range over a vst3: insert. That reached create_plugin and the real VST3 host, which scans the machine's plug-in folders and loads every installed plug-in in-process; some (Native Instruments) hang on unload, so the test binary never exited on a developer machine. CI has no plug-ins installed, so it stayed green.

Under cfg(test), create_plugin now returns None for third-party ids, so a test reaching the real hosts sees an absent plug-in instead of hanging. The rule, stated in the tests module doc: tests adopt fakes under clap:test.* ids and never call the real hosts.
2026-10-18 13:07:27 -04:00
NetterSilverandClaude Haiku 5.5 a72e46e7b6 Bounce the whole session when CLI --bounce has no range
Fixes #745

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 19:04:41 +02:00
NetterSilverandClaude Haiku 5.5 6f3591ae11 Keep the existing volume lane in automation.clip_gain_to_volume
Found while testing crates/engine/src/cmd/mix.rs:676-681 (clip_gain_to_vol)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 13:43:37 +00:00
NetterSilverandClaude Haiku 5.5 a4b6bd0179 Fix source offset when trimming the start of a stretched audio clip
Found while testing crates/model/src/clip.rs:246 (Clip::trim_start_to, used by crates/engine/src/edit.rs:36 split_clip)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 11:09:42 +00:00
reoniazx 3ea890496e fix(engine): preserve channels when splitting tracks into mono 2026-10-16 12:43:06 +08:00
euisuhandOpenAI Codex 7a086baa7b fix(ui): capture live plugin state before saving track presets
Track presets serialized the insert model's earlier capture instead of the
hosted plugin's current state. Reuse the existing save/bounce capture path
before preset serialization and exercise it with the real CLAP fixture.

Closes #661
Related #639

Co-Authored-By: OpenAI Codex <noreply@openai.com>
2026-10-16 13:36:19 +09:00
Marco Arnulfo 5f03a5991e Plugin-only editor changes mark the session modified (fixes #455) 2026-10-16 03:20:53 +02:00
Paul GandClaude Opus 5.5 adab577f9a Resample through a polyphase bank: 5x faster rate-converting imports
Precompute the windowed-sinc weights once per phase of the reduced from/to ratio; each output is one dot product. Same weights, order and edges as the per-tap kernel; large ratios fall back to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 15:50:13 -07:00
Paul GandClaude Opus 5.5 05fe56ec1a Import audio off the UI thread (#597)
Decode, resample and build peaks on a worker; file.import_audio then only places the clip. CLI/MCP/control unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:21:28 -07:00
MarekSpodBiedry fb087c879b Give the New Tracks name field its own row so it stays wide (#626) 2026-10-15 17:14:38 +02:00
Billy Coleman III 075104f448 Apply strip mute automation within each processing block
Gate ordinary strip output and pre-fader copies at each represented sample
using the existing mute lane, mode, held values and threshold. Preserve the
fader and pan schedules, static suppression and hosted processing blocks.
2026-10-15 04:35:33 -04:00
NetterSilverandClaude Haiku 5.5 ea0e4719fc Release held VST3 notes on reset instead of discarding them
Fixes #441

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 04:00:26 +02:00
vxMacDev 2cba9ed4cd Save a session bundle only if it opens again (#433)
The bundle reader refuses more than 10,000 files or 1 GiB unpacked,
but the writer had no limits, so a browser save of a long session
could succeed, mark the session clean and produce a file SoundCraft
won't open. The writer now counts files and bytes the way the reader
does and refuses the save before anything is delivered or marked
clean. Both use one BundleLimits.
2026-10-14 16:54:56 -05:00
midasmatterhorn 315745b022 ui: MIDI Real-Time Properties submits only the property that changed (#444) 2026-10-15 00:36:03 +03:00
NetterSilverandClaude Haiku 5.5 952ca5e4ed Round negative timecode and feet+frames positions symmetrically
Found while testing crates/time/src/timecode.rs:111

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 23:16:37 +02:00
midasmatterhorn 800b27013d session: reject duplicate ids and an exhausted id space on load (#448) 2026-10-14 23:22:31 +03:00
Diego LopesandClaude Sonnet 5.5 7bf80663dc Fix resampler edge read at device/session rate mismatch
The output resampler interpolated toward a stale sample at the end of every
mix block when the device rate differs from the session rate. It now holds
the last valid sample.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-14 13:59:47 -04:00
Marco Arnulfo b50373d6db A Pencil click on a MIDI clip selects it, so the MIDI Editor follows (fixes #562) 2026-10-14 19:25:20 +02:00
Billy Coleman III 1e1458caf1 Compile exact dependency order for deep Aux routing
Prepare longest-path depths for acyclic Aux and Folder input routes so
reverse-created chains beyond eight returns preserve their audio. Count
output and send destinations once per producer, retain depth ties in
session order, and preserve the whole-graph bounded fallback for cycles.

Add public PCM regressions for deep chains, routing edits, callback sizes
and cycle compatibility, plus focused private ordering cases.
2026-10-14 13:14:38 -04:00
midasmatterhorn 76696826cd mix: clamp negative fractional source position in stretched-clip rendering 2026-10-14 18:01:42 +03:00
Hamza f8b2f8ce2e cli: reject unknown options and missing option values
`convert`, `run` and `script` read options with `arg_value`, which matches a
flag exactly and takes the argument after it. A misspelling, an `--option=value`
form or an option with no value therefore read as absent: the command ran
without what was asked for and still exited 0.

    convert IN OUT --rate=44100   wrote IN's 48000 Hz unchanged
    convert IN OUT --bitdepth 16  wrote the default 24-bit
    convert IN OUT --bogus        wrote the file, said nothing
    run --cmd session.new --bounce=OUT   bounced nothing

Check what each subcommand accepts and report an unknown option or a missing
value with a non-zero exit, and read `--option=value` as the value it looks
like. The documented space-separated form is unchanged.
2026-10-14 16:59:15 +05:00
Gert Pellin d4096db1f0 Bounce: a Dither option in the dialog and for stems (#522)
The Bounce dialog never sent `dither`, so every 16- and 24-bit bounce
was dithered, and file.bounce_stems hard-coded it. The dialog gets a
Dither (TPDF) checkbox, off by default and disabled for 32-bit float,
and file.bounce_stems takes the same `dither?: true` parameter as
file.bounce_mix.
2026-10-14 13:28:56 +02:00
Billy Coleman III 28c936a256 Prevent track-name collisions after truncation
Validate the trimmed 64-scalar stored track name before mutating the
document, return that exact name, and preserve no-op document state.

Retain routing-folder bus naming and repair with unique bus names,
stable routes and undo/redo coverage.
2026-10-13 20:42:34 -04:00
Billy Coleman III 2dd8f7da55 Preserve exact channel formats when committing tracks
Retain the source ChannelFormat for committed audio tracks and reject
unsupported widths, overflowing ranges and incomplete renders before
replacing selected tracks. Preserve current routing and rollback behavior
and document the existing renderer limits with multichannel coverage.
2026-10-13 13:39:46 -04:00
MarekSpodBiedry d13f6657f8 Keep MIDI note selection valid across clip, session and note-order changes (#443) 2026-10-13 17:54:51 +02:00
MarekSpodBiedry bddc113f46 Heal keeps each clip's gain envelope and skips clips whose mute or stretch differ (#450) 2026-10-13 17:54:49 +02:00
MarekSpodBiedry b7329ee904 Keep unrelated overlapping clips untouched when deleting fades (#449) 2026-10-13 17:52:39 +02:00
MarekSpodBiedry 9a398d79ff Load older sessions whose track height is the bare Fit string (#447) 2026-10-13 17:52:29 +02:00
MarekSpodBiedry f9d04ffc98 Validate the full WAVE_FORMAT_EXTENSIBLE fmt chunk (#465) 2026-10-13 17:50:18 +02:00
Limestudio90andClaude Opus 5.5 245015fd89 VST3 host: IMessage and IAttributeList for plugins (Helix loads its database)
`IHostApplication::createInstance` returned nothing, so plugins whose
controller and processor exchange `IMessage`s through
`IConnectionPoint::notify` could not create one. Line 6 Helix Stadium
Native reported "Failed to get content database recovery info (Out of
memory condition. [code -2])", loaded no models and exposed no
parameters.

The host now creates `IMessage` (an id plus its attribute list) and
`IAttributeList` (ints, floats, UTF-16 strings and binary blobs, by
NUL-terminated attribute id). Strings and blobs are kept in their own
allocation so pointers from `getBinary` stay valid while the list holds
the value; strings are capped at 1M UTF-16 units and blobs at 256 MiB.
Null arguments are refused rather than read. Other classes are still not
provided.

Verified with Helix Stadium Native 1.03.3a on Windows: the error is gone,
its model database and presets load, and its parameters appear in the
generic plugin window. A unit test drives the objects through the COM
interfaces the way a plugin does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 14:55:34 +02:00
99 changed files with 6248 additions and 410 deletions
Generated
+1
View File
@@ -3800,6 +3800,7 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"toml",
"zbus",
]
[[package]]
+6 -6
View File
@@ -122,11 +122,11 @@ where things are.
- **Automation:** volume, pan, mute, send and every plugin parameter; Write, Touch, Latch and
Trim passes recorded live from the faders; thin, glide, coalesce and convert to clip gain.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, ring modulator,
saturator, lo-fi, rectifier, pitch shifter, time shift, gain, trim, invert, DC removal,
signal generator, dither, a subtractive synth, a drum synth and a sampler (plays any WAV,
AIFF, FLAC, MP3 or OGG across the keyboard), all original. **CLAP plugins** load too. Any
processor runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
and Audio-to-MIDI from a sung or played melody.
@@ -138,7 +138,7 @@ where things are.
pitch-preserving Elastic 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 dither and
- **Bounce:** mix or stems to WAV, AIFF or FLAC at 16/24/32-bit float with optional dither and
normalisation, reporting peak, true peak and integrated loudness (LUFS).
- **Fast:** a parallel mix engine, copy-on-write undo, and a UI that holds 120 frames per second
with seventy-odd tracks playing.
+9 -1
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-23 · **Change:** trivial (Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,12 +122,17 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-23:** A built-in Ring Modulator (`ring_mod`) applies ring modulation, frequency modulation or a positive or negative resonator, and an LFO, an envelope follower or sample and hold moves its frequency. AudioSuite › Modulation › Sci-Fi now runs it instead of the Flanger. No readiness percentage change.
- **2026-10-19:** Float32 WAV and AIFF-C encoding preserve finite samples above full scale. Integer PCM still clips at full scale. Tests cover whole-file and streaming encoders, non-finite samples, and bounce output with normalization on and off. No readiness percentage change.
- **2026-10-18:** Mono audio devices receive the average of the stereo channels before output clipping, retaining right-only audio. Surround mixes use the same average after their ITU stereo fold-down. Regression tests cover channel balance, polarity cancellation, clipping, centered mono tracks, each 5.1 channel and several callback sizes. No readiness percentage change.
- **2026-10-16:** Master fader volume automation uses each render block's actual last sample, matching track faders. Breakpoints in the next block no longer pull the preceding block's level toward them, and ramps reach their last-sample value. Regression tests cover nonzero starts, one-sample callbacks and several block sizes. No readiness percentage change.
- **2026-10-16:** Aux, folder and master latency calculations exclude inserts already taken out for producing NaN or infinity, matching audio tracks. Subsequent blocks no longer compensate for an insert that is bypassed after failure. Regression tests check impulse timing, healthy insert latency and reset/retry at 64- and 512-sample blocks. No readiness percentage change.
- **2026-10-16:** Split into Mono extracts the correct source channel for each mono track instead of playing channel zero on every track. Clip offsets, gain, fades and stretch, source timestamps, Undo/Redo and saved audio are preserved; repeated clips share each extracted mono source. Unavailable media returns an error without partial edits. No readiness percentage change.
- **2026-10-14:** MCP `batch` rejects more than 1000 calls before executing any command, instead of silently running only the first 1000 (#460). No readiness percentage change.
- **2026-10-14:** `soundcraft-cli convert` dithers only when the output loses resolution (fewer bits than the source, a float or lossy source, or resampled/normalized samples); a same-depth, wider or 32-bit float conversion copies the samples exactly and digital silence stays silent. `--dither` and `--no-dither` override the choice (#548)
- **2026-10-13:** WAV: an empty data chunk followed by metadata (LIST, bext) reads as no audio; a recording that was never finalised (sizes left at 0) still gives what was written (#466)
- **2026-10-13:** Sampler: a ping-pong loop keeps its phase and direction when a step is wider than the loop (a narrow loop with high transposition), instead of reflecting once and dropping the rest of the travel (#463)
- **2026-10-13 (VST3 host messages):** The VST3 host creates `IMessage` and `IAttributeList` objects for plugins (`IHostApplication::createInstance`), so plugins whose controller and processor talk through `IConnectionPoint::notify` work: Line 6 Helix Stadium Native now loads its content database (it reported "Failed to get content database recovery info (Out of memory condition)") and exposes its parameters. No readiness percentage change.
- **2026-10-13:** `mix.instrument` takes the built-in instrument's parameters in `params` (validated, clamped) and refuses any other field, so a synth setting written beside `plugin` is an error instead of being accepted and ignored (#265)
- **2026-10-13:** A bounce of a bus that an aux returns onto lines up with the timeline: the aux insert's lookahead is compensated like the main mix's (it left the bus late and cut the end off), and tracks routed to that bus wait for it too (#246)
- **2026-10-13:** Delay compensation covers the inserts of the master faders: a bounce of the main mix with a Maximizer on the master lines up with the same mix without it (it came out 71 samples late) (#351)
@@ -161,9 +166,12 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | Progress log: Ring Modulator plugin; readiness estimates unchanged |
| 2026-10-19 | trivial | Float32 file encoding preserves finite headroom (#791); readiness estimates unchanged |
| 2026-10-18 | trivial | Mono device playback retains both stereo sides and surround fold-down channels; readiness estimates unchanged |
| 2026-10-16 | trivial | Master volume automation block boundaries corrected; readiness estimates unchanged |
| 2026-10-16 | trivial | Failed inserts no longer contribute aux, folder or master latency after containment; readiness estimates unchanged |
| 2026-10-16 | trivial | Split into Mono preserves each source channel, clip edits and saved audio; readiness estimates unchanged |
| 2026-10-14 | trivial | Progress log: oversized MCP batches rejected before execution (#460) |
| 2026-10-13 | trivial | Progress log: empty WAV data chunk followed by metadata (#466) |
| 2026-10-13 | trivial | Progress log: sampler ping-pong phase for wide steps (#463) |
+25 -31
View File
@@ -3,30 +3,22 @@
use std::path::PathBuf;
#[derive(Default)]
pub struct AutomationArgs {
pub read_root: Option<PathBuf>,
pub write_root: Option<PathBuf>,
read_given: bool,
write_given: bool,
pub read_roots: Vec<PathBuf>,
pub write_roots: Vec<PathBuf>,
}
impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
Self { read_root, write_root, read_given: false, write_given: false }
}
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
/// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
/// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
let roots = match flag {
"--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => &mut self.write_roots,
_ => return Ok(false),
};
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline {
Some(value) => value.to_owned(),
None => {
@@ -40,8 +32,7 @@ impl AutomationArgs {
if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder"));
}
*root = Some(value.into());
*given = true;
roots.push(value.into());
Ok(true)
}
}
@@ -51,40 +42,43 @@ mod tests {
use super::*;
#[test]
fn roots_are_taken_once_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None);
fn roots_add_up_and_other_arguments_are_left() {
let mut roots = AutomationArgs::default();
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter();
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--demo", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into()));
assert_eq!(roots.write_root, Some("out".into()));
assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("song.scraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
assert_eq!(roots.read_root, Some("a folder".into()));
// #874: a flag given again adds a folder, in either spelling.
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["out2".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
assert_eq!(roots.write_roots, [PathBuf::from("out"), PathBuf::from("out2")]);
}
#[test]
fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
let mut roots = AutomationArgs { read_roots: vec!["read".into()], write_roots: vec!["write".into()] };
let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into()));
assert_eq!(roots.write_root, Some("write".into()));
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
}
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
}
}
#[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
let mut roots = AutomationArgs::new(None, None);
let mut roots = AutomationArgs::default();
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
assert_eq!(roots.read_root, Some("-folder".into()));
assert_eq!(roots.write_root, Some("./-folder".into()));
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
}
}
+171 -29
View File
@@ -2,18 +2,22 @@
//!
//! ```text
//! soundcraft-cli info FILE audio file or .scraft summary
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize]
//! soundcraft-cli convert IN OUT [--bit-depth 16|24|32f] [--rate HZ] [--normalize] [--dither | --no-dither]
//! dithers only when the output loses resolution, unless told
//! soundcraft-cli run [--in S.scraft | --demo] [--cmd ID[=JSON]]… [--bounce OUT] [--start T --end T] [--save S.scraft] [--inspect]
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT] (one `ID JSON` per line)
//! soundcraft-cli script FILE [--in S | --demo] [--save S] [--bounce OUT [--start T --end T]] (one `ID JSON` per line;
//! without --start/--end, --bounce covers the whole session)
//! soundcraft-cli commands [FILTER] list commands
//! soundcraft-cli describe ID one command's details
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR] [--automation-write-root DIR]
//! MCP server on stdio. The two roots confine every file the
//! soundcraft-cli mcp [--connect PORT] [--demo] [--automation-read-root DIR]... [--automation-write-root DIR]...
//! MCP server on stdio. The roots confine every file the
//! agent's commands read (open, import, presets) and write
//! (save, bounce, export) to those folders, links followed;
//! a root left out grants none of its access. They run the
//! in-process server, so they conflict with --connect.
//! each flag may be given more than once, a path inside any
//! of its folders being allowed, and a kind left out grants
//! none of its access. They run the in-process server, so
//! they conflict with --connect.
//! soundcraft-cli parity [--write PATH] parity report (markdown)
//! soundcraft-cli plugins list built-in plugins
//! ```
@@ -38,9 +42,57 @@ macro_rules! outln {
}
fn arg_value(args: &[String], flag: &str) -> Option<String> {
args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned()
args.iter().enumerate().find_map(|(i, a)| {
if a == flag { args.get(i + 1).cloned() } else { a.strip_prefix(flag).and_then(|rest| rest.strip_prefix('=')).map(str::to_owned) }
})
}
/// Every option a subcommand accepts, with whether it carries a value.
type Options = [(&'static str, bool)];
/// A misspelled option, or one whose value is missing, reads as absent through `arg_value`, so the
/// subcommand would run without what was asked for and still report success. Reject both instead.
fn check_options(args: &[String], allowed: &Options) -> Result<(), String> {
let mut i = 0;
while i < args.len() {
let Some(name) = args[i].strip_prefix("--") else {
i += 1;
continue;
};
let (name, inline) = name.split_once('=').map_or((name, None), |(n, v)| (n, Some(v)));
let flag = format!("--{name}");
let Some(&(_, takes_value)) = allowed.iter().find(|(f, _)| f == &flag) else {
return Err(format!("unknown option {flag}"));
};
match (takes_value, inline) {
(false, Some(_)) => return Err(format!("{flag} takes no value")),
(true, None) => {
// A following option is the next flag, not this one's value.
if args.get(i + 1).is_none_or(|v| v.starts_with("--")) {
return Err(format!("{flag} needs a value"));
}
i += 1;
}
_ => {}
}
i += 1;
}
Ok(())
}
/// `--in`, `--demo`/`--sample`, `--bounce`, `--start`, `--end`, `--save` and `--inspect`, read by
/// `load_engine` and `finish` and so shared by `run` and `script`.
const ENGINE_OPTIONS: &Options = &[
("--in", true),
("--demo", false),
("--sample", false),
("--bounce", true),
("--start", true),
("--end", true),
("--save", true),
("--inspect", false),
];
fn fail(msg: impl std::fmt::Display) -> ExitCode {
eprintln!("soundcraft-cli: {msg}");
ExitCode::FAILURE
@@ -122,6 +174,10 @@ fn info(args: &[String]) -> ExitCode {
fn convert(args: &[String]) -> ExitCode {
let (Some(input), Some(output)) = (args.first(), args.get(1)) else { return fail("convert IN OUT") };
const OPTIONS: &Options = &[("--rate", true), ("--bit-depth", true), ("--normalize", false), ("--dither", false), ("--no-dither", false)];
if let Err(e) = check_options(&args[2..], OPTIONS) {
return fail(e);
}
let rate = match parse_rate(arg_value(args, "--rate").as_deref()) {
Ok(r) => r,
Err(e) => return fail(e),
@@ -130,12 +186,16 @@ fn convert(args: &[String]) -> ExitCode {
Ok(b) => b,
Err(e) => return fail(e),
};
let dither_flag = match parse_dither(args) {
Ok(d) => d,
Err(e) => return fail(e),
};
let bytes = match std::fs::read(input) {
Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")),
};
let ext = std::path::Path::new(input).extension().and_then(std::ffi::OsStr::to_str);
let (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
let (info, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")),
};
@@ -143,14 +203,17 @@ fn convert(args: &[String]) -> ExitCode {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, rate);
buf.sample_rate = rate;
}
if args.iter().any(|a| a == "--normalize") {
let normalize = args.iter().any(|a| a == "--normalize");
if normalize {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
}
let processed = rate.is_some_and(|r| r != info.sample_rate) || normalize;
let dither = dither_flag.unwrap_or_else(|| auto_dither(info.sample_format, bit_depth, processed));
let out_ext = std::path::Path::new(output).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let Some(format) = soundcraft_audio_io::encode_format_for(&out_ext) else {
return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
};
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None };
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither, bwf: None };
match soundcraft_audio_io::encode(&buf, &opts).map_err(|e| e.to_string()).and_then(|b| std::fs::write(output, b).map_err(|e| e.to_string())) {
Ok(()) => {
outln!("wrote {output} ({} ch, {} Hz, {:.2} s)", buf.num_channels(), buf.sample_rate, buf.duration_secs());
@@ -179,6 +242,41 @@ fn parse_bit_depth(v: Option<&str>) -> Result<soundcraft_audio_io::BitDepth, Str
}
}
/// `--dither` / `--no-dither`: `Some` when the caller chose, `None` to decide from the depths.
fn parse_dither(args: &[String]) -> Result<Option<bool>, String> {
match (args.iter().any(|a| a == "--dither"), args.iter().any(|a| a == "--no-dither")) {
(true, true) => Err("--dither and --no-dither: choose one".into()),
(true, false) => Ok(Some(true)),
(false, true) => Ok(Some(false)),
(false, false) => Ok(None),
}
}
/// Whether `convert` dithers when not told: only when writing integer samples that cannot hold the
/// 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};
let target_bits = match target {
BitDepth::Float32 => return false,
BitDepth::Int16 => 16,
BitDepth::Int24 => 24,
BitDepth::Int32 => 32,
};
if processed {
return true;
}
match source {
SampleFormat::Int8 => target_bits < 8,
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,
}
}
/// One stderr line per media file a session refers to but could not load (the exit code is unchanged).
fn missing_warnings(missing: &[String]) -> Vec<String> {
missing.iter().map(|m| format!("warning: missing media: {m}")).collect()
@@ -212,6 +310,11 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
if let Some(s) = arg_value(args, "--end") {
p["end"] = time_arg(&s);
}
// No range given: bounce the whole session, not whatever edit selection the commands left.
if p.get("start").is_none() && p.get("end").is_none() {
p["start"] = json!(0);
p["end"] = json!(e.session().content_end().max(1));
}
match e.execute("file.bounce_mix", &p) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
@@ -243,32 +346,42 @@ fn parse_cmd_spec(spec: &str) -> Result<(String, Value), String> {
}
fn run(args: &[String]) -> ExitCode {
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--cmd", true)]).collect();
if let Err(e) = check_options(args, &options) {
return fail(e);
}
let mut e = match load_engine(args) {
Ok(e) => e,
Err(err) => return fail(err),
};
let mut i = 0;
while i < args.len() {
if args.get(i).is_some_and(|a| a == "--cmd")
&& let Some(spec) = args.get(i + 1)
{
let (id, params) = match parse_cmd_spec(spec) {
Ok(v) => v,
Err(err) => return fail(err),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => return fail(format!("{id}: {err}")),
}
i += 1;
let mut it = args.iter();
while let Some(a) = it.next() {
let spec = match a.strip_prefix("--cmd=") {
Some(inline) => inline,
None if a == "--cmd" => match it.next() {
Some(next) => next,
None => continue,
},
None => continue,
};
let (id, params) = match parse_cmd_spec(spec) {
Ok(v) => v,
Err(err) => return fail(err),
};
match e.execute(&id, &params) {
Ok(v) => outln!("{id}: {v}"),
Err(err) => return fail(format!("{id}: {err}")),
}
i += 1;
}
finish(&mut e, args)
}
fn script(args: &[String]) -> ExitCode {
let Some(path) = args.first() else { return fail("script FILE") };
let options: Vec<(&str, bool)> = ENGINE_OPTIONS.iter().copied().chain([("--keep-going", false)]).collect();
if let Err(e) = check_options(&args[1..], &options) {
return fail(e);
}
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => return fail(format!("{path}: {e}")),
@@ -398,7 +511,7 @@ struct McpArgs {
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
let mut connect = None;
let mut demo = false;
let mut roots_args = automation_args::AutomationArgs::new(None, None);
let mut roots_args = automation_args::AutomationArgs::default();
let mut it = args.iter().cloned();
while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? {
@@ -411,7 +524,9 @@ fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
_ => {}
}
}
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
let read: Vec<&std::path::Path> = roots_args.read_roots.iter().map(std::path::PathBuf::as_path).collect();
let write: Vec<&std::path::Path> = roots_args.write_roots.iter().map(std::path::PathBuf::as_path).collect();
let roots = AutomationRoots::new(&read, &write)?;
if roots.is_some() && connect.is_some() {
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
so drop --connect (or use --connect without the roots)"
@@ -437,8 +552,7 @@ fn mcp(args: &[String]) -> ExitCode {
if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone());
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
eprintln!("soundcraft-cli: files confined: {}", roots.describe());
h = h.with_automation_roots(Some(roots));
}
let mut s = Server::new(h);
@@ -511,6 +625,34 @@ mod tests {
assert!(parse_bit_depth(Some("8")).is_err());
}
#[test]
fn convert_dither_flags() {
let args = |a: &[&str]| a.iter().map(|s| s.to_string()).collect::<Vec<_>>();
assert_eq!(parse_dither(&args(&["in.wav", "out.wav"])), Ok(None));
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--dither"])), Ok(Some(true)));
assert_eq!(parse_dither(&args(&["in.wav", "out.wav", "--no-dither"])), Ok(Some(false)));
assert!(parse_dither(&args(&["in.wav", "out.wav", "--dither", "--no-dither"])).is_err());
}
#[test]
fn convert_dithers_only_when_resolution_is_lost() {
use soundcraft_audio_io::{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));
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Int24, false));
assert!(!auto_dither(SampleFormat::Int8, BitDepth::Int16, false));
// Float output never dithers.
assert!(!auto_dither(SampleFormat::Float32, BitDepth::Float32, true));
assert!(!auto_dither(SampleFormat::Int24, BitDepth::Float32, false));
// Fewer bits, a float or lossy source, or processed samples: dither.
assert!(auto_dither(SampleFormat::Int24, BitDepth::Int16, false));
assert!(auto_dither(SampleFormat::Float32, BitDepth::Int24, false));
assert!(auto_dither(SampleFormat::Compressed, BitDepth::Int16, false));
assert!(auto_dither(SampleFormat::Int32, BitDepth::Int24, false));
assert!(auto_dither(SampleFormat::Int16, BitDepth::Int16, true));
}
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
+95
View File
@@ -0,0 +1,95 @@
//! `soundcraft-cli convert` dithers only when the output loses resolution (#548), run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::Command;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-convert-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 16-bit mono WAV of `samples`, written without dither so the file holds exactly these values.
fn write_wav16(path: &Path, samples: &[i16]) {
let mut buf = AudioBuffer::new(48_000, 1, samples.len());
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();
}
fn convert(input: &Path, output: &Path, extra: &[&str]) {
let o = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"))
.env("SOUNDCRAFT_NO_PREFS", "1")
.arg("convert")
.arg(input)
.arg(output)
.args(extra)
.output()
.unwrap();
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
}
/// 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();
buf.channels[0].iter().map(|s| f64::from(*s) * 32768.0).collect()
}
#[test]
fn convert_copies_samples_when_nothing_is_lost() {
let dir = tmpdir("exact");
let (silence, tone) = (dir.join("silence.wav"), dir.join("tone.wav"));
let tone_samples: Vec<i16> = (0..4800).map(|i| (8000.0 * (f64::from(i) * 0.05).sin()) as i16).collect();
write_wav16(&silence, &[0; 4800]);
write_wav16(&tone, &tone_samples);
let want: Vec<f64> = tone_samples.iter().map(|s| f64::from(*s)).collect();
// Same depth, wider and float: every sample comes back unchanged, silence stays silent.
for (depth, name) in [("16", "s16.wav"), ("24", "s24.wav"), ("32f", "s32.wav")] {
let out = dir.join(name);
convert(&silence, &out, &["--bit-depth", depth]);
assert!(steps16(&out).iter().all(|s| *s == 0.0), "silence at {depth} is not silent");
let out = dir.join(format!("t{name}"));
convert(&tone, &out, &["--bit-depth", depth]);
assert_eq!(steps16(&out), want, "tone at {depth} changed");
}
// The default output depth (24-bit) too.
let out = dir.join("default.wav");
convert(&tone, &out, &[]);
assert_eq!(steps16(&out), want);
// Asked for, dither is still applied.
let out = dir.join("forced.wav");
convert(&silence, &out, &["--bit-depth", "16", "--dither"]);
assert!(steps16(&out).iter().any(|s| *s != 0.0), "--dither did not dither");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn convert_dithers_when_reducing_depth_unless_told_not_to() {
let dir = tmpdir("reduce");
let src = dir.join("src24.wav");
// A 24-bit file with detail below one 16-bit step.
let mut buf = AudioBuffer::new(48_000, 1, 4800);
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 dithered = dir.join("d16.wav");
convert(&src, &dithered, &["--bit-depth", "16"]);
assert!(steps16(&dithered).iter().any(|s| *s != 0.0), "24 to 16-bit was not dithered");
// Plain rounding: everything is below half a step, so it all rounds to zero.
let plain = dir.join("p16.wav");
convert(&src, &plain, &["--bit-depth", "16", "--no-dither"]);
assert!(steps16(&plain).iter().all(|s| *s == 0.0), "--no-dither still dithered");
let _ = std::fs::remove_dir_all(&dir);
}
+107 -4
View File
@@ -2,7 +2,7 @@
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
use std::io::Write;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use serde_json::Value;
@@ -27,7 +27,16 @@ fn tmpdir(tag: &str) -> PathBuf {
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
mcp_session_in(None, args, calls)
}
/// [`mcp_session`] with the server started in the working directory `cwd`.
fn mcp_session_in(cwd: Option<&Path>, args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
let mut cmd = bin();
if let Some(cwd) = cwd {
cmd.current_dir(cwd);
}
let mut child = cmd.arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
{
let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
@@ -101,6 +110,74 @@ fn mcp_automation_roots_confine_the_server() {
let _ = std::fs::remove_dir_all(base);
}
/// #874: each root flag may be given more than once. A path inside any root of its kind passes;
/// the two roots' common parent, `..` out of a root and a link out of one stay refused.
#[test]
fn mcp_automation_roots_take_several_folders() {
// Resolved, as the server shows the roots (`/var` is `/private/var` on macOS).
let base = std::fs::canonicalize(tmpdir("several")).unwrap();
for d in ["in", "in2", "out", "out2", "outside"] {
std::fs::create_dir_all(base.join(d)).unwrap();
}
for f in ["in/a.wav", "in2/b.wav", "outside/secret.wav", "parent.wav"] {
make_wav(&base.join(f));
}
let p = |f: &str| base.join(f).to_str().unwrap().to_string();
let sep = std::path::MAIN_SEPARATOR;
let calls = [
("import_audio", serde_json::json!({"path": p("in/a.wav")})),
("import_audio", serde_json::json!({"path": p("in2/b.wav")})),
// The roots' common parent is not a root.
("import_audio", serde_json::json!({"path": p("parent.wav")})),
("import_audio", serde_json::json!({"path": format!("{}{sep}..{sep}outside{sep}secret.wav", p("in2"))})),
("bounce_mix", serde_json::json!({"path": p("out/a.wav")})),
("bounce_mix", serde_json::json!({"path": p("out2/b.wav")})),
("bounce_mix", serde_json::json!({"path": p("x.wav")})),
// Read and write stay separate: a read root is not a write root, nor the other way round.
("bounce_mix", serde_json::json!({"path": p("in2/c.wav")})),
("import_audio", serde_json::json!({"path": p("out2/b.wav")})),
];
let read2 = format!("--automation-read-root={}", p("in2"));
let args = ["--automation-read-root", &p("in"), &read2, "--automation-write-root", &p("out"), "--automation-write-root", &p("out2")];
let (success, stderr, replies) = mcp_session(&args, &calls);
assert!(success, "{stderr}");
assert!(stderr.contains(&format!("read {}, {}", p("in"), p("in2"))), "{stderr}");
let errors: Vec<bool> = replies.iter().map(|r| r["result"]["isError"] == true).collect();
assert_eq!(errors, [false, false, true, true, false, false, true, true, true], "{replies:?}");
for (i, kind) in [(2, "read"), (3, "read"), (6, "write"), (7, "write"), (8, "read")] {
assert!(text(&replies[i]).contains(&format!("outside the {kind} roots ")), "{}", text(&replies[i]));
}
assert!(base.join("out/a.wav").is_file() && base.join("out2/b.wav").is_file());
assert!(!base.join("x.wav").exists() && !base.join("in2/c.wav").exists());
// A link in one root that leads out of all of them is outside.
#[cfg(unix)]
{
std::os::unix::fs::symlink(base.join("outside/secret.wav"), base.join("in2/leak.wav")).unwrap();
let (_, _, replies) = mcp_session(&args, &[("import_audio", serde_json::json!({"path": p("in2/leak.wav")}))]);
assert!(text(&replies[0]).contains("outside the read roots"), "{}", text(&replies[0]));
}
let _ = std::fs::remove_dir_all(base);
}
#[test]
fn a_bare_save_session_name_lands_in_the_working_directory_inside_the_roots() {
let work = tmpdir("bare-name");
let work_s = work.to_str().unwrap();
let calls = [
("new_session", serde_json::json!({"name": "t", "sample_rate": 48000})),
("execute", serde_json::json!({"command": "track.new", "params": {"kind": "audio", "name": "Tone", "format": "mono"}})),
("save_session", serde_json::json!({"path": "t.scraft"})),
];
let (success, stderr, replies) = mcp_session_in(Some(&work), &["--automation-read-root", work_s, "--automation-write-root", work_s], &calls);
assert!(success, "{stderr}");
for reply in &replies {
assert_eq!(reply["result"]["isError"], false, "{reply}");
}
assert!(work.join("t.scraft").is_file());
let _ = std::fs::remove_dir_all(work);
}
#[test]
fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args");
@@ -117,8 +194,9 @@ fn mcp_automation_root_flags_are_checked_up_front() {
// The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
// #874: a flag may be given again, but every folder it names must exist.
fails(&["--automation-read-root", dir, "--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
fails(&["--automation-write-root", dir, "--automation-write-root"], "needs a folder");
fails(&["--automation-read-root"], "needs a folder");
fails(&["--automation-write-root", "--demo"], "needs a folder");
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
@@ -137,3 +215,28 @@ fn usage_lists_the_root_flags() {
let help = String::from_utf8_lossy(&o.stdout).into_owned();
assert!(help.contains("--automation-read-root DIR") && help.contains("--automation-write-root DIR"), "{help}");
}
/// Without `--start`/`--end`, `--bounce` covers the whole session, not a stray edit selection
/// left by the script (a bare `19.2` in command params is 19 samples).
#[test]
fn script_bounce_without_range_covers_the_session() {
let base = tmpdir("bounce-range");
let script = base.join("cmds.txt");
std::fs::write(
&script,
concat!(
"track.new {\"count\":1,\"kind\":\"instrument\",\"name\":\"Keys\"}\n",
"midi.new_clip {\"track\":\"Keys\",\"start\":0,\"end\":{\"seconds\":19.2}}\n",
"edit.select {\"start\":0,\"end\":19.2}\n",
),
)
.unwrap();
let out = base.join("out.wav");
let o = run(&["script", script.to_str().unwrap(), "--bounce", out.to_str().unwrap()]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let stdout = String::from_utf8_lossy(&o.stdout).into_owned();
let last: Value = serde_json::from_str(stdout.lines().last().unwrap()).unwrap();
let secs = last["seconds"].as_f64().unwrap();
assert!((secs - 19.2).abs() < 0.01, "bounced {secs} s: {stdout}");
let _ = std::fs::remove_dir_all(base);
}
+130
View File
@@ -0,0 +1,130 @@
//! Unknown options and missing option values must be reported, not silently skipped (#549).
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
fn run(args: &[&str]) -> Output {
bin().args(args).output().unwrap()
}
fn tmpdir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-options-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A 0.1 s mono 48 kHz 16-bit WAV, written by hand so the test needs no fixture.
fn tone(dir: &Path) -> PathBuf {
let frames: u32 = 4800;
let data: Vec<u8> = (0..frames).flat_map(|_| [0x00, 0x10]).collect();
let (bytes, rate, ch, bits) = (data.len() as u32, 48_000u32, 1u16, 16u16);
let mut wav = Vec::new();
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + bytes).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&ch.to_le_bytes());
wav.extend_from_slice(&rate.to_le_bytes());
wav.extend_from_slice(&(rate * u32::from(ch) * u32::from(bits) / 8).to_le_bytes());
wav.extend_from_slice(&(ch * bits / 8).to_le_bytes());
wav.extend_from_slice(&bits.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&bytes.to_le_bytes());
wav.extend_from_slice(&data);
let path = dir.join("tone.wav");
std::fs::write(&path, wav).unwrap();
path
}
fn sample_format(cli_info: &str) -> String {
serde_json::from_str::<serde_json::Value>(cli_info).unwrap()["sample_format"].as_str().unwrap().to_owned()
}
#[test]
fn convert_rejects_an_unknown_option_instead_of_writing_the_file() {
let dir = tmpdir("convert-unknown");
let input = tone(&dir);
let out = dir.join("out.wav");
for bad in ["--bitdepth", "--normalise", "--bogus"] {
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
assert!(!o.status.success(), "{bad} should fail");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("unknown option {bad}")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "{bad} should not write the output");
}
}
#[test]
fn convert_rejects_an_option_whose_value_is_missing() {
let dir = tmpdir("convert-novalue");
let input = tone(&dir);
let out = dir.join("out.wav");
for bad in ["--rate", "--bit-depth"] {
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), bad]);
assert!(!o.status.success(), "{bad} should fail");
assert!(String::from_utf8_lossy(&o.stderr).contains(&format!("{bad} needs a value")), "{bad}: {}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "{bad} should not write the output");
}
}
#[test]
fn convert_accepts_the_inline_value_form() {
let dir = tmpdir("convert-inline");
let input = tone(&dir);
let out = dir.join("out.wav");
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth=32f"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = run(&["info", out.to_str().unwrap()]);
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Float32");
}
#[test]
fn convert_still_accepts_the_documented_space_separated_form() {
let dir = tmpdir("convert-space");
let input = tone(&dir);
let out = dir.join("out.wav");
let o = run(&["convert", input.to_str().unwrap(), out.to_str().unwrap(), "--bit-depth", "16"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let info = run(&["info", out.to_str().unwrap()]);
assert_eq!(sample_format(&String::from_utf8_lossy(&info.stdout)), "Int16");
}
#[test]
fn run_rejects_a_misspelled_or_value_less_bounce() {
let dir = tmpdir("run-bounce");
let out = dir.join("x.wav");
let o = run(&["run", "--cmd", "session.new", "--bounce"]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("--bounce needs a value"), "{}", String::from_utf8_lossy(&o.stderr));
let o = run(&["run", "--cmd", "session.new", "--bounces", out.to_str().unwrap()]);
assert!(!o.status.success());
assert!(String::from_utf8_lossy(&o.stderr).contains("unknown option --bounces"), "{}", String::from_utf8_lossy(&o.stderr));
assert!(!out.exists(), "nothing should be bounced");
}
#[test]
fn run_bounces_with_the_inline_value_form() {
let dir = tmpdir("run-inline");
let out = dir.join("y.wav");
let o = run(&["run", "--cmd", "session.new", &format!("--bounce={}", out.display())]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
assert!(out.exists(), "--bounce=PATH should write the file");
}
#[test]
fn run_executes_the_inline_cmd_form() {
let o = run(&["run", "--cmd=track.new", "--cmd", "track.new"]);
assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr));
let stdout = String::from_utf8_lossy(&o.stdout);
assert_eq!(stdout.lines().filter(|l| l.starts_with("track.new: ")).count(), 2, "both --cmd forms should run: {stdout}");
}
+3 -1
View File
@@ -112,8 +112,10 @@ fn open_path(app: &mut SoundApp, p: &str) {
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
// Decoded in the background so a long file doesn't freeze the window (#597).
let at = app.engine.session().edit.selection.start;
app.run("file.import_audio", serde_json::json!({"path": p, "at": at}))
soundcraft_ui_egui::import::start(app, serde_json::json!({"path": p, "at": at}));
Ok(serde_json::Value::Null)
};
if let Err(e) = r {
log::warn!("{p}: {e}");
+6
View File
@@ -55,3 +55,9 @@ sync_github_name = true
real_name = "Marco Arnulfo"
display_name = "Marco Arnulfo"
sync_github_name = false
[people.mattburlage]
display_name = "Matt Smith-Burlage"
[people.ProdByBuddha]
sync_github_name = true
+1 -1
View File
@@ -192,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")]
mod api {
use super::{AuDescriptor, AuError, Plugin, PluginInfo};
use super::{AuDescriptor, AuError, Plugin, PluginInfo, registry};
pub fn scan() -> Vec<AuDescriptor> {
registry::scan()
+1
View File
@@ -115,6 +115,7 @@ pub enum BitDepth {
Int16,
Int24,
Int32,
/// Preserve finite samples, including values above full scale; map NaN to 0 and infinities to ±1.
Float32,
}
+11 -5
View File
@@ -193,8 +193,8 @@ pub(crate) fn write_interleaved(
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(&clamp_float(s).to_le_bytes()),
(true, true) => scratch.extend_from_slice(&clamp_float(s).to_be_bytes()),
(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()),
}
@@ -206,9 +206,15 @@ pub(crate) fn write_interleaved(
Ok(())
}
/// Clamp a float sample to [-1, 1], mapping NaN to 0.
pub(crate) fn clamp_float(x: f32) -> f32 {
if x.is_nan() { 0.0 } else { x.clamp(-1.0, 1.0) }
/// 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.
+20 -6
View File
@@ -10,6 +10,8 @@ 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).
@@ -50,13 +52,25 @@ fn parse_fmt(c: &[u8]) -> Result<Fmt> {
fmt.valid_bits = fmt.bits;
if fmt.tag == WAVE_FORMAT_EXTENSIBLE {
// cbSize(2) validBits(2) channelMask(4) subFormat GUID(16)
if let (Some(valid), Some(sub)) = (le_u16(c, 18), le_u16(c, 24)) {
if valid != 0 {
fmt.valid_bits = valid;
}
fmt.tag = sub;
} else {
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)
+101
View File
@@ -0,0 +1,101 @@
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}");
}
}
}
}
+1
View File
@@ -1,6 +1,7 @@
//! Integration tests, built as one test binary: each file is a module.
mod encode_format;
mod float_headroom;
mod malformed;
mod roundtrip;
mod stream;
+38
View File
@@ -511,3 +511,41 @@ proptest! {
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);
}
+2 -2
View File
@@ -205,7 +205,7 @@ fn encode_rejects_bad_buffers_and_formats() {
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 and out-of-range samples are clamped, not errors.
// 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] {
@@ -217,7 +217,7 @@ fn encode_rejects_bad_buffers_and_formats() {
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], -1.0);
assert_eq!(out.channels[0][2], if depth == BitDepth::Float32 { -5.0 } else { -1.0 });
}
}
// Ragged buffers encode the common length.
+22 -4
View File
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream;
use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc;
use std::time::Duration;
@@ -97,9 +97,19 @@ impl Remote {
if self.conn.is_some() {
return Ok(());
}
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?;
let s =
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?;
let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
let mut last = "no addresses found".to_string();
let mut stream = None;
for sock in &socks {
match TcpStream::connect_timeout(sock, Duration::from_millis(800)) {
Ok(s) => {
stream = Some(s);
break;
}
Err(e) => last = e.to_string(),
}
}
let s = stream.ok_or_else(|| format!("cannot reach SoundCraft at {}: {last}", self.addr))?;
s.set_read_timeout(Some(Duration::from_secs(120))).map_err(|e| e.to_string())?;
let r = s.try_clone().map_err(|e| e.to_string())?;
self.conn = Some((s, BufReader::new(r)));
@@ -179,6 +189,14 @@ mod tests {
assert!(r.call("session.inspect", json!({})).is_err());
}
#[test]
fn remote_resolves_hostnames() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
let mut r = Remote::new(&format!("localhost:{port}"));
assert_eq!(r.connect(), Ok(()));
}
/// Fake app: accepts one connection per entry in `replies`, counts the request line it reads,
/// then either drops the connection (`false`) or answers ok (`true`).
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
@@ -57,7 +57,7 @@ fn fixture(name: &str) -> Fixture {
}
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read, write).unwrap();
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
}
@@ -186,7 +186,7 @@ fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
}
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> {
let roots = AutomationRoots::new(read, write).unwrap();
let roots = AutomationRoots::new(read.as_slice(), write.as_slice()).unwrap();
Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
}
+4 -1
View File
@@ -340,7 +340,6 @@ impl PluginEditor for ClapEditor {
fn idle(&mut self) -> Vec<(String, f32)> {
let raw = self.link.idle();
let _ = self.link.host().dirty.swap(false, Ordering::Relaxed);
let mut out: Vec<(String, f32)> = Vec::new();
for (cid, v) in raw {
let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue };
@@ -354,4 +353,8 @@ impl PluginEditor for ClapEditor {
}
out
}
fn state_changed(&mut self) -> bool {
self.link.host().dirty.swap(false, Ordering::Relaxed)
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ use std::sync::OnceLock;
pub fn fixture_dir() -> &'static Path {
static DIR: OnceLock<PathBuf> = OnceLock::new();
DIR.get_or_init(|| {
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let st = Command::new(cargo)
+1 -1
View File
@@ -1,4 +1,4 @@
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
# A tiny CLAP plugin used only by SoundCraft's integration tests. Not part of the
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
# with `cargo build` and loads the resulting library. Never shipped.
[package]
+5
View File
@@ -272,6 +272,11 @@ pub trait PluginEditor: Send {
/// user closed, and returns parameter edits made in the editor as `(param id, value)` in
/// SoundCraft units (ids and ranges of [`PluginInfo::params`]).
fn idle(&mut self) -> Vec<(String, f32)>;
/// True once after the plugin said its state changed in a way its parameters don't show
/// (CLAP `state.mark_dirty`), so the session stores the new state.
fn state_changed(&mut self) -> bool {
false
}
/// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
/// GUI shows it. Values are in SoundCraft units.
fn set_param(&mut self, _id: &str, _value: f32) {}
+110 -1
View File
@@ -226,16 +226,95 @@ fn resample_ratio(ch: &[Vec<f32>], ratio: f64, out_len: usize) -> Vec<Vec<f32>>
ch.iter().map(|c| resample_channel(c, ratio, out_len, &table)).collect()
}
/// Most kernel weights a [`Polyphase`] bank may hold (16 MiB of f32).
const MAX_POLYPHASE_WEIGHTS: usize = 1 << 22;
/// The windowed-sinc kernel of [`resample_channel`] precomputed for every phase of a rational
/// rate change `from` → `to` (reduced to `step` / `phases`). Output sample `n` reads input time
/// `n * step / phases`: whole part `i`, phase `p`. Its taps are input samples `i + first ..`, one
/// weight each, so every output is one dot product over contiguous memory instead of a table
/// lookup and interpolation per tap. The weights are the ones `resample_channel` computes, taps
/// beyond the kernel's half width weigh 0, and taps are summed in the same order.
struct Polyphase {
phases: u64,
step: u64,
/// Offset of the first tap from `i`.
first: i64,
taps: usize,
/// `phases * taps` weights, phase-major.
weights: Vec<f32>,
}
impl Polyphase {
/// `None` when the bank would be too large (rates with no small common ratio); the caller
/// then falls back to [`resample_channel`].
fn new(from: u32, to: u32, table: &SincTable) -> Option<Polyphase> {
let g = gcd(from, to);
let (step, phases) = (u64::from(from / g), u64::from(to / g));
let hw = table.half_width;
let first = (-hw).floor() as i64;
let last = hw.ceil() as i64 + 1;
let taps = usize::try_from(last - first + 1).ok()?;
if usize::try_from(phases).ok()?.checked_mul(taps)? > MAX_POLYPHASE_WEIGHTS {
return None;
}
let mut weights = Vec::with_capacity(taps * phases as usize);
for p in 0..phases {
let frac = p as f64 / phases as f64;
for k in first..first + taps as i64 {
let d = frac - k as f64;
weights.push(if d.abs() <= hw { table.at(d) } else { 0.0 });
}
}
Some(Polyphase { phases, step, first, taps, weights })
}
fn run(&self, x: &[f32], out_len: usize) -> Vec<f32> {
let len = x.len() as i64;
let mut out = Vec::with_capacity(out_len);
for n in 0..out_len as u64 {
let Some(pos) = n.checked_mul(self.step) else { break };
let (i, p) = ((pos / self.phases) as i64, (pos % self.phases) as usize);
let w = self.weights.get(p * self.taps..(p + 1) * self.taps).unwrap_or_default();
let start = i + self.first;
let acc = match usize::try_from(start).ok().and_then(|s| x.get(s..s + self.taps)) {
// Inside the signal: a plain dot product.
Some(xs) => xs.iter().zip(w).fold(0.0f32, |a, (v, k)| a + v * k),
// At the edges, taps outside the signal are skipped, as `resample_channel` does.
None => (0..self.taps as i64)
.filter(|k| (0..len).contains(&(start + k)))
.fold(0.0f32, |a, k| a + x.get((start + k) as usize).copied().unwrap_or(0.0) * w.get(k as usize).copied().unwrap_or(0.0)),
};
out.push(acc);
}
out.resize(out_len, 0.0);
out
}
}
fn gcd(mut a: u32, mut b: u32) -> u32 {
while b != 0 {
(a, b) = (b, a % b);
}
a.max(1)
}
/// High-quality windowed-sinc sample-rate conversion from `from` Hz to `to` Hz.
pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
if from == 0 || to == 0 || from == to {
return ch.to_vec();
}
let ratio = f64::from(to) / f64::from(from);
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
let table = SincTable::new(cutoff);
let bank = Polyphase::new(from, to, &table);
ch.iter()
.map(|c| {
let out_len = ((c.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
resample_ratio(std::slice::from_ref(c), ratio, out_len).into_iter().next().unwrap_or_default()
match &bank {
Some(b) => b.run(c, out_len),
None => resample_channel(c, ratio, out_len, &table),
}
})
.collect()
}
@@ -662,6 +741,36 @@ pub fn peak_db(ch: &[Vec<f32>]) -> f32 {
mod tests {
use super::*;
/// The polyphase bank gives what the per-tap kernel gives (it only moves where the weights
/// are computed), at common rate pairs, both directions, and at the signal's edges.
#[test]
fn polyphase_resample_matches_the_per_tap_kernel() {
let x: Vec<f32> = (0..3_000).map(|i| (i as f32 * 0.071).sin() * 0.6 + (i as f32 * 0.53).sin() * 0.3).collect();
let pairs = [(44_100, 48_000), (48_000, 44_100), (48_000, 96_000), (96_000, 48_000), (22_050, 48_000), (8_000, 44_100), (48_000, 22_050)];
for (from, to) in pairs {
let ratio = f64::from(to) / f64::from(from);
let out_len = ((x.len() as f64 * ratio).round() as usize).min(MAX_OFFLINE_SAMPLES);
let cutoff = if ratio < 1.0 { ratio * 0.94 } else { 0.97 };
let table = SincTable::new(cutoff);
let reference = resample_channel(&x, ratio, out_len, &table);
let bank = Polyphase::new(from, to, &table).expect("small common ratio");
let fast = bank.run(&x, out_len);
assert_eq!(fast.len(), reference.len(), "{from}->{to}");
let worst = fast.iter().zip(&reference).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(worst < 1e-5, "{from}->{to}: worst difference {worst}");
assert_eq!(resample(std::slice::from_ref(&x), from, to)[0], fast, "resample uses the bank");
}
// Signals shorter than the kernel are all edge.
let short = [0.5f32, -0.25, 0.75];
let table = SincTable::new(0.97);
let reference = resample_channel(&short, 48_000.0 / 44_100.0, 3, &table);
let fast = Polyphase::new(44_100, 48_000, &table).expect("bank").run(&short, 3);
assert!(fast.iter().zip(&reference).all(|(a, b)| (a - b).abs() < 1e-6), "{fast:?} {reference:?}");
// Rates with no small common ratio fall back to the per-tap kernel.
assert!(Polyphase::new(44_100, 96_001, &table).is_none());
assert_eq!(resample(&[short.to_vec()], 44_100, 96_001)[0].len(), 7);
}
#[test]
fn fade_shapes_endpoints() {
for s in FadeShape::ALL {
+2
View File
@@ -29,6 +29,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&modulation::CHORUS_INFO,
&modulation::FLANGER_INFO,
&modulation::PHASER_INFO,
&modulation::RING_INFO,
&harmonic::SAT_INFO,
&harmonic::LOFI_INFO,
&harmonic::RECT_INFO,
@@ -61,6 +62,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"chorus" => Box::new(modulation::ModFx::chorus()),
"flanger" => Box::new(modulation::ModFx::flanger()),
"phaser" => Box::new(modulation::Phaser::new()),
"ring_mod" => Box::new(modulation::RingMod::new()),
"saturator" => Box::new(harmonic::Saturator::new()),
"lofi" => Box::new(harmonic::LoFi::new()),
"rectifier" => Box::new(harmonic::Rectifier::new()),
+220 -2
View File
@@ -1,7 +1,7 @@
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser.
//! Delay and modulation effects: Mod Delay, Chorus, Flanger, Phaser, Ring Modulator.
use crate::params::{Params, choice, lin, log, param_plumbing};
use crate::util::{DelayLine, Lfo, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::util::{DelayLine, Lfo, Rng, Smoother, flush, frames_in, ms_to_samples, sane_channels, sane_sr, time_coef};
use crate::{Category, ParamInfo, Plugin, PluginInfo, Unit};
/// Per-channel LFO phase offset for stereo movement (channel 1 is offset by `spread` cycles).
@@ -459,3 +459,221 @@ impl Plugin for Phaser {
ms_to_samples(100.0, self.sr) as usize
}
}
// ---------------------------------------------------------------------------------------------
const RING_EFFECTS: &[&str] = &["Ring", "Frequency Mod", "Resonator +", "Resonator -"];
const RING_SOURCES: &[&str] = &["LFO", "Envelope", "Sample & Hold"];
const RING_MIN_HZ: f32 = 20.0;
/// Octaves the frequency moves at full modulation depth.
const RING_MOD_OCTAVES: f32 = 2.0;
/// Peak delay swing of Frequency Mod.
const RING_FM_SWING_MS: f32 = 1.0;
/// Sample-and-hold noise seed (the same sequence after every reset).
const RING_SEED: u32 = 0x5EED_1234;
static RING_PARAMS: [ParamInfo; 7] = [
choice("effect", "Effect", RING_EFFECTS, 0),
log("frequency", "Frequency", RING_MIN_HZ, 5000.0, 440.0, Unit::Hz),
lin("resonance", "Resonance", 0.0, 95.0, 50.0, Unit::Percent),
choice("mod_source", "Mod Source", RING_SOURCES, 0),
log("mod_rate", "Mod Rate", 0.05, 20.0, 1.0, Unit::Hz),
lin("mod_depth", "Mod Depth", 0.0, 100.0, 0.0, Unit::Percent),
lin("mix", "Mix", 0.0, 100.0, 100.0, Unit::Percent),
];
pub static RING_INFO: PluginInfo = PluginInfo {
id: "ring_mod",
name: "Ring Modulator",
short_name: "RingMod",
category: Category::Modulation,
params: &RING_PARAMS,
is_instrument: false,
audiosuite: true,
};
/// Ring modulation, frequency modulation and positive or negative comb resonators, with the
/// frequency moved by an LFO, an envelope follower or a sample-and-hold source.
///
/// - Ring multiplies the input by a sine carrier at the frequency.
/// - Frequency Mod reads the input through a delay that the carrier swings by ±1 ms.
/// - Resonator + and Resonator - feed the output back after one period of the frequency, with
/// a positive or a negative sign. The output is scaled by `1 - resonance`, so the peak gain at
/// resonance is unity.
///
/// At 100 % depth the modulation moves the frequency by up to two octaves either way. The
/// envelope follower moves it up only.
pub struct RingMod {
p: Params,
sr: f32,
lines: Vec<DelayLine>,
lfo: Lfo,
rng: Rng,
carrier: f32,
env: f32,
env_attack: f32,
env_release: f32,
held: f32,
hold_left: usize,
last_effect: usize,
freq: Smoother,
res: Smoother,
depth: Smoother,
mix: Smoother,
}
impl Default for RingMod {
fn default() -> Self {
Self::new()
}
}
impl RingMod {
pub fn new() -> Self {
let mut r = Self {
p: Params::new(&RING_PARAMS),
sr: 48_000.0,
lines: Vec::new(),
lfo: Lfo::new(),
rng: Rng::new(RING_SEED),
carrier: 0.0,
env: 0.0,
env_attack: 0.0,
env_release: 0.0,
held: 0.0,
hold_left: 0,
last_effect: 0,
freq: Smoother::new(440.0),
res: Smoother::new(0.0),
depth: Smoother::new(0.0),
mix: Smoother::new(1.0),
};
r.update();
r
}
fn update(&mut self) {
self.freq.set(self.p.v(1));
self.res.set(self.p.v(2) / 100.0);
self.depth.set(self.p.v(5) / 100.0);
self.mix.set(self.p.v(6) / 100.0);
}
/// Samples in one period of the lowest frequency the modulation can reach.
fn longest_period(&self) -> f32 {
self.sr / (self.p.v(1) * 2f32.powf(-RING_MOD_OCTAVES * self.p.v(5) / 100.0)).max(1.0)
}
}
impl Plugin for RingMod {
param_plumbing!(RING_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
let comb = self.sr / (RING_MIN_HZ * 2f32.powf(-RING_MOD_OCTAVES));
let max = comb.max(ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr)) as usize + 8;
self.lines = (0..sane_channels(channels))
.map(|_| {
let mut d = DelayLine::default();
d.allocate(max);
d
})
.collect();
self.env_attack = time_coef(5.0, self.sr);
self.env_release = time_coef(100.0, self.sr);
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
s.set_time(20.0, self.sr);
}
self.update();
self.reset();
}
fn reset(&mut self) {
self.lines.iter_mut().for_each(DelayLine::clear);
self.lfo.reset();
self.rng = Rng::new(RING_SEED);
self.carrier = 0.0;
self.env = 0.0;
self.held = 0.0;
self.hold_left = 0;
self.last_effect = self.p.choice(0);
for s in [&mut self.freq, &mut self.res, &mut self.depth, &mut self.mix] {
s.snap();
}
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.lines.len().min(io.len());
let frames = frames_in(io, frames, ch);
let effect = self.p.choice(0);
if effect != self.last_effect {
// The lines hold the previous effect's state.
self.lines.iter_mut().for_each(DelayLine::clear);
self.last_effect = effect;
}
let source = self.p.choice(3);
let rate = self.p.v(4);
let inc = rate / self.sr;
let hold = ((self.sr / rate.max(0.05)) as usize).max(1);
let nyq = self.sr * 0.45;
let swing = ms_to_samples(RING_FM_SWING_MS, self.sr);
for n in 0..frames {
let level = io.iter().take(ch).filter_map(|b| b.get(n)).filter(|x| x.is_finite()).fold(0.0f32, |m, x| m.max(x.abs()));
let k = if level > self.env { self.env_attack } else { self.env_release };
self.env = flush(level + (self.env - level) * k);
let lfo = Lfo::sin_at(self.lfo.tick(inc));
let m = match source {
1 => self.env.min(1.0),
2 => {
if self.hold_left == 0 {
self.held = self.rng.bipolar();
self.hold_left = hold;
}
self.hold_left -= 1;
self.held
}
_ => lfo,
};
let f = (self.freq.next() * 2f32.powf(RING_MOD_OCTAVES * self.depth.next() * m)).clamp(1.0, nyq);
let fb = self.res.next();
let mix = self.mix.next();
self.carrier += f / self.sr;
if self.carrier >= 1.0 {
self.carrier -= 1.0;
}
let carrier = Lfo::sin_at(self.carrier);
for c in 0..ch {
let (Some(buf), Some(line)) = (io.get_mut(c), self.lines.get_mut(c)) else { continue };
let Some(x) = buf.get_mut(n) else { continue };
let dry = if x.is_finite() { *x } else { 0.0 };
let wet = match effect {
1 => {
line.push(dry);
line.read_cubic(2.0 + swing * (1.0 + carrier))
}
2 | 3 => {
let sign = if effect == 2 { 1.0 } else { -1.0 };
let y = flush(dry + sign * fb * line.read_cubic(self.sr / f));
line.push(y);
y * (1.0 - fb)
}
_ => dry * carrier,
};
*x = dry * (1.0 - mix) + wet * mix;
}
}
}
fn tail_samples(&self) -> usize {
match self.p.choice(0) {
1 => ms_to_samples(2.0 * RING_FM_SWING_MS, self.sr) as usize + 4,
2 | 3 => {
// Time for the feedback to fall by 60 dB at the longest period the modulation reaches.
let fb = (self.p.v(2) / 100.0).clamp(0.0, 0.99);
let repeats = if fb < 1e-3 { 1.0 } else { (-60.0 / (20.0 * fb.log10())).min(500.0) + 1.0 };
(self.longest_period() * repeats) as usize
}
_ => 0,
}
}
}
+92
View File
@@ -524,3 +524,95 @@ fn lofi_quantizes() {
levels.dedup();
assert!(levels.len() <= 5, "{levels:?}");
}
fn ring_mod(params: &[(&str, f32)]) -> Box<dyn Plugin> {
let mut p = prepared("ring_mod");
for &(id, v) in params {
assert!(p.set_param(id, v), "ring_mod.{id}");
}
p.reset();
p
}
/// Loudest bin within two bins of `hz` in an 8192-point spectrum.
fn band_db(spec: &[f32], hz: f32) -> f32 {
let bin = (hz / (SR / 8192.0)).round() as usize;
spec[bin - 2..=bin + 2].iter().cloned().fold(f32::MIN, f32::max)
}
fn zero_crossings(x: &[f32]) -> usize {
x.windows(2).filter(|w| (w[0] < 0.0) != (w[1] < 0.0)).count()
}
#[test]
fn ring_mod_makes_sum_and_difference_tones() {
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 300.0), ("mod_depth", 0.0), ("mix", 100.0)]);
let out = run(p.as_mut(), &[sine(1000.0, 0.5, 48_000)]);
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
let (low, high, input) = (band_db(&spec, 700.0), band_db(&spec, 1300.0), band_db(&spec, 1000.0));
// The two tones fall at different places between bins, so their bin peaks differ a little.
assert!((low - high).abs() < 2.0, "700 Hz {low} dB, 1300 Hz {high} dB");
assert!(low - input > 40.0, "the 1000 Hz input is left at {input} dB against {low} dB");
}
#[test]
fn ring_mod_frequency_mod_adds_sidebands() {
let x = sine(1000.0, 0.5, 48_000);
let dry = soundcraft_dsp::spectrum::magnitude_db(&x[8192..], 8192);
assert!(band_db(&dry, 1000.0) - band_db(&dry, 1200.0) > 40.0);
let mut p = ring_mod(&[("effect", 1.0), ("frequency", 200.0), ("mix", 100.0)]);
let out = run(p.as_mut(), &[x]);
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
for hz in [800.0, 1200.0] {
assert!(band_db(&spec, 1000.0) - band_db(&spec, hz) < 20.0, "no sideband at {hz} Hz");
}
}
#[test]
fn ring_mod_resonators_ring_at_the_frequency_with_their_sign() {
// 480 Hz at 48 kHz is a 100-sample period.
for (effect, sign) in [(2.0, 1.0), (3.0, -1.0)] {
let mut p = ring_mod(&[("effect", effect), ("frequency", 480.0), ("resonance", 90.0), ("mix", 100.0)]);
let mut x = vec![0.0f32; 9600];
x[0] = 1.0;
let out = run(p.as_mut(), &[x]);
let y = &out[0];
assert!((y[0] - 0.1).abs() < 1e-4, "effect {effect}: direct {}", y[0]);
assert!((y[100] - sign * 0.09).abs() < 1e-4, "effect {effect}: first echo {}", y[100]);
assert!((y[200] - 0.081).abs() < 1e-4, "effect {effect}: second echo {}", y[200]);
assert!(y[50].abs() < 1e-6 && y[150].abs() < 1e-6, "effect {effect}: sound between the echoes");
let last = y.iter().rposition(|v| v.abs() > 0.1e-3).unwrap();
assert!(last < p.tail_samples(), "effect {effect}: ringing at {last}, tail {}", p.tail_samples());
}
}
#[test]
fn ring_mod_modulation_sources_move_the_frequency() {
// A constant input through Ring gives the carrier itself.
let dc = vec![1.0f32; 48_000];
// The envelope of a full-scale input lifts 100 Hz by one octave at 50 % depth.
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 1.0), ("mod_depth", 50.0), ("mix", 100.0)]);
let out = run(p.as_mut(), std::slice::from_ref(&dc));
let n = zero_crossings(&out[0][24_000..]);
assert!((195..=205).contains(&n), "envelope: {n} crossings in 0.5 s, 200 expected for 200 Hz");
// A 1 Hz LFO raises the frequency for its first half cycle and lowers it for the second.
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 0.0), ("mod_rate", 1.0), ("mod_depth", 100.0), ("mix", 100.0)]);
let out = run(p.as_mut(), std::slice::from_ref(&dc));
let (up, down) = (zero_crossings(&out[0][..12_000]), zero_crossings(&out[0][24_000..36_000]));
assert!(up > 3 * down, "LFO: {up} crossings rising, {down} falling");
// Sample and hold at 4 Hz keeps one frequency for each 12,000-sample step.
let mut p = ring_mod(&[("effect", 0.0), ("frequency", 100.0), ("mod_source", 2.0), ("mod_rate", 4.0), ("mod_depth", 100.0), ("mix", 100.0)]);
let out = run(p.as_mut(), std::slice::from_ref(&dc));
let mut steps = Vec::new();
for s in out[0].chunks(12_000) {
let (a, b) = (zero_crossings(&s[..6000]), zero_crossings(&s[6000..]));
assert!(a.abs_diff(b) <= 2, "sample and hold changed within a step: {a} then {b}");
steps.push(a);
}
steps.sort_unstable();
steps.dedup_by(|a, b| a.abs_diff(*b) <= 2);
assert!(steps.len() >= 3, "sample and hold steps: {steps:?}");
}
+63 -5
View File
@@ -181,11 +181,15 @@ impl Autosave {
fn create(root: &Path, name: &str) -> Result<Autosave> {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
std::fs::create_dir_all(root).map_err(|err| io_error(root, err))?;
create_folder(root).map_err(|err| io_error(root, err))?;
let mut dir = root.join(&base);
let mut n = 1;
loop {
match std::fs::create_dir(&dir) {
// Not recursive, so a folder that already exists is reported and the next name tried.
let mut builder = std::fs::DirBuilder::new();
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
match builder.create(&dir) {
Ok(()) => break,
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
n += 1;
@@ -224,9 +228,7 @@ impl Autosave {
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()))?;
std::fs::create_dir_all(&audio_dir)
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
.map_err(|err| io_error(&audio_dir, err))?;
create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
name
}
};
@@ -409,6 +411,18 @@ pub fn discard(left: &Leftover) {
let _ = std::fs::remove_dir_all(&left.dir);
}
/// Create `dir` and its missing parents under the autosave root. On Unix every folder made here is
/// owner-only (`0o700`): autosaves and the takes of a session never saved are unsaved work, and
/// with the default mode other accounts on the machine could read them. A folder that already
/// exists keeps its mode.
pub fn create_folder(dir: &Path) -> std::io::Result<()> {
let mut builder = std::fs::DirBuilder::new();
builder.recursive(true);
#[cfg(unix)]
std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
builder.create(dir)
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
@@ -534,6 +548,50 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// Autosaves are unsaved work: on Unix the folders the autosaver makes are owner-only, and an
/// autosave root that already exists keeps its mode.
#[cfg(unix)]
#[test]
fn the_autosaver_makes_owner_only_folders() {
use std::os::unix::fs::PermissionsExt as _;
let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777;
let base = folder("autosave-modes");
let mut e = Engine::default();
record(&mut e, 0.5);
// A root that doesn't exist yet is made with the first autosave, owner-only like the
// session's folder and the audio copied into it.
let new = base.join("Autosave");
let w = Autosaver::new(new.clone());
w.write(e.session_arc(), None, None);
w.flush().unwrap();
let session = std::fs::read_dir(&new).unwrap().flatten().map(|d| d.path()).next().unwrap();
assert_eq!((mode(&new), mode(&session), mode(&session.join("Audio Files"))), (0o700, 0o700, 0o700));
w.close(false);
w.flush().unwrap();
// A root that already exists keeps its mode; the folders made inside it are owner-only.
let old = base.join("old");
std::fs::create_dir_all(&old).unwrap();
std::fs::set_permissions(&old, std::fs::Permissions::from_mode(0o755)).unwrap();
let w = Autosaver::new(old.clone());
w.write(e.session_arc(), None, None);
w.flush().unwrap();
let session = std::fs::read_dir(&old).unwrap().flatten().map(|d| d.path()).next().unwrap();
assert_eq!((mode(&old), mode(&session)), (0o755, 0o700));
// A session never saved records its takes into `<name>/Audio Files` under the root. Both
// are made owner-only, and so is `Audio Files` in a take folder an earlier version left.
let takes = old.join("Untitled").join("Audio Files");
create_folder(&takes).unwrap();
assert_eq!((mode(&old.join("Untitled")), mode(&takes)), (0o700, 0o700));
let earlier = old.join("Earlier");
std::fs::create_dir_all(&earlier).unwrap();
std::fs::set_permissions(&earlier, std::fs::Permissions::from_mode(0o755)).unwrap();
create_folder(&earlier.join("Audio Files")).unwrap();
assert_eq!((mode(&earlier), mode(&earlier.join("Audio Files"))), (0o755, 0o700));
w.close(false);
w.flush().unwrap();
let _ = std::fs::remove_dir_all(&base);
}
#[test]
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
use crate::io::Take;
+48 -1
View File
@@ -140,9 +140,17 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
let sr = e.session().sample_rate;
let tracks = tracks_param(e, "audiosuite.process", p)?;
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
// Refuse before generating anything, rather than cut the clip short.
for t in tracks.iter().filter_map(|t| e.session().track(*t)).filter(|t| t.kind == TrackKind::Audio) {
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(t.channels()))?;
let channels = t.channels().max(1);
crate::io::refuse_longer(e.session(), "audiosuite.process", &t.name, "generate on", r.len(), crate::io::render_limit(channels))?;
// Each track keeps its generated buffer for the life of the command (and undo holds it), so
// cap the total samples generated across tracks, not just each one.
let frames = usize::try_from(r.len()).unwrap_or(0).min(crate::io::render_limit(channels));
budget = budget
.checked_sub(frames.saturating_mul(channels))
.ok_or_else(|| bad("audiosuite.process", "too much audio to generate in one command"))?;
}
let mut clips = Vec::new();
for track in tracks {
@@ -257,4 +265,43 @@ mod tests {
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
assert_eq!(ranges, vec![(0, 32), (32, 64)]);
}
#[test]
fn duplicate_track_ids_generate_once_not_per_listing() {
let mut e = Engine::new(Session::default());
let t = e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
e.execute("edit.select", &json!({"tracks": [t.0], "start": 0, "end": 4800})).unwrap();
// The same track eight times over: it must generate once, not eight times (#896).
let out = e
.execute(
"audiosuite.process",
&json!({"process": "signal_generator", "tracks": [t.0, t.0, t.0, t.0, t.0, t.0, t.0, t.0], "params": {"freq": 440.0}}),
)
.unwrap();
assert_eq!(out["processed"], 1);
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
}
#[test]
fn generate_refuses_a_total_budget_across_tracks() {
let mut e = Engine::new(Session::default());
// Two tracks each near the per-track limit: the second would push the total past the budget.
for n in 0..2 {
e.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&format!("T{n}")));
}
let t: Vec<_> = e.session().tracks.iter().map(|t| t.id.0).collect();
// Select both tracks so signal_generator targets them.
e.execute("edit.select", &json!({"tracks": [t[0], t[1]], "start": 0, "end": 4800})).unwrap();
e.execute("audiosuite.process", &serde_json::json!({"process": "signal_generator", "track": t[0], "params": {"freq": 440.0}})).unwrap();
assert_eq!(e.session().tracks[0].clips().len(), 1, "one track generates one clip");
// Two distinct tracks over the total budget are refused (MAX_RENDER_SAMPLES is f32 samples
// across all channels; a single track over the limit is already refused per-track).
let len = (soundcraft_mix::MAX_RENDER_SAMPLES as i64) / 3 * 2;
let out = e.execute(
"audiosuite.process",
&serde_json::json!({"process": "signal_generator", "tracks": [t[0], t[1]], "params": {"freq": 440.0}, "start": 0, "end": len}),
);
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
assert!(ok, "two tracks over budget, but got: {out:?}");
}
}
+72 -5
View File
@@ -88,7 +88,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true, fold_down?: stereo}", always, bounce),
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, fold_down?: stereo}", has_tracks, |e, p| {
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, dither?: true, fold_down?: stereo}", has_tracks, |e, p| {
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 };
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
_ => soundcraft_audio_io::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: true, bwf: None }, fold)?;
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)?;
Ok(json!({"files": files}))
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
@@ -113,9 +113,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?, replace?: false}", always, |e, p| {
let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") {
refuse_replacing(e, "file.export_session_text", std::path::Path::new(path), p)?;
crate::io::write_file(path, text.as_bytes())?;
}
Ok(json!({"text": text}))
@@ -240,9 +241,17 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
let target = import_audio_target(e, p)?;
let mut out = Vec::new();
for path in &paths {
let bytes = crate::io::read_file(path)?;
let name = std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Audio").to_string();
let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
// Audio the app decoded on a worker thread (`Engine::offer_import`) is only placed here;
// the path is checked the same way either way.
crate::file_access::check_read(std::path::Path::new(path)).map_err(crate::EngineError::Io)?;
let r = match e.take_import(std::path::Path::new(path)) {
Some(d) => crate::io::place_decoded(e, &name, d, Some(path.as_str()), target, at)?,
None => {
let bytes = crate::io::read_file(path)?;
crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?
}
};
out.push(r);
}
Ok(json!({"imported": out}))
@@ -353,6 +362,64 @@ mod tests {
path.to_string_lossy().into_owned()
}
/// Issue #597: the app decodes on a worker thread and hands the audio over; the command then
/// only places it. Audio decoded for another rate is ignored and the file is read again.
#[test]
fn import_audio_places_audio_offered_by_a_worker() {
let dir = std::env::temp_dir().join(format!("sc-import-offered-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
let rate = e.session().sample_rate.hz();
let decoded = std::thread::spawn({
let wav = wav.clone();
move || crate::io::decode_import(&wav, rate)
})
.join()
.unwrap()
.unwrap();
assert_eq!(decoded.frames(), 4_800);
e.offer_import(decoded);
// The file changes after decoding: the offered audio is placed, not the new bytes.
std::fs::write(&wav, b"not audio").unwrap();
let r = e.execute("file.import_audio", &json!({"path": wav, "at": 960})).unwrap();
assert_eq!(r["imported"][0]["frames"], 4_800);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].clips()[0].start, 960);
assert_eq!(e.session().sources[0].path, wav);
// Taken once: the next import reads the (now broken) file.
assert!(e.execute("file.import_audio", &json!({"path": wav})).is_err());
// Decoded for another rate: ignored, so the broken file is read and refused.
let good = tone_wav(&dir, "good.wav");
let other = crate::io::decode_import(&good, rate / 2).unwrap();
std::fs::write(&good, b"not audio").unwrap();
e.offer_import(other);
assert!(e.execute("file.import_audio", &json!({"path": good})).is_err());
e.drop_offered_imports();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn export_session_text_needs_replace_to_overwrite() {
let dir = std::env::temp_dir().join(format!("sc-export-text-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("info.txt");
let path_str = path.to_string_lossy().into_owned();
let mut e = Engine::default();
e.execute("file.export_session_text", &json!({"path": path_str})).unwrap();
std::fs::write(&path, "keep me").unwrap();
let err = e.execute("file.export_session_text", &json!({"path": path_str})).unwrap_err();
assert!(err.to_string().contains("replace: true"), "{err}");
assert_eq!(std::fs::read_to_string(&path).unwrap(), "keep me");
e.execute("file.export_session_text", &json!({"path": path_str, "replace": true})).unwrap();
assert_ne!(std::fs::read_to_string(&path).unwrap(), "keep me");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
+26 -1
View File
@@ -130,8 +130,19 @@ fn notes_move(e: &mut Engine, p: &Value) -> Result<Value> {
n.pitch = i64::from(n.pitch).saturating_add(ds).clamp(0, 127) as u8;
}
}
let moved: Vec<Note> = seq.notes.iter().enumerate().filter(|(i, _)| idx.contains(i)).map(|(_, n)| *n).collect();
seq.sort();
Ok(json!({"moved": idx.len()}))
// Where the moved notes ended up after sorting, so the editor can keep them selected.
let mut taken = vec![false; seq.notes.len()];
let mut indices = Vec::with_capacity(moved.len());
for m in &moved {
if let Some(j) = seq.notes.iter().enumerate().position(|(j, n)| !taken[j] && n == m) {
taken[j] = true;
indices.push(j);
}
}
indices.sort_unstable();
Ok(json!({"moved": idx.len(), "indices": indices}))
}
#[cfg(test)]
@@ -172,6 +183,20 @@ mod tests {
assert_eq!(n["notes"][0]["pitch"], 127);
}
#[test]
fn notes_move_reports_where_the_moved_notes_went() {
let mut e = Engine::default();
let t = e.session_mut().add_track(TrackKind::Instrument, ChannelFormat::Stereo, Some("Keys"));
let clip = e.execute("midi.new_clip", &json!({"track": t.0})).unwrap()["clip"].as_u64().unwrap();
for start in [0, 480, 960] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": start})).unwrap();
}
let r = e.execute("midi.notes_move", &json!({"clip": clip, "indices": [0], "ticks": 720})).unwrap();
assert_eq!(r["indices"], json!([1]));
let notes = e.execute("midi.notes", &json!({"clip": clip})).unwrap();
assert_eq!(notes["notes"][1]["start"], 720);
}
#[test]
fn rejects_audio_tracks() {
let mut e = Engine::default();
+91 -8
View File
@@ -697,23 +697,35 @@ fn clip_gain_to_vol(e: &mut Engine, p: &Value) -> Result<Value> {
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let base = tr.mixer.volume_db;
// An existing lane overrides the fader, so the clip gain rides on top of the lane value.
let old = tr.lane(&AutoParam::Volume).cloned();
let level = |at: i64| old.as_ref().map_or(base, |l| l.value_at(at, base));
let mut pins = Vec::new();
let mut pts = Vec::new();
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
if c.gain_env.is_empty() {
pts.push((c.start, base + c.gain_db));
pts.push((c.end() - 1, base + c.gain_db));
} else {
for (o, db) in &c.gain_env {
pts.push((c.start + o, base + c.gain_db + db));
}
// Pin the unchanged level just outside the clip so the neighbouring segments keep their shape.
if c.start > 0 {
pins.push((c.start - 1, level(c.start - 1)));
}
pins.push((c.end(), level(c.end())));
// The clip edges always get a point, so the envelope's first and last values hold out to them.
let mut ats = vec![c.start, c.end() - 1];
ats.extend(c.gain_env.iter().map(|&(o, _)| c.start + o).filter(|&at| at > c.start && at < c.end() - 1));
// The lane's own points inside the clip carry the clip gain too.
if let Some(l) = &old {
ats.extend(l.points.iter().map(|pt| pt.at).filter(|&at| at > c.start && at < c.end() - 1));
}
for at in ats {
pts.push((at, level(at) + c.gain_db + env_db(&c.gain_env, at - c.start)));
}
c.gain_db = 0.0;
c.gain_env.clear();
}
}
let lane = tr.lane_mut(&AutoParam::Volume);
for (at, v) in pts {
// Pins first, so a neighbouring clip's converted points win where they meet.
for (at, v) in pins.into_iter().chain(pts) {
lane.set_point(at, v);
}
}
@@ -872,6 +884,77 @@ mod tests {
}
}
#[test]
fn clip_gain_to_volume_keeps_the_existing_lane() {
let clip_gain_to_volume = |points: &[(i64, f32)], env: Vec<(i64, f32)>| {
let (mut e, t) = tone_session(points);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = env;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(clip).unwrap().1.gain_db == 0.0);
(before, after)
};
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], Vec::new());
let delta = rms_db(&before, &after);
assert!(delta.abs() < 0.02, "level changed by {delta} dB");
// A moving lane keeps its shape, and its points inside the clip carry the gain.
let (before, after) = clip_gain_to_volume(&[(0, -12.0), (4096, -6.0), (8192, -12.0)], Vec::new());
for (start, end) in [(0, 2048), (2048, 4096), (4096, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
// The clip's own gain envelope rides on the lane value as well.
let (before, after) = clip_gain_to_volume(&[(0, -6.0), (8192, -6.0)], vec![(2000, 0.0), (6000, -10.0)]);
for (start, end) in [(3900, 4100), (5000, 5200)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_holds_the_envelope_ends_out_to_the_clip_edges() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
let c = e.session_mut().find_clip_mut(clip).unwrap();
c.gain_db = 6.0;
c.gain_env = vec![(2000, 0.0), (6000, -10.0)];
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 0, "end": 8192})).unwrap();
let after = rendered(e.session());
for (start, end) in [(0, 1000), (1000, 2000), (6000, 7000), (7000, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_to_volume_leaves_audio_outside_the_clip_alone() {
// Three clips in a row; only the middle one is converted, and the lane extends past it on both sides.
let (mut e, t) = tone_session(&[(0, -6.0), (16384, -6.0)]);
let (middle, last) = (e.session_mut().new_clip_id(), e.session_mut().new_clip_id());
let pl = e.session_mut().track_mut(t).unwrap().playlist_mut().unwrap();
if let Some(c) = pl.clips.first_mut() {
c.length = 2048;
}
pl.clips.push(Clip::audio(middle, "mid", SourceId(910), 2048, 2048, 4096));
pl.clips.push(Clip::audio(last, "end", SourceId(910), 6144, 6144, 2048));
e.session_mut().find_clip_mut(middle).unwrap().gain_db = 6.0;
let before = rendered(e.session());
e.execute("automation.clip_gain_to_volume", &json!({"track": t.0, "start": 2048, "end": 6144})).unwrap();
let after = rendered(e.session());
assert!(e.session().find_clip(middle).unwrap().1.gain_db == 0.0);
for (start, end) in [(0, 2040), (2056, 6136), (6152, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.1, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn insert_params_rejects_unknown_and_non_numeric_keys() {
let mut e = Engine::default();
+30 -1
View File
@@ -38,6 +38,27 @@ mod view_more;
mod vst3;
mod zoom_toggle;
#[cfg(test)]
mod panic_probe {
use super::*;
use crate::cmd;
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "test.panic_probe", "Panic Probe", [], None, "{}", always, probe)]
}
fn probe(e: &mut Engine, p: &Value) -> Result<Value> {
if p.get("panic").is_none() {
return Ok(Value::Null);
}
e.clipboard.length = 123;
e.message("probe");
e.transport_requests.push(crate::TransportRequest::Play);
e.last_selection = Some((soundcraft_time::Range::new(0, 10), Vec::new()));
panic!("probe")
}
}
pub use file::bounce_job;
pub(crate) use edit::remember_selection;
@@ -136,6 +157,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(sampler::specs());
v.extend(video::specs());
v.extend(zoom_toggle::specs());
#[cfg(test)]
v.extend(panic_probe::specs());
v
})
}
@@ -224,13 +247,19 @@ pub fn tracks_param(e: &Engine, cmd: &str, p: &Value) -> Result<Vec<TrackId>> {
if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
let s = e.session();
let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for v in arr {
let id = match (v.as_u64(), v.as_str()) {
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
(_, Some(name)) => s.track_by_name(name).map(|t| t.id),
_ => None,
};
out.push(id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?);
let id = id.ok_or_else(|| bad(cmd, format!("unknown track {v}")))?;
// Duplicates multiply the work (each entry renders and keeps its own result), so drop
// repeated ids instead of letting them blow the budget.
if seen.insert(id) {
out.push(id);
}
}
return Ok(out);
}
+22 -1
View File
@@ -48,6 +48,15 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
Some(Value::String(s)) => Some(b64::decode(s).ok_or_else(|| bad(CMD, "`state` is not valid base64 (or is too large)"))?),
Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),
};
let n = state.as_ref().map_or(0, Vec::len);
// Plugins report changes that leave their state as it was: don't modify the session for those.
let stored = e.session().track(t).and_then(|tr| match slot {
Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),
None => tr.instrument.as_ref(),
});
if stored.is_some_and(|ins| ins.state_bytes().as_deref() == state.as_deref().filter(|b| !b.is_empty())) {
return Ok(json!({"bytes": n}));
}
let s = e.session_mut();
let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;
let ins = match slot {
@@ -55,7 +64,6 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
None => tr.instrument.as_mut(),
}
.ok_or_else(|| bad(CMD, "empty slot"))?;
let n = state.as_ref().map_or(0, Vec::len);
match state {
Some(bytes) => ins.set_state_bytes(&bytes),
None => ins.state = None,
@@ -97,6 +105,19 @@ mod tests {
assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);
}
#[test]
fn storing_the_same_state_again_changes_nothing() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "plugin": "gain", "slot": 0})).unwrap();
let blob = b64::encode(b"same");
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
let rev = e.revision;
e.execute("mix.insert_state", &json!({"track": t, "slot": 0, "state": blob})).unwrap();
assert_eq!(e.revision, rev);
}
#[test]
fn hostile_params_are_errors() {
let mut e = Engine::default();
+52 -5
View File
@@ -5,7 +5,7 @@ use super::{
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -414,6 +414,8 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.split_into_mono", p)?;
let s = e.session_mut();
let mut out = Vec::new();
let mut mono_sources = std::collections::BTreeMap::new();
let mut copied_samples = 0usize;
for t in tracks {
let Some(src) = s.track(t).cloned() else { continue };
if src.channels() < 2 || src.kind != TrackKind::Audio {
@@ -429,12 +431,46 @@ fn split_mono(e: &mut Engine, p: &Value) -> Result<Value> {
let mut clips = src.clips().to_vec();
for c in &mut clips {
c.id = s.new_clip_id();
// Mono tracks read channel 0 of the source; record the channel in the name for now.
if let ClipContent::Audio { source, .. } = &mut c.content {
let audio =
s.pool.get(*source).ok_or_else(|| bad("track.split_into_mono", format!("audio unavailable for clip `{}`", c.name)))?;
let channels = audio.buffer.channels.len();
if channels == 0 {
return Err(bad("track.split_into_mono", format!("audio has no channels for clip `{}`", c.name)));
}
// Match the renderer's channel wrapping when a track is wider than its source.
// An already-mono source can be shared without copying it.
if channels > 1 {
let key = (*source, ch % channels);
let mono = if let Some(&id) = mono_sources.get(&key) {
id
} else {
let samples =
audio.buffer.channels.get(key.1).ok_or_else(|| bad("track.split_into_mono", "source channel unavailable"))?;
copied_samples = copied_samples
.checked_add(samples.len())
.filter(|&n| n <= crate::io::MAX_IMPORT_SAMPLES)
.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 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);
if let Some(source) = s.sources.iter_mut().find(|source| source.id == id) {
source.time_reference = time_reference;
}
mono_sources.insert(key, id);
id
};
*source = mono;
}
}
c.name = format!("{}.{suffix}", c.name);
}
if let Some(tr) = s.track_mut(id) {
tr.mixer.pan = vec![if ch == 0 { -1.0 } else { 1.0 }];
tr.comments = format!("source_channel={ch}");
if let Some(pl) = tr.playlist_mut() {
pl.clips = clips;
}
@@ -704,15 +740,26 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
fn rename(e: &mut Engine, p: &Value) -> Result<Value> {
let t = track_param(e, "track.rename", p, "track")?.ok_or_else(|| bad("track.rename", "`track` required"))?;
let name = str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad("track.rename", "`name` required"))?;
let s = e.session_mut();
// Validate the stored name, including its existing 64-character limit.
let name: String = name.chars().take(64).collect();
let s = e.session();
if s.tracks.iter().any(|x| x.name == name && x.id != t) {
return Err(bad("track.rename", format!("a track named `{name}` already exists")));
}
let name: String = name.chars().take(64).collect();
let bus = s.track(t).and_then(|track| match (track.kind, &track.mixer.input) {
(TrackKind::Folder, Route::Bus(bus)) => Some(*bus),
_ => None,
});
let track = s.track(t).ok_or_else(|| bad("track.rename", "unknown track"))?;
if track.name == name
&& bus.is_none_or(|id| {
let expected = unique_folder_bus_name(s, &format!("{name} Bus"), Some(id));
s.bus(id).is_none_or(|bus| bus.name == expected)
})
{
return Ok(json!({"name": name}));
}
let s = e.session_mut();
if let Some(track) = s.track_mut(t) {
track.name.clone_from(&name);
}
+173 -7
View File
@@ -149,9 +149,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
let old = part.start;
part.trim_start_to(isect.start);
rebase_midi_start(&mut part, old, t);
part.fade_in = Fade::default();
}
if isect.end < part.end() {
part.trim_end_to(isect.end);
part.fade_out = Fade::default();
}
part.start -= range.start;
out.push(part);
@@ -405,7 +407,13 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
n
}
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file.
/// Source samples covered by `length` timeline samples of a clip with the given `stretch`.
fn source_len(length: Samples, stretch: f64) -> Samples {
if stretch.is_finite() && stretch > 0.0 && (stretch - 1.0).abs() >= 1e-9 { (length as f64 / stretch).round() as Samples } else { length }
}
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file,
/// either touching (a split) or with an empty gap that matches the gap in the source (a cut).
pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
pl.sort();
@@ -413,13 +421,20 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
let mut healed = 0;
for c in pl.clips.drain(..) {
if let Some(prev) = out.last_mut() {
let contiguous = prev.end() == c.start && c.start >= range.start && c.start <= range.end;
let contiguous = c.start >= prev.end() && prev.end() >= range.start && c.start <= range.end;
let gap = if contiguous { c.start.saturating_sub(prev.end()) } else { 0 };
let same = match (&prev.content, &c.content) {
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => a == b && oa + prev.length == *ob,
(ClipContent::Audio { source: a, offset: oa }, ClipContent::Audio { source: b, offset: ob }) => {
a == b && oa.saturating_add(source_len(prev.length.saturating_add(gap), prev.stretch)) == *ob
}
_ => false,
};
if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 {
prev.length += c.length;
let same_playback = prev.muted == c.muted && (prev.stretch - c.stretch).abs() < 1e-9 && (prev.gain_db - c.gain_db).abs() < 1e-6;
if contiguous && same && same_playback {
if !prev.gain_env.is_empty() || !c.gain_env.is_empty() {
join_gain_env(prev, &c);
}
prev.length = c.end().saturating_sub(prev.start);
prev.fade_out = c.fade_out;
healed += 1;
continue;
@@ -431,6 +446,24 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
healed
}
/// Give `l` the gain envelope of `l` followed by `r`, which starts at or after where `l` ends, so
/// every sample keeps its gain. Call it before `l.length` grows to cover `r`.
fn join_gain_env(l: &mut Clip, r: &Clip) {
let cut = l.length;
let seam = r.start.saturating_sub(l.start);
let last = cut.saturating_sub(1);
let (end_db, start_db) = (l.env_db_at(last), r.env_db_at(0));
l.gain_env.retain(|&(at, _)| at < cut);
l.gain_env.extend(r.gain_env.iter().filter(|&&(at, _)| at >= 0).map(|&(at, db)| (at.saturating_add(seam), db)));
// Pin both sides of the seam where the joined points would interpolate across it.
for (at, db) in [(seam, start_db), (last, end_db)] {
if (l.env_db_at(at) - db).abs() > 1e-4 {
let i = l.gain_env.partition_point(|&(t, _)| t < at);
l.gain_env.insert(i, (at, db));
}
}
}
/// Move clips by `delta`, overwriting what they land on; `dest` sends whole tracks of them
/// elsewhere (see [`destinations`]).
pub fn move_clips(s: &mut Session, ids: &[ClipId], delta: Samples, dest: &[(TrackId, TrackId)]) -> usize {
@@ -812,8 +845,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh
pub fn delete_fades(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;
let mut touched: HashSet<ClipId> = HashSet::new();
for c in &mut pl.clips {
if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) {
touched.insert(c.id);
if c.fade_in.len > 0 || c.fade_out.len > 0 {
n += 1;
}
@@ -821,11 +856,12 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
c.fade_out = Fade::default();
}
}
// Overlapping (crossfaded) neighbours are trimmed back to the midpoint.
// Overlapping (crossfaded) neighbours in the range are trimmed back to the midpoint; other overlaps stay as they are.
pl.sort();
for i in 0..pl.clips.len().saturating_sub(1) {
let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue };
if a_end > b_start {
let in_range = pl.clips.get(i).is_some_and(|a| touched.contains(&a.id)) || pl.clips.get(i + 1).is_some_and(|b| touched.contains(&b.id));
if in_range && a_end > b_start {
let mid = b_start + (a_end - b_start) / 2;
if let Some(a) = pl.clips.get_mut(i) {
a.trim_end_to(mid);
@@ -870,6 +906,20 @@ mod tests {
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()
}
#[test]
fn copy_range_resets_fades_at_cut_edges() {
let (mut s, t) = session_with_clip();
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
}
let fades = |r: Range| -> Vec<(Samples, Samples)> { copy_range(&s, t, r).iter().map(|c| (c.fade_in.len, c.fade_out.len)).collect() };
assert_eq!(fades(Range::new(2000, 3000)), vec![(0, 0)]);
assert_eq!(fades(Range::new(0, 3000)), vec![(100, 0)]);
assert_eq!(fades(Range::new(2000, 6000)), vec![(0, 100)]);
assert_eq!(fades(Range::new(0, 6000)), vec![(100, 100)]);
}
#[test]
fn separate_splits_and_heal_rejoins() {
let (mut s, t) = session_with_clip();
@@ -880,6 +930,103 @@ mod tests {
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
}
#[test]
fn heal_rejoins_cut_separation() {
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
// A selection that does not span the whole gap leaves it alone.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
assert_eq!(heal(&mut s, t, Range::new(2500, 3500)), 0);
// Shuffled pieces no longer line up with their source and stay apart.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), true);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
// Moved apart: the timeline gap no longer matches the source gap.
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips[1].start = 3500;
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
// Overlapping neighbours whose offsets line up are not a separation.
let (mut s, t) = session_with_clip();
assert_eq!(separate_at(&mut s, t, 3000), 1);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips[1].start = 2500;
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
assert_eq!(clips(&s, t).len(), 2);
}
#[test]
fn heal_skips_clips_with_different_playback() {
for tweak in [(|c: &mut Clip| c.muted = true) as fn(&mut Clip), |c: &mut Clip| c.stretch = 1.5] {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
tweak(&mut pl.clips[1]);
}
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 0);
assert_eq!(s.track(t).unwrap().clips().len(), 2);
}
}
#[test]
fn heal_keeps_each_side_gain_envelope() {
let cases: [(Vec<(Samples, f32)>, Vec<(Samples, f32)>); 4] = [
(vec![(500, -3.0), (2500, -12.0)], vec![]),
(vec![], vec![(0, -6.0), (1000, 0.0)]),
(vec![(0, 0.0), (1999, -20.0)], vec![(-100, 4.0), (300, -9.0)]),
(vec![(500, -3.0), (2500, -12.0)], vec![(0, -9.75), (500, -12.0)]),
];
for (left_env, right_env) in cases {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 3000);
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
pl.clips[0].gain_env = left_env;
pl.clips[1].gain_env = right_env;
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
let healed = &s.track(t).unwrap().clips()[0];
for rel in 0..4000 {
let want = if rel < 2000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
}
}
}
#[test]
fn heal_keeps_gain_envelopes_across_a_gap() {
let (mut s, t) = session_with_clip();
clear_range(&mut s, t, Range::new(2000, 3000), false);
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).unwrap();
pl.clips[0].gain_env = vec![(0, 0.0), (999, -6.0)];
pl.clips[1].gain_env = vec![(0, -12.0), (1999, -3.0)];
let (l, r) = (pl.clips[0].clone(), pl.clips[1].clone());
assert_eq!(heal(&mut s, t, Range::new(1500, 3500)), 1);
let healed = &s.track(t).unwrap().clips()[0];
assert_eq!(healed.length, 4000);
for rel in (0..1000).chain(2000..4000) {
let want = if rel < 1000 { l.gain_at(rel) } else { r.gain_at(rel - 2000) };
assert!((healed.gain_at(rel) - want).abs() < 1e-4, "sample {rel}: {} != {want}", healed.gain_at(rel));
}
}
#[test]
fn split_then_heal_rejoins_stretched_clip() {
let (mut s, t) = session_with_clip();
if let Some(c) = s.track_mut(t).and_then(|t| t.playlist_mut()).and_then(|p| p.clips.first_mut()) {
c.stretch = 2.0;
}
assert_eq!(separate_at(&mut s, t, 3000), 1);
assert_eq!(clips(&s, t), vec![(1000, 2000, 1000), (3000, 2000, 2000)]);
assert_eq!(heal(&mut s, t, Range::new(0, 10_000)), 1);
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]);
}
#[test]
fn clear_slip_and_shuffle() {
let (mut s, t) = session_with_clip();
@@ -1057,4 +1204,23 @@ mod tests {
assert!(got.windows(2).all(|w| w[0].0 <= w[1].0), "playlist not sorted: {got:?}");
assert!(got.iter().all(|c| c.1 > 0), "empty clip: {got:?}");
}
#[test]
fn delete_fades_leaves_unrelated_overlaps_alone() {
let (mut s, t) = session_with_clip();
let ids: Vec<ClipId> = (0..4).map(|_| s.new_clip_id()).collect();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.clear();
for (id, start, len) in [(ids[0], 0, 1100), (ids[1], 1000, 1000), (ids[2], 5000, 1100), (ids[3], 6000, 1000)] {
let mut c = Clip::audio(id, "c", SourceId(500), start, start, len);
c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 };
pl.clips.push(c);
}
}
assert_eq!(delete_fades(&mut s, t, Range::new(900, 1200)), 2);
assert_eq!(clips(&s, t), vec![(0, 1050, 0), (1050, 950, 1050), (5000, 1100, 5000), (6000, 1000, 6000)]);
let c = s.track(t).unwrap().clips();
assert_eq!((c[1].fade_in.len, c[2].fade_out.len, c[3].fade_in.len), (0, 100, 100));
}
}
+109 -40
View File
@@ -1,15 +1,16 @@
//! The folders automation may read and write.
//!
//! `soundcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in
//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]:
//! files an agent's commands reach; each flag may be given more than once (#874). [`AutomationRoots`]
//! holds the folders of each kind. While they are in force, every path a command reads goes through
//! [`check_read`] and every path it writes through [`check_write`]:
//!
//! - The path is made absolute against the working directory, as the file system reads it. Its
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
//! `/work`), without regard to case on Windows.
//! - The result must lie inside one of the roots of its kind, compared by whole components
//! (`/work2` isn't inside `/work`), without regard to case on Windows.
//! - Read and write access are separate: a root given alone grants only its own kind of access.
//!
//! Roots are in force on a thread inside [`confine`], and in the whole process after
@@ -32,11 +33,11 @@ pub enum Access {
Write,
}
/// The folders automation may read and write; a root left out grants no access of its kind.
/// The folders automation may read and write; a kind with no root grants no access of its kind.
#[derive(Debug)]
pub struct AutomationRoots {
read: Option<Root>,
write: Option<Root>,
read: Vec<Root>,
write: Vec<Root>,
}
#[derive(Debug)]
@@ -62,48 +63,61 @@ impl Root {
}
impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
/// (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() {
/// The roots `read` and `write`, which must be existing folders (one given twice counts once);
/// `None` when neither kind has any (nothing is confined then).
pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
if read.is_empty() && write.is_empty() {
return Ok(None);
}
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
Ok(Some(Arc::new(Self { read, write })))
let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
for path in paths {
let root = Root::open(path, access)?;
if !roots.iter().any(|r| r.resolved == root.resolved) {
roots.push(root);
}
}
Ok(roots)
};
Ok(Some(Arc::new(Self { read: open(read, Access::Read)?, write: open(write, Access::Write)? })))
}
/// The read root, absolute with its links followed.
pub fn read_root(&self) -> Option<&Path> {
self.read.as_ref().map(|r| r.shown.as_path())
/// The roots for people: `read A, B; write C` (`none` for a kind without any).
pub fn describe(&self) -> String {
let list = |roots: &[Root]| {
if roots.is_empty() {
return "none".to_string();
}
roots.iter().map(|r| r.shown.display().to_string()).collect::<Vec<_>>().join(", ")
};
format!("read {}; write {}", list(&self.read), list(&self.write))
}
/// The write root, absolute with its links followed.
pub fn write_root(&self) -> Option<&Path> {
self.write.as_ref().map(|r| r.shown.as_path())
}
/// May the file or folder at `path` be read (or written)? `Err` says why not.
/// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
/// of that kind. `Err` says why not.
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
let path = path.as_ref();
let shown_path = path.display();
let root = match access {
Access::Read => self.read.as_ref(),
Access::Write => self.write.as_ref(),
let roots = match access {
Access::Read => &self.read,
Access::Write => &self.write,
};
let Some(Root { resolved: folder, shown }) = root else {
if roots.is_empty() {
return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")),
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {shown_path}")),
};
};
if inside(&resolve(path)?, folder) {
}
let resolved = resolve(path)?;
if roots.iter().any(|r| inside(&resolved, &r.resolved)) {
return Ok(());
}
let root = shown.display();
match access {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
let shown: Vec<String> = roots.iter().map(|r| r.shown.display().to_string()).collect();
match (shown.as_slice(), access) {
([root], Access::Read) => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")),
([root], Access::Write) => Err(format!("automation path rejected: outside the write root {root}: {shown_path}")),
(_, Access::Read) => Err(format!("automation path rejected: outside the read roots {}: {shown_path}", shown.join(", "))),
(_, Access::Write) => Err(format!("automation path rejected: outside the write roots {}: {shown_path}", shown.join(", "))),
}
}
}
@@ -280,7 +294,7 @@ mod tests {
}
std::fs::write(base.join("in/a.scraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.scraft"), "x").unwrap();
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
let roots = AutomationRoots::new(&[&base.join("in")], &[&base.join("out")]).unwrap().unwrap();
(base, roots)
}
@@ -340,18 +354,18 @@ mod tests {
#[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing");
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
let e = AutomationRoots::new(&[&base.join("nope")], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let e = AutomationRoots::new(None, Some(&base.join("in/a.scraft"))).unwrap_err();
let e = AutomationRoots::new(&[], &[&base.join("in/a.scraft")]).unwrap_err();
assert!(
e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}"
);
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none());
assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
// One root alone grants only its own access.
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
let read_only = AutomationRoots::new(&[&base.join("in")], &[]).unwrap().unwrap();
let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base);
@@ -388,11 +402,66 @@ mod tests {
assert_eq!(roots.check(s(base.join("in/alias/x.scraft")), Access::Read), Ok(()));
// A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link")));
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
let linked = AutomationRoots::new(&[&base.join("in-link")], &[]).unwrap().unwrap();
assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base);
}
/// #874: several roots of a kind: a path inside any of them passes, one inside none is refused
/// naming them all, and read and write stay separate.
#[test]
fn several_roots_of_a_kind_each_grant_their_access() {
let (base, _) = folders("several");
std::fs::create_dir_all(base.join("out2")).unwrap();
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
std::fs::write(base.join("parent.scraft"), "x").unwrap();
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[&base.join("out"), &base.join("out2")]).unwrap().unwrap();
assert_eq!(roots.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
assert_eq!(roots.check(s(base.join("in2/b.scraft")), Access::Read), Ok(()));
let e = roots.check(s(base.join("outside/secret.scraft")), Access::Read).unwrap_err();
assert!(e.starts_with("automation path rejected: outside the read roots ") && e.contains(", "), "{e}");
// Two sibling roots don't make their parent a root.
assert!(roots.check(s(base.join("parent.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.clone()), Access::Read).is_err());
assert_eq!(roots.check(s(base.join("out/x.wav")), Access::Write), Ok(()));
assert_eq!(roots.check(s(base.join("out2/new/x.wav")), Access::Write), Ok(()));
assert!(roots.check(s(base.join("outside/x.wav")), Access::Write).unwrap_err().contains("outside the write roots"));
assert!(roots.check(s(base.join("x.wav")), Access::Write).is_err());
assert!(roots.check(s(base.join("in2/b.scraft")), Access::Write).is_err());
assert!(roots.check(s(base.join("out2/x.wav")), Access::Read).is_err());
// `..` out of one root is outside, also when it lands next to another root.
let sep = std::path::MAIN_SEPARATOR;
let up = format!("{}{sep}..{sep}outside{sep}secret.scraft", s(base.join("in2")));
assert!(roots.check(up, Access::Read).is_err());
// `..` from one root into another stays inside.
let across = format!("{}{sep}..{sep}in{sep}a.scraft", s(base.join("in2")));
assert_eq!(roots.check(across, Access::Read), Ok(()));
// A folder given twice (here once through `..`) counts once.
let twice = AutomationRoots::new(&[&base.join("in"), &base.join("in/sub/..")], &[]).unwrap().unwrap();
assert!(!twice.describe().contains(", ") && twice.describe().ends_with("; write none"), "{}", twice.describe());
// A missing folder among several is an error.
let e = AutomationRoots::new(&[&base.join("in"), &base.join("nope")], &[]).unwrap_err();
assert!(e.starts_with("cannot open the automation read root"), "{e}");
let _ = std::fs::remove_dir_all(base);
}
/// A link inside one root that leads into another root of the same kind stays inside; one that
/// leads out of all of them is outside.
#[cfg(unix)]
#[test]
fn links_between_several_roots() {
let (base, _) = folders("several-links");
std::fs::write(base.join("in2/b.scraft"), "x").unwrap();
std::os::unix::fs::symlink(base.join("in2/b.scraft"), base.join("in/to-in2.scraft")).unwrap();
std::os::unix::fs::symlink(base.join("outside/secret.scraft"), base.join("in2/leak.scraft")).unwrap();
std::os::unix::fs::symlink(&base, base.join("in2/up")).unwrap();
let roots = AutomationRoots::new(&[&base.join("in"), &base.join("in2")], &[]).unwrap().unwrap();
assert_eq!(roots.check(s(base.join("in/to-in2.scraft")), Access::Read), Ok(()));
assert!(roots.check(s(base.join("in2/leak.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
assert!(roots.check(s(base.join("in2/up/outside/secret.scraft")), Access::Read).unwrap_err().contains("outside the read roots"));
let _ = std::fs::remove_dir_all(base);
}
#[cfg(windows)]
#[test]
fn windows_ignores_case_and_refuses_devices() {
+235 -30
View File
@@ -75,7 +75,10 @@ pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf,
/// Create `dir` and its parents, inside the automation write root when there is one.
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
let dir = dir.as_ref();
crate::file_access::check_write(dir).map_err(EngineError::Io)?;
// The empty folder of a bare file name ("t.scraft") is the working directory, which the root
// check would refuse as an empty path.
let checked = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
crate::file_access::check_write(checked).map_err(EngineError::Io)?;
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
}
@@ -114,6 +117,11 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, sr);
buf.sample_rate = sr;
}
add_source_audio(s, name, Arc::new(SourceAudio::new(buf)), path, format)
}
/// Register audio already at the session rate, with its peaks built, as a new source.
fn add_source_audio(s: &mut Session, name: &str, audio: Arc<SourceAudio>, path: Option<&str>, format: FileFormat) -> SourceId {
let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path {
@@ -124,14 +132,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
id,
name: stem.clone(),
path: stored,
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
frames: buf.frames() as u64,
sample_rate: sr,
channels: u16::try_from(audio.buffer.num_channels()).unwrap_or(u16::MAX),
frames: audio.frames() as u64,
sample_rate: s.sample_rate.hz(),
format,
time_reference: 0,
unsaved: true,
});
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
s.pool.insert(id, audio);
id
}
@@ -144,20 +152,69 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
u64::try_from((u128::from(stamp) * to + from / 2) / from).unwrap_or(u64::MAX)
}
/// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
/// An audio file decoded, resampled to a session rate and given its waveform peaks: everything an
/// import costs except placing the clip. Making one needs no engine, so the app makes it on a
/// worker thread ([`decode_import`]) and hands it over with [`Engine::offer_import`] before it
/// runs `file.import_audio`, which then only places the clip (issue #597).
#[derive(Debug, Clone)]
pub struct DecodedImport {
pub(crate) key: PathBuf,
pub(crate) rate: u32,
info: soundcraft_audio_io::AudioInfo,
audio: Arc<SourceAudio>,
}
impl DecodedImport {
/// Frames at the session rate it was decoded for.
pub fn frames(&self) -> usize {
self.audio.frames()
}
}
/// Reads and decodes the audio file at `path` for a session at `rate`. Thread-safe and slow (the
/// whole file is read, decoded, resampled and given peaks): run it off the UI thread.
pub fn decode_import(path: &str, rate: u32) -> Result<DecodedImport> {
let bytes = read_file(path)?;
let name = Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("Audio");
let (info, audio) = decode_import_bytes(name, &bytes, rate)?;
Ok(DecodedImport { key: file_key(Path::new(path)), rate, info, audio })
}
fn decode_import_bytes(name: &str, bytes: &[u8], rate: u32) -> Result<(soundcraft_audio_io::AudioInfo, Arc<SourceAudio>)> {
let ext = Path::new(name).extension().and_then(OsStr::to_str).map(str::to_ascii_lowercase);
let (info, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let buf = at_rate(buf, e.session().sample_rate.hz(), MAX_IMPORT_SAMPLES)
.ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
let buf = at_rate(buf, rate, MAX_IMPORT_SAMPLES).ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
if buf.frames() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
let channels = buf.num_channels();
Ok((info, Arc::new(SourceAudio::new(buf))))
}
/// Decode and place an audio file. With no target track, a new track of matching width is made.
pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let (info, audio) = decode_import_bytes(name, bytes, e.session().sample_rate.hz())?;
place_import(e, name, &info, audio, path, target, at)
}
/// Place audio from [`decode_import`] (made for this session's rate) as `import_audio_bytes` does.
pub(crate) fn place_decoded(e: &mut Engine, name: &str, d: DecodedImport, path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
place_import(e, name, &d.info, d.audio, path, target, at)
}
fn place_import(
e: &mut Engine,
name: &str,
info: &soundcraft_audio_io::AudioInfo,
audio: Arc<SourceAudio>,
path: Option<&str>,
target: Option<TrackId>,
at: Samples,
) -> Result<Value> {
let channels = audio.buffer.num_channels();
let file_rate = info.sample_rate;
let s = e.session_mut();
let session_rate = s.sample_rate.hz();
let src = add_source(s, name, buf, path, info.format);
let src = add_source_audio(s, name, audio, path, info.format);
if let (Some(bwf), Some(sx)) = (info.bwf.as_ref(), s.sources.last_mut()) {
sx.time_reference = stamp_at_rate(bwf.time_reference, file_rate, session_rate);
}
@@ -829,12 +886,27 @@ pub fn commit_track(e: &mut Engine, t: TrackId) -> Result<Option<TrackId>> {
if end <= 0 {
return Ok(None);
}
let range = Range::new(0, end + s.sample_rate.samples(2.0));
// The track renders through a main mix of at least two channels.
refuse_longer(s, "track.commit", &tr.name, "commit", range.len(), render_limit(tr.channels().max(2)))?;
let fmt = tr.format;
if fmt.channels() > soundcraft_mix::MAX_CHANNELS {
return Err(EngineError::BadParams(
"track.commit".into(),
format!("track commit supports at most {} channels; cannot render {}", soundcraft_mix::MAX_CHANNELS, fmt.label()),
));
}
let too_long = || EngineError::BadParams("track.commit".into(), format!("`{}` is too long to commit within the offline render limit", tr.name));
let end = end.checked_add(s.sample_rate.samples(2.0)).ok_or_else(too_long)?;
let frames = usize::try_from(end).map_err(|_| too_long())?;
// The shared renderer uses a stereo main mix for mono, then folds it back to mono.
// Reject requests it would truncate before rendering or replacing the source track.
if frames > soundcraft_mix::MAX_RENDER_SAMPLES / fmt.channels().max(2) {
return Err(too_long());
}
let range = Range::new(0, end);
let audio = soundcraft_mix::render_track_pre_fader(s, t, range);
if audio.len() != fmt.channels() || audio.iter().any(|channel| channel.len() != frames) {
return Err(EngineError::Io("track commit did not render the complete channel format and range".into()));
}
let name = format!("{}.cm", tr.name);
let fmt = ChannelFormat::for_channels(audio.len().clamp(1, 2));
let mixer = tr.mixer.clone();
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: audio };
let s = e.session_mut();
@@ -1104,7 +1176,7 @@ pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
"session.save" | "session.save_as" | "session.save_copy" => {
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file))
}
"session.save_template" | "file.export_selected_tracks_as_session" => file.exists(),
"session.save_template" | "file.export_selected_tracks_as_session" | "file.export_session_text" => file.exists(),
_ => false,
}
}
@@ -1208,9 +1280,15 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
/// saves and opens sessions as bundles.
pub const BUNDLE_EXTENSION: &str = "scraftz";
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
const MAX_BUNDLE_ENTRIES: usize = 10_000;
const MAX_BUNDLE_BYTES: u64 = 1 << 30;
/// Most entries and bytes a bundle may unpack to (bundles are hostile input). The writer keeps to
/// them too, so a bundle it saves always opens again (#433).
#[derive(Clone, Copy, Debug)]
struct BundleLimits {
entries: usize,
bytes: u64,
}
const BUNDLE_LIMITS: BundleLimits = BundleLimits { entries: 10_000, bytes: 1 << 30 };
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
/// installs one that starts a download. Native builds write files and never install it.
@@ -1239,6 +1317,11 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
/// session with its source paths pointing into the bundle.
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
session_bundle_within(session, BUNDLE_LIMITS)
}
/// [`session_bundle`], refused when it would hold more than `limits` allows.
fn session_bundle_within(session: &Session, limits: BundleLimits) -> Result<(Vec<u8>, Session)> {
use std::io::Write as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
@@ -1246,6 +1329,20 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
let mut taken = std::collections::HashSet::new();
let (mut entries, mut total) = (0usize, 0u64);
// Counted as the reader counts them: files, and the bytes they unpack to.
let mut fits = |len: usize| {
entries += 1;
total = total.saturating_add(len as u64);
if entries > limits.entries || total > limits.bytes {
return Err(EngineError::Io(format!(
"bundle: too large to save (a bundle holds at most {} files and {} MiB, or it can't be opened again)",
limits.entries,
limits.bytes >> 20
)));
}
Ok(())
};
for src in &mut s.sources {
let Some(audio) = s.pool.get(src.id) else { continue };
let base = sanitize_name(&src.name);
@@ -1257,6 +1354,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
}
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
fits(bytes.len())?;
w.start_file(target.as_str(), opts).map_err(zerr)?;
w.write_all(&bytes).map_err(ioerr)?;
taken.insert(target.clone());
@@ -1265,6 +1363,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
fits(text.len())?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
let bytes = w.finish().map_err(zerr)?.into_inner();
@@ -1293,7 +1392,7 @@ fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
/// [`open_session`].
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes)?
read_bundle(bytes, BUNDLE_LIMITS)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
@@ -1341,11 +1440,11 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
fn read_bundle(bytes: &[u8], limits: BundleLimits) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
use std::io::Read as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
if z.len() > MAX_BUNDLE_ENTRIES {
if z.len() > limits.entries {
return Err(EngineError::Io("bundle: too many files".into()));
}
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
@@ -1357,7 +1456,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
let rel = f.name().map_err(zerr)?.to_string();
let size = f.size();
total = total.saturating_add(size);
if total > MAX_BUNDLE_BYTES {
if total > limits.bytes {
return Err(EngineError::Io("bundle: too large".into()));
}
let mut data = Vec::new();
@@ -1451,13 +1550,18 @@ fn add_recording_in(
if s.track(tid).is_none() {
continue;
}
let resampled = buf.sample_rate != s.sample_rate.hz();
let hz = s.sample_rate.hz();
let resampled = buf.sample_rate != hz;
let mut buf = buf;
if resampled && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, hz);
buf.sample_rate = hz;
}
let name = path.file_stem().and_then(OsStr::to_str).unwrap_or("Take");
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
let Some(x) = s.sources.last_mut() else { continue };
x.unsaved = resampled;
let len = i64::try_from(x.frames).unwrap_or(0);
let len = i64::try_from(buf.frames()).unwrap_or(0);
let pass = pass.unwrap_or(len).max(1);
// Work out what each pass keeps first, so a take nothing is placed from isn't registered.
let mut kept_passes = Vec::new();
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
let n = pass.min(len - offset);
if offset > 0 && n < pass / 8 {
@@ -1470,6 +1574,16 @@ fn add_recording_in(
}
None => (start, 0, n),
};
kept_passes.push((offset, clip_start, cut, clip_len));
}
if kept_passes.is_empty() {
continue;
}
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
if let Some(x) = s.sources.last_mut() {
x.unsaved = resampled;
}
for (offset, clip_start, cut, clip_len) in kept_passes {
if offset > 0
&& let Some(t) = s.track_mut(tid)
{
@@ -1583,6 +1697,39 @@ mod record_tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_punch_that_misses_the_take_leaves_the_session_alone() {
let dir = std::env::temp_dir().join(format!("soundcraft-punch-miss-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 1");
let take = dir.join("Audio Files/Take.wav");
write_take(&take, 1_000);
let shared = add_source(
e.session_mut(),
"Take",
AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 100]] },
Some(&take.to_string_lossy()),
FileFormat::Wav,
);
let sources = e.session().sources.len();
let pool = e.session().pool.len();
let plan = Recording {
start: 1_000,
pass: None,
punch: Some(Range::new(5_000, 6_000)),
rate: 48_000,
takes: vec![Take { track: vox, path: take.clone(), channels: 1 }],
};
let placed = place_recording(&mut e, &plan, vec![(vox, take)]);
assert!(placed.is_empty());
assert_eq!(e.session().sources.len(), sources);
assert_eq!(e.session().pool.len(), pool);
assert!(e.session().sources.iter().any(|x| x.id == shared));
assert!(e.session().pool.contains(shared));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
assert_eq!(live_span(1_000, None, None, 0), None);
@@ -1705,7 +1852,7 @@ mod save_tests {
let mut r = Engine::default();
assert_eq!(
open_session(&mut r, &path("B/Moved.scraft")).unwrap(),
vec![dir.join("A").join("Audio Files/Audio 1_02.wav").display().to_string()]
vec![dir.join("A").join("Audio Files").join("Audio 1_02.wav").display().to_string()]
);
let _ = std::fs::remove_dir_all(&dir);
}
@@ -2056,6 +2203,49 @@ mod stem_tests {
assert_eq!(n, 7, "{r}");
let _ = std::fs::remove_dir_all(&dir);
}
/// The largest gap between the 16-bit stem in `dir16` and the 32-bit float one in `dir32`, in LSBs.
fn max_error_lsb(dir16: &std::path::Path, dir32: &std::path::Path) -> f64 {
let mut worst = 0.0f64;
let mut names: Vec<_> = std::fs::read_dir(dir32).unwrap().map(|f| f.unwrap().file_name()).collect();
names.sort();
assert!(!names.is_empty());
for name in names {
let (_, float) = soundcraft_audio_io::decode(&std::fs::read(dir32.join(&name)).unwrap(), None).unwrap();
let (_, int) = soundcraft_audio_io::decode(&std::fs::read(dir16.join(&name)).unwrap(), None).unwrap();
for (f, i) in float.channels.iter().zip(&int.channels) {
for (a, b) in f.iter().zip(i) {
worst = worst.max((f64::from(a.clamp(-1.0, 1.0)) - f64::from(*b)).abs() * 32768.0);
}
}
}
worst
}
#[test]
fn stems_dither_only_when_asked() {
let mut e = crate::demo::demo_engine();
let root = std::env::temp_dir().join(format!("soundcraft-stems-dither-{}", std::process::id()));
let mut bounce = |name: &str, extra: serde_json::Value| {
let dir = root.join(name);
let mut p = serde_json::json!({"dir": dir.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 10.5}});
for (k, v) in extra.as_object().unwrap() {
p[k] = v.clone();
}
e.execute("file.bounce_stems", &p).unwrap();
dir
};
let float = bounce("float", serde_json::json!({"bit_depth": "32f"}));
let plain = bounce("plain", serde_json::json!({"bit_depth": "16", "dither": false}));
let dithered = bounce("dithered", serde_json::json!({"bit_depth": "16"}));
// Without dither every sample is the float one rounded to the nearest step; TPDF dither of
// ±1 LSB moves some of them further than half a step.
let plain_error = max_error_lsb(&plain, &float);
let dithered_error = max_error_lsb(&dithered, &float);
assert!(plain_error <= 0.5 + 1e-3, "{plain_error}");
assert!(dithered_error > 0.6, "{dithered_error}");
let _ = std::fs::remove_dir_all(&root);
}
}
#[cfg(test)]
@@ -2124,7 +2314,7 @@ mod bundle_tests {
use std::io::Write as _;
let e = crate::demo::demo_engine();
let (bytes, _) = session_bundle(e.session()).unwrap();
let (text, files) = read_bundle(&bytes).unwrap();
let (text, files) = read_bundle(&bytes, BUNDLE_LIMITS).unwrap();
let mut s = Session::from_json(&text).unwrap();
s.sources[1].path = s.sources[0].path.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
@@ -2144,6 +2334,21 @@ mod bundle_tests {
}
}
#[test]
fn a_bundle_is_saved_only_if_it_opens_again() {
// #433: the writer keeps to the reader's limits.
let e = crate::demo::demo_engine();
let (bytes, _) = session_bundle(e.session()).unwrap();
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let entries = z.len();
let size: u64 = (0..entries).map(|i| z.by_index(i).unwrap().size()).sum();
let exact = BundleLimits { entries, bytes: size };
let (at_limit, _) = session_bundle_within(e.session(), exact).unwrap();
assert!(read_bundle(&at_limit, exact).is_ok(), "what the writer takes, the reader opens");
assert!(session_bundle_within(e.session(), BundleLimits { entries: entries - 1, ..exact }).is_err());
assert!(session_bundle_within(e.session(), BundleLimits { bytes: size - 1, ..exact }).is_err());
}
#[test]
fn same_named_sources_get_distinct_files() {
let mut e = crate::demo::demo_engine();
+59
View File
@@ -110,6 +110,9 @@ struct HistoryEntry {
doc: Arc<Session>,
}
/// Most decoded imports [`Engine::offer_import`] holds at once (each can be gigabytes).
const MAX_OFFERED_IMPORTS: usize = 4;
/// Engine state: the document, history, clipboard and transport plumbing.
pub struct Engine {
doc: Arc<Session>,
@@ -139,6 +142,8 @@ pub struct Engine {
/// The edit selection (range, tracks) before the last change, for Restore Last Selection.
/// Kept out of the document so selecting never marks the session modified.
pub last_selection: Option<(soundcraft_time::Range, Vec<TrackId>)>,
/// Audio decoded off the UI thread, waiting for its `file.import_audio` (see `offer_import`).
imports: Vec<io::DecodedImport>,
}
impl Default for Engine {
@@ -169,9 +174,33 @@ impl Engine {
merge_hold: None,
messages: Vec::new(),
last_selection: None,
imports: Vec::new(),
}
}
/// Hands over audio [`io::decode_import`] made off the UI thread: the next `file.import_audio`
/// of the same file at the session's rate places it instead of reading the file again. The
/// command still checks the path may be read. Only the few newest are kept.
pub fn offer_import(&mut self, d: io::DecodedImport) {
self.imports.retain(|x| x.key != d.key);
self.imports.push(d);
let extra = self.imports.len().saturating_sub(MAX_OFFERED_IMPORTS);
self.imports.drain(..extra);
}
/// Drops audio offered for imports that never ran (the command failed or was not sent).
pub fn drop_offered_imports(&mut self) {
self.imports.clear();
}
/// The offered audio of the file at `path`, if it was decoded for the session's current rate.
pub(crate) fn take_import(&mut self, path: &std::path::Path) -> Option<io::DecodedImport> {
let key = io::file_key(path);
let rate = self.session().sample_rate.hz();
let i = self.imports.iter().position(|d| d.key == key && d.rate == rate)?;
Some(self.imports.remove(i))
}
pub fn session(&self) -> &Session {
&self.doc
}
@@ -316,6 +345,7 @@ impl Engine {
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
let side_effects = (self.clipboard.clone(), self.messages.clone(), self.transport_requests.clone(), self.last_selection.clone());
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match result {
Ok(r) => r,
@@ -323,6 +353,7 @@ impl Engine {
self.doc = before;
self.revision = rev.wrapping_add(1);
self.dirty = dirty;
(self.clipboard, self.messages, self.transport_requests, self.last_selection) = side_effects;
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
}
};
@@ -394,6 +425,11 @@ impl Engine {
}
}
/// The gesture `execute_merged` is continuing, if any.
pub fn merge_key(&self) -> Option<&str> {
self.merge_key.as_deref()
}
/// End a gesture started with `execute_merged`.
pub fn end_merge(&mut self) {
self.merge_key = None;
@@ -521,3 +557,26 @@ mod dirty_tests {
assert!(!e.is_dirty());
}
}
#[cfg(test)]
mod panic_tests {
use super::*;
#[test]
fn a_panicking_command_restores_side_effect_state() {
let mut e = Engine::default();
// Non-default values, so clearing them instead of restoring them would fail.
e.clipboard.length = 42;
e.messages.push("prior".into());
e.transport_requests.push(TransportRequest::Stop);
e.last_selection = Some((soundcraft_time::Range::new(20, 30), Vec::new()));
let (clipboard, messages, requests, selection) =
(e.clipboard.clone(), e.messages.clone(), e.transport_requests.clone(), e.last_selection.clone());
let r = e.execute("test.panic_probe", &json!({"panic": true}));
assert!(matches!(r, Err(EngineError::Internal(..))));
assert_eq!(e.clipboard, clipboard);
assert_eq!(e.messages, messages);
assert_eq!(e.transport_requests, requests);
assert_eq!(e.last_selection, selection);
}
}
+52
View File
@@ -5,6 +5,58 @@ use soundcraft_engine::io::{Bounce, bounce_stems};
use soundcraft_time::Range;
use std::path::PathBuf;
#[test]
fn float_bounce_preserves_headroom_and_reports_the_written_levels() {
let mut e = soundcraft_engine::Engine::default();
let sr = e.session().sample_rate.hz();
let frames = sr as usize + 17;
let samples: Vec<f32> = (0..frames).map(|i| 1.5 * (std::f32::consts::TAU * 440.0 * i as f32 / sr as f32).sin()).collect();
let audio = soundcraft_audio_io::AudioBuffer { sample_rate: sr, channels: vec![samples.clone(), samples] };
let s = e.session_mut();
// A new track has no inserts, so no limiter can mask encoder clipping.
let track = s.add_track(soundcraft_model::TrackKind::Audio, soundcraft_model::ChannelFormat::Stereo, Some("Headroom"));
let source = soundcraft_engine::io::add_source(s, "headroom", audio, None, FileFormat::Wav);
let clip = soundcraft_model::Clip::audio(s.new_clip_id(), "headroom", source, 0, 0, frames as i64);
soundcraft_engine::edit::place_clip(s, track, clip);
let mix = soundcraft_mix::render_range(e.session(), Range::new(0, frames as i64), 1024);
assert!(soundcraft_dsp::offline::peak(&mix) > 1.0);
let dir = scratch("bounce-float-headroom");
for extension in ["wav", "aiff"] {
for normalize in [false, true] {
let path = dir.join(format!("Mix.{extension}"));
let result = e
.execute(
"file.bounce_mix",
&serde_json::json!({
"path": path, "bit_depth": "32f", "dither": false, "normalize": normalize,
"start": 0, "end": frames,
}),
)
.unwrap();
let bytes = std::fs::read(&path).unwrap();
let (_, file) = soundcraft_audio_io::decode(&bytes, None).unwrap();
let mut expected = mix.clone();
if normalize {
soundcraft_dsp::offline::normalize(&mut expected, -0.1, false);
assert!(file.peak() < 1.0);
} else {
assert!(file.peak() > 1.0, "{extension}: headroom was clipped");
}
assert_eq!(file.channels, expected, "{extension}, normalize={normalize}");
let mut meter = soundcraft_dsp::meter::LoudnessMeter::new(sr as f32, file.num_channels());
meter.process(&file.channels, file.frames());
for (key, value) in [
("peak_db", soundcraft_dsp::offline::peak_db(&file.channels)),
("lufs", meter.integrated_lufs()),
("true_peak_db", meter.true_peak_db()),
] {
assert_eq!(result[key].as_f64(), Some(f64::from(value)), "{extension}, normalize={normalize}, {key}");
}
}
}
let _ = std::fs::remove_dir_all(&dir);
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
@@ -0,0 +1,160 @@
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_engine::{Engine, io};
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Insert, Route, SendSlot, Session, TrackId, TrackKind};
use soundcraft_time::{Range, SampleRate};
use std::sync::Arc;
const SIGNAL_FRAMES: usize = 128;
fn fixture(format: ChannelFormat) -> (Engine, TrackId, Vec<Vec<f32>>) {
let mut session = Session::new("Commit layout", SampleRate::new(8_000).unwrap());
session.outputs[0].format = format;
let track = session.add_track(TrackKind::Audio, format, Some("Distinct channels"));
// Each channel has a different bipolar waveform: swapping, summing or dropping channels
// cannot pass a width-only assertion or an identical-signal comparison.
let channels: Vec<Vec<f32>> = (0..format.channels())
.map(|ch| (0..SIGNAL_FRAMES).map(|frame| (((frame * (ch + 3)) % 31) as f32 - 15.0) * (ch + 1) as f32 / 3_200.0).collect())
.collect();
let source =
io::add_source(&mut session, "Distinct channels", AudioBuffer { sample_rate: 8_000, channels: channels.clone() }, None, FileFormat::Wav);
let clip = session.new_clip_id();
let tr = session.track_mut(track).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::audio(clip, "Signal", source, 0, 0, SIGNAL_FRAMES as i64));
tr.mixer.inserts[0] = Some(Insert::new("invert"));
session.edit.selected_tracks = vec![track];
(Engine::new(session), track, channels)
}
fn commit(e: &mut Engine, track: TrackId) -> TrackId {
TrackId(e.execute("track.commit", &json!({"tracks": [track.0]})).unwrap()["tracks"][0].as_u64().unwrap())
}
fn committed_audio(e: &Engine, track: TrackId) -> &AudioBuffer {
let ClipContent::Audio { source, .. } = e.session().track(track).unwrap().clips()[0].content else { panic!("commit must create audio") };
&e.session().pool.get(source).unwrap().buffer
}
fn assert_audio_eq(actual: &[Vec<f32>], expected: &[Vec<f32>]) {
assert_eq!(actual.len(), expected.len());
for (ch, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.len(), expected.len(), "channel {ch} length");
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert!((actual - expected).abs() < 1e-6, "channel {ch}, frame {frame}: {actual} != {expected}");
}
}
}
#[test]
fn commit_keeps_every_renderable_exact_format_and_processed_channel_order() {
for format in ChannelFormat::ALL.into_iter().filter(|format| format.channels() <= soundcraft_mix::MAX_CHANNELS) {
let (mut e, track, signal) = fixture(format);
let committed = commit(&mut e, track);
assert_eq!(e.session().track(committed).unwrap().format, format, "{}", format.label());
let audio = committed_audio(&e, committed);
assert_eq!(audio.num_channels(), format.channels(), "{}", format.label());
assert_eq!(audio.frames(), SIGNAL_FRAMES + 16_000);
// Existing mono rendering uses a -3 dB center pan followed by SQRT_2 compensation.
// Model that small baseline gain explicitly, retaining the same strict signal tolerance.
let gain = if format == ChannelFormat::Mono { 10.0_f32.powf(-3.0 / 20.0) * std::f32::consts::SQRT_2 } else { 1.0 };
let expected: Vec<Vec<f32>> = signal.iter().map(|ch| ch.iter().map(|sample| -sample * gain).collect()).collect();
let head: Vec<Vec<f32>> = audio.channels.iter().map(|ch| ch[..SIGNAL_FRAMES].to_vec()).collect();
assert_audio_eq(&head, &expected);
assert!(audio.channels.iter().all(|ch| ch[SIGNAL_FRAMES..].iter().all(|sample| sample.abs() < 1e-6)));
assert!(e.session().track(track).unwrap().inactive && e.session().track(track).unwrap().hidden);
assert_eq!(e.session().track_index(committed), e.session().track_index(track).map(|index| index + 1));
}
}
#[test]
fn commit_preserves_bus_and_send_audio_after_undo_reopen_and_wav_export() {
for format in [ChannelFormat::Surround60, ChannelFormat::Atmos504, ChannelFormat::Ambisonics(1)] {
let (mut e, track, _) = fixture(format);
let s = e.session_mut();
let bus = s.add_bus("Same-layout bus", format);
let aux = s.add_track(TrackKind::Aux, format, Some("Bus return"));
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
let tr = s.track_mut(track).unwrap();
tr.mixer.output = Route::Bus(bus);
tr.mixer.volume_db = -4.5;
let mut send = SendSlot::new(Route::Main);
send.level_db = -9.0;
tr.mixer.sends[0] = Some(send);
let mixer = tr.mixer.clone();
let range = Range::new(0, SIGNAL_FRAMES as i64);
let before = soundcraft_mix::render_range(e.session(), range, 64);
let committed = commit(&mut e, track);
let tr = e.session().track(committed).unwrap();
assert_eq!(tr.mixer.output, mixer.output);
assert_eq!(tr.mixer.sends, mixer.sends);
assert_eq!(tr.mixer.volume_db, mixer.volume_db);
assert!(tr.mixer.inserts.iter().all(Option::is_none), "processing is baked once");
assert_audio_eq(&soundcraft_mix::render_range(e.session(), range, 64), &before);
assert!(e.undo());
assert!(e.session().track(committed).is_none());
assert!(!e.session().track(track).unwrap().inactive);
assert_audio_eq(&soundcraft_mix::render_range(e.session(), range, 64), &before);
assert!(e.redo());
let dir = std::env::temp_dir().join(format!("soundcraft-commit-layout-{}-{}", std::process::id(), format.channels()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Commit.scraft").to_string_lossy().into_owned();
io::save_session(&mut e, &path).unwrap();
let mut reopened = Engine::default();
assert!(io::open_session(&mut reopened, &path).unwrap().is_empty());
assert_eq!(reopened.session().track(committed).unwrap().format, format);
assert_audio_eq(&committed_audio(&reopened, committed).channels, &committed_audio(&e, committed).channels);
assert_audio_eq(&soundcraft_mix::render_range(reopened.session(), range, 64), &before);
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None };
let wav_path = dir.join("mix.wav");
io::Bounce::new(reopened.session_arc(), &wav_path, range, &opts, None, false, false).unwrap().run().unwrap();
let wav = std::fs::read(&wav_path).unwrap();
let (_, decoded) = soundcraft_audio_io::decode(&wav, Some("wav")).unwrap();
assert_audio_eq(&decoded.channels, &before);
// The WAV extensible mask must describe this layout, not another layout of equal width.
let fmt_at = wav.windows(4).position(|tag| tag == b"fmt ").unwrap();
let mask = u32::from_le_bytes(wav[fmt_at + 28..fmt_at + 32].try_into().unwrap());
assert_eq!(mask, format.channel_mask());
std::fs::remove_dir_all(&dir).unwrap();
}
}
#[test]
fn oversized_or_overflowing_commit_rolls_back_all_selected_tracks() {
for (format, length) in [
(ChannelFormat::Mono, (soundcraft_mix::MAX_RENDER_SAMPLES / 2 + 1) as i64),
(ChannelFormat::Surround51, (soundcraft_mix::MAX_RENDER_SAMPLES / 6 + 1) as i64),
(ChannelFormat::Ambisonics(3), (soundcraft_mix::MAX_RENDER_SAMPLES / 16 + 1) as i64),
(ChannelFormat::Surround51, i64::MAX),
] {
let (mut e, first, _) = fixture(format);
let s = e.session_mut();
let second = s.add_track(TrackKind::Audio, format, Some("Too long"));
let mut clip = s.track(first).unwrap().clips()[0].clone();
clip.id = s.new_clip_id();
clip.length = length;
s.track_mut(second).unwrap().playlist_mut().unwrap().clips.push(clip);
let before = e.session_arc();
let dirty = e.is_dirty();
let result = e.execute("track.commit", &json!({"tracks": [first.0, second.0]}));
assert!(result.unwrap_err().to_string().contains("offline render limit"));
assert!(Arc::ptr_eq(&before, &e.session_arc()), "no hidden track, truncated source or partially committed first track");
assert_eq!(e.is_dirty(), dirty);
assert!(!e.can_undo(), "failed commit must not add an undo step");
}
}
#[test]
fn commit_rejects_formats_wider_than_the_renderer_without_replacing_tracks() {
for format in ChannelFormat::ALL.into_iter().filter(|format| format.channels() > soundcraft_mix::MAX_CHANNELS) {
let (mut e, track, _) = fixture(format);
let before = e.session_arc();
let result = e.execute("track.commit", &json!({"tracks": [track.0]}));
assert!(result.unwrap_err().to_string().contains("at most 16 channels"));
assert!(Arc::ptr_eq(&before, &e.session_arc()));
assert!(!e.session().track(track).unwrap().inactive);
assert!(!e.session().track(track).unwrap().hidden);
assert!(!e.can_undo());
}
}
+3
View File
@@ -2,6 +2,7 @@
mod bounce;
mod clap_e2e;
mod commit_multichannel;
mod consolidate_selection;
mod current_feet_frames_position;
mod edit_modes;
@@ -14,4 +15,6 @@ mod mix_required_params;
mod random_edits;
mod recording;
mod session_transport;
mod split_into_mono;
mod track_name_collisions;
mod trim_to_selection;
+159
View File
@@ -0,0 +1,159 @@
//! Split tracks into mono without replacing the right/surround channels with channel zero.
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_engine::{Engine, io};
use soundcraft_model::{ChannelFormat, Clip, Session, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
fn fixture(format: ChannelFormat, channels: usize) -> (Engine, TrackId, SourceId) {
let mut s = Session::default();
let track = s.add_track(TrackKind::Audio, format, Some("Original"));
let channels = (0..channels).map(|ch| (0..256).map(|i| (ch + 1) as f32 * 0.1 + i as f32 * 0.0001).collect()).collect();
let source = io::add_source(&mut s, "Original.wav", AudioBuffer { sample_rate: 48_000, channels }, None, FileFormat::Wav);
s.sources[0].time_reference = 123_456;
let mut clip = Clip::audio(s.new_clip_id(), "Edited", source, 7, 13, 47);
clip.gain_db = -6.0;
clip.gain_env = vec![(0, -3.0), (20, 0.0), (46, -9.0)];
clip.fade_in.len = 5;
clip.fade_out.len = 9;
clip.stretch = 1.25;
let mut second = clip.clone();
second.id = s.new_clip_id();
second.start = 100;
second.content = soundcraft_model::ClipContent::Audio { source, offset: 90 };
s.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![clip, second];
(Engine::new(s), track, source)
}
fn split(e: &mut Engine, track: TrackId) -> Vec<TrackId> {
let result = e.execute("track.split_into_mono", &json!({"track": track.0})).unwrap();
result["tracks"].as_array().unwrap().iter().map(|id| TrackId(id.as_u64().unwrap())).collect()
}
#[test]
fn split_into_mono_preserves_every_channel_and_clip_edit() {
for (format, channels) in [(ChannelFormat::Stereo, 2), (ChannelFormat::Surround51, 6)] {
let (mut e, track, source) = fixture(format, channels);
let original = e.session().track(track).unwrap().clone();
let audio = Arc::clone(e.session().pool.get(source).unwrap());
let expected = soundcraft_mix::render_clips(e.session(), track, Range::new(0, 192));
let tracks = split(&mut e, track);
assert_eq!(tracks.len(), channels);
assert_eq!(e.session().track(track).unwrap(), &original);
assert!(Arc::ptr_eq(e.session().pool.get(source).unwrap(), &audio));
// Two clips referencing the same source share one extracted source per channel.
assert_eq!(e.session().sources.len(), 1 + channels);
for (ch, &id) in tracks.iter().enumerate() {
let mono = e.session().track(id).unwrap();
assert_eq!(mono.format, ChannelFormat::Mono);
let actual = soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192));
assert_eq!(actual[0], expected[ch], "{format:?} channel {ch}");
let mono_source = mono.clips()[0].source().unwrap();
assert_ne!(mono_source, source);
assert!(mono.clips().iter().all(|clip| clip.source() == Some(mono_source)));
let data = e.session().pool.get(mono_source).unwrap();
assert_eq!(data.buffer.channels, vec![audio.buffer.channels[ch].clone()]);
assert_eq!(data.peaks.len(), 1);
let metadata = e.session().source(mono_source).unwrap();
assert_eq!((metadata.channels, metadata.time_reference), (1, 123_456));
assert!(metadata.unsaved);
for (clip, original) in mono.clips().iter().zip(original.clips()) {
let mut expected_clip = original.clone();
expected_clip.id = clip.id;
expected_clip.name = clip.name.clone();
expected_clip.content = soundcraft_model::ClipContent::Audio { source: mono_source, offset: original.source_offset() };
assert_eq!(clip, &expected_clip);
}
}
}
}
#[test]
fn split_stereo_matches_the_original_mix_in_playback_and_bounce_blocks() {
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
let original = e.session().clone();
let tracks = split(&mut e, track);
assert_eq!(e.session().track(tracks[0]).unwrap().name, "Original.L");
assert_eq!(e.session().track(tracks[1]).unwrap().name, "Original.R");
e.session_mut().track_mut(track).unwrap().inactive = true;
for block in [37, 512, 1024] {
let expected = soundcraft_mix::render_range(&original, Range::new(0, 192), block);
let actual = soundcraft_mix::render_range(e.session(), Range::new(0, 192), block);
assert_eq!(actual, expected, "block {block}");
}
}
#[test]
fn split_stereo_preserves_distinct_left_and_right_levels() {
let (mut e, track, source) = fixture(ChannelFormat::Stereo, 2);
let s = e.session_mut();
s.pool.insert(source, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.2; 256], vec![0.7; 256]] })));
let clip = Clip::audio(s.new_clip_id(), "Distinct channels", source, 0, 0, 256);
s.track_mut(track).unwrap().playlist_mut().unwrap().clips = vec![clip];
split(&mut e, track);
e.session_mut().track_mut(track).unwrap().inactive = true;
let audio = soundcraft_mix::render_range(e.session(), Range::new(0, 128), 64);
eprintln!("Split into Mono output: L={}, R={}", audio[0][64], audio[1][64]);
assert_eq!(audio[0][64], 0.2);
assert_eq!(audio[1][64], 0.7);
}
#[test]
fn split_sources_survive_save_and_reopen_with_distinct_audio_files() {
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
let tracks = split(&mut e, track);
let (bytes, saved) = io::session_bundle(e.session()).unwrap();
let paths: std::collections::BTreeSet<_> = saved.sources.iter().map(|source| &source.path).collect();
assert_eq!(paths.len(), 3);
let mut reopened = Engine::default();
assert!(io::open_session_bytes(&mut reopened, "split.scraftz", &bytes).unwrap().is_empty());
for id in tracks {
let expected = soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192));
let actual = soundcraft_mix::render_clips(reopened.session(), id, Range::new(0, 192));
assert_eq!(actual, expected);
}
}
#[test]
fn split_tracks_and_sources_undo_and_redo_together() {
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
let before = e.session().clone();
split(&mut e, track);
let after = e.session().clone();
assert_eq!(e.undo_history().len(), 1);
assert!(e.undo());
assert_eq!(e.session(), &before);
assert!(e.redo());
assert_eq!(e.session(), &after);
}
#[test]
fn split_reuses_a_mono_source_on_a_stereo_track() {
let (mut e, track, source) = fixture(ChannelFormat::Stereo, 1);
let expected = soundcraft_mix::render_clips(e.session(), track, Range::new(0, 192));
for id in split(&mut e, track) {
assert!(e.session().track(id).unwrap().clips().iter().all(|clip| clip.source() == Some(source)));
assert_eq!(soundcraft_mix::render_clips(e.session(), id, Range::new(0, 192))[0], expected[0]);
}
assert_eq!(e.session().sources.len(), 1);
}
#[test]
fn missing_or_empty_media_refuses_the_split_without_partial_changes() {
for empty in [false, true] {
let (mut e, track, _) = fixture(ChannelFormat::Stereo, 2);
let missing = SourceId(9999);
if empty {
e.session_mut().pool.insert(missing, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![] })));
}
let clip = Clip::audio(e.session_mut().new_clip_id(), "Unavailable", missing, 0, 160, 16);
e.session_mut().track_mut(track).unwrap().playlist_mut().unwrap().clips.push(clip);
let before = e.session().clone();
let error = e.execute("track.split_into_mono", &json!({"track": track.0})).unwrap_err();
assert!(matches!(error, soundcraft_engine::EngineError::BadParams(..)), "{error}");
assert_eq!(e.session(), &before);
assert!(e.undo_history().is_empty());
}
}
@@ -0,0 +1,173 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, Session, TrackKind};
use std::sync::Arc;
#[test]
fn truncated_ascii_and_unicode_names_cannot_collide_with_another_track() {
for character in ['A', '界'] {
let existing: String = std::iter::repeat_n(character, 64).collect();
let mut session = Session::default();
let occupied = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some(&existing));
let target = session.add_track(TrackKind::Aux, ChannelFormat::Mono, Some("Target"));
let mut engine = Engine::new(session);
let before = engine.session_arc();
let revision = engine.revision;
let result = engine.execute("track.rename", &json!({"track": target.0, "name": format!(" {existing} extra ")}));
assert!(result.is_err());
assert!(Arc::ptr_eq(&before, &engine.session_arc()));
assert_eq!(engine.revision, revision);
assert_eq!(engine.session().track(occupied).unwrap().name, existing);
assert_eq!(engine.session().track(target).unwrap().name, "Target");
assert!(!engine.is_dirty());
assert!(!engine.can_undo());
}
}
#[test]
fn rename_returns_the_stored_unicode_name_and_undo_restores_the_document() {
let mut session = Session::default();
let track = session.add_track(TrackKind::Audio, ChannelFormat::Surround51, Some("Original"));
session.track_mut(track).unwrap().comments = "Retain this comment".into();
let mut engine = Engine::new(session);
let before = engine.session().to_json().unwrap();
let stored = "é".repeat(64);
let result = engine.execute("track.rename", &json!({"track": track.0, "name": format!(" {stored}extra ")})).unwrap();
assert_eq!(result["name"], stored);
let renamed = engine.session().track(track).unwrap();
assert_eq!(renamed.name, stored);
assert_eq!(renamed.name.chars().count(), 64);
assert_eq!(renamed.id, track);
assert_eq!(renamed.format, ChannelFormat::Surround51);
assert_eq!(renamed.comments, "Retain this comment");
assert_eq!(engine.undo_history(), ["Rename Track"]);
assert!(engine.undo());
assert_eq!(engine.session().to_json().unwrap(), before);
assert!(engine.redo());
assert_eq!(engine.session().track(track).unwrap().name, stored);
}
#[test]
fn unchanged_name_after_truncation_does_not_make_an_undo_step() {
let name = "A".repeat(64);
let mut session = Session::default();
let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, Some(&name));
let mut engine = Engine::new(session);
let before = engine.session_arc();
let revision = engine.revision;
let result = engine.execute("track.rename", &json!({"track": track.0, "name": format!("{name} suffix")})).unwrap();
assert_eq!(result["name"], name);
assert!(Arc::ptr_eq(&before, &engine.session_arc()));
assert_eq!(engine.revision, revision);
assert!(!engine.is_dirty());
assert!(!engine.can_undo());
}
#[test]
fn routing_folder_rename_preserves_unique_owned_bus_routes_and_history() {
use soundcraft_model::{BusId, Route, TrackId};
let stored = "界".repeat(64);
let first_name = format!("{stored} Bus");
let second_name = format!("{stored} Bus 2");
let mut session = Session::default();
let member = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Member"));
let user_bus = session.add_bus(&first_name, ChannelFormat::Stereo);
let second_user_bus = session.add_bus(&second_name, ChannelFormat::Stereo);
let mut engine = Engine::new(session);
let unrelated = engine.execute("track.new", &json!({"kind": "folder", "name": "Unrelated"})).unwrap();
let unrelated = TrackId(unrelated["tracks"][0].as_u64().unwrap());
let unrelated_route = engine.session().track(unrelated).unwrap().mixer.input.clone();
let created = engine.execute("track.move_to_new_folder", &json!({"tracks": [member.0], "name": "Initial"})).unwrap();
let folder = TrackId(created["folder"].as_u64().unwrap());
let owned_bus = BusId(created["bus"].as_u64().unwrap());
let before = engine.session().to_json().unwrap();
let history = engine.undo_history();
let bus_count = engine.session().busses.len();
let result = engine.execute("track.rename", &json!({"track": folder.0, "name": format!(" {stored} suffix ")})).unwrap();
assert_eq!(result["name"], stored);
assert_eq!(engine.session().track(folder).unwrap().name, stored);
assert_eq!(engine.session().bus(owned_bus).unwrap().name, format!("{stored} Bus 3"));
assert_eq!(engine.session().bus(user_bus).unwrap().name, first_name);
assert_eq!(engine.session().bus(second_user_bus).unwrap().name, second_name);
assert_eq!(engine.session().track(unrelated).unwrap().mixer.input, unrelated_route);
assert_eq!(engine.session().track(folder).unwrap().mixer.input, Route::Bus(owned_bus));
assert_eq!(engine.session().track(member).unwrap().mixer.output, Route::Bus(owned_bus));
assert_eq!(engine.session().track(member).unwrap().folder, Some(folder));
assert_eq!(engine.session().busses.len(), bus_count);
assert_eq!(engine.undo_history().len(), history.len() + 1);
assert_eq!(engine.undo_label(), Some("Rename Track"));
let renamed = engine.session().to_json().unwrap();
let document = engine.session_arc();
let revision = engine.revision;
let history = engine.undo_history();
engine.execute("track.rename", &json!({"track": folder.0, "name": format!("{stored} more")})).unwrap();
assert!(Arc::ptr_eq(&document, &engine.session_arc()));
assert_eq!(engine.revision, revision);
assert_eq!(engine.undo_history(), history);
assert!(engine.undo());
assert_eq!(engine.session().to_json().unwrap(), before);
assert!(engine.redo());
assert_eq!(engine.session().to_json().unwrap(), renamed);
// Current-main same-name folder rename also repairs the folder's own bus, without taking
// either colliding user bus or changing its members' bus identity.
engine.session_mut().busses.iter_mut().find(|bus| bus.id == owned_bus).unwrap().name = "Manually named owned bus".into();
let manual = engine.session().to_json().unwrap();
engine.execute("track.rename", &json!({"track": folder.0, "name": stored})).unwrap();
assert_eq!(engine.session().bus(owned_bus).unwrap().name, format!("{stored} Bus 3"));
assert_eq!(engine.session().bus(user_bus).unwrap().name, first_name);
assert_eq!(engine.session().bus(second_user_bus).unwrap().name, second_name);
assert_eq!(engine.session().track(member).unwrap().mixer.output, Route::Bus(owned_bus));
assert!(engine.undo());
assert_eq!(engine.session().to_json().unwrap(), manual);
assert!(engine.redo());
assert_eq!(engine.session().to_json().unwrap(), renamed);
}
#[test]
fn truncated_folder_collision_refuses_bus_and_document_changes_with_redo_pending() {
use soundcraft_model::{Route, TrackId};
for character in ['B', '界'] {
let occupied_name: String = std::iter::repeat_n(character, 64).collect();
let mut session = Session::default();
let occupied = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some(&occupied_name));
let mut engine = Engine::new(session);
let created = engine.execute("track.new", &json!({"kind": "folder", "name": "Target"})).unwrap();
let folder = TrackId(created["tracks"][0].as_u64().unwrap());
let owned_bus = match engine.session().track(folder).unwrap().mixer.input {
Route::Bus(bus) => bus,
_ => panic!("the routing folder must own a bus"),
};
engine.execute("mix.volume", &json!({"track": folder.0, "db": -3})).unwrap();
assert!(engine.undo());
engine.mark_clean();
let document = engine.session_arc();
let before = engine.session().to_json().unwrap();
let revision = engine.revision;
let journal = engine.journal.clone();
let undo = engine.undo_history();
let redo = engine.redo_label().map(str::to_owned);
assert!(redo.is_some());
let result = engine.execute("track.rename", &json!({"track": folder.0, "name": format!(" {occupied_name} suffix ")}));
assert!(result.unwrap_err().to_string().contains(&occupied_name));
assert!(Arc::ptr_eq(&document, &engine.session_arc()));
assert_eq!(engine.session().to_json().unwrap(), before);
assert_eq!(engine.revision, revision);
assert_eq!(engine.journal, journal);
assert_eq!(engine.undo_history(), undo);
assert_eq!(engine.redo_label(), redo.as_deref());
assert!(!engine.is_dirty());
assert_eq!(engine.session().track(occupied).unwrap().name, occupied_name);
assert_eq!(engine.session().track(folder).unwrap().name, "Target");
assert_eq!(engine.session().bus(owned_bus).unwrap().name, "Target Bus");
assert_eq!(engine.session().track(folder).unwrap().mixer.input, Route::Bus(owned_bus));
}
}
+90 -5
View File
@@ -204,7 +204,12 @@ pub fn read_smf(bytes: &[u8]) -> Result<Smf, MidiError> {
tracks.push(track);
}
if format == 1 && tracks.len() > 1 && tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty()) {
// A lone track is only a conductor track if it carried global data and has no name; otherwise it is kept.
let globals_present = !(globals.tempos.is_empty() && globals.meters.is_empty() && globals.markers.is_empty() && globals.key_sigs.is_empty());
if format == 1
&& (tracks.len() > 1 || globals_present)
&& tracks.first().is_some_and(|t| t.sequence.notes.is_empty() && t.sequence.ctrls.is_empty() && (tracks.len() > 1 || t.name.is_empty()))
{
tracks.remove(0);
}
@@ -444,7 +449,12 @@ fn write_track(out: &mut Vec<u8>, mut events: Vec<WEvent>) -> Result<(), MidiErr
/// Errors: invalid tempo (non-finite or <= 0 bpm), meter denominator that is not a power of two
/// (1..=128), more than 65535 tracks, or a gap between events beyond the 28-bit delta limit.
pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
let ntrks = u16::try_from(smf.tracks.len().saturating_add(1)).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?;
// The conductor track is omitted when there is nothing to put in it and no content tracks, so an
// empty `Smf` is written as a file with zero tracks and reads back as zero tracks.
let has_conductor =
!(smf.tempos.is_empty() && smf.meters.is_empty() && smf.key_sigs.is_empty() && smf.markers.is_empty() && smf.tracks.is_empty());
let ntrks =
u16::try_from(smf.tracks.len().saturating_add(usize::from(has_conductor))).map_err(|_| MidiError::Write("more than 65535 tracks".into()))?;
let mut out = Vec::new();
out.extend_from_slice(b"MThd");
out.extend_from_slice(&6u32.to_be_bytes());
@@ -476,18 +486,58 @@ pub fn write_smf(smf: &Smf) -> Result<Vec<u8>, MidiError> {
for (t, text) in &smf.markers {
ev.push(WEvent { tick: clamp_tick(*t), order: 3, bytes: meta(0x06, text.as_bytes())? });
}
write_track(&mut out, ev)?;
if has_conductor {
write_track(&mut out, ev)?;
}
for track in &smf.tracks {
let mut ev = Vec::new();
if !track.name.is_empty() {
ev.push(WEvent { tick: 0, order: 0, bytes: meta(0x03, track.name.as_bytes())? });
}
for n in &track.sequence.notes {
let notes = &track.sequence.notes;
let mut ends: Vec<u64> = notes.iter().map(|n| clamp_tick(n.end()).max(clamp_tick(n.start).saturating_add(1))).collect();
let mut dropped = vec![false; notes.len()];
// The reader pairs offs with ons first-in first-out, so a note still sounding when the next
// same-pitch note starts would swap ends with it: trim it to that start. When two same-pitch
// notes start on the same tick they cannot be told apart, so only the longer one is written.
let mut order: Vec<usize> = (0..notes.len()).collect();
order.sort_by_key(|&i| {
notes.get(i).map_or((0, 0, 0, std::cmp::Reverse(0)), |n| {
(n.channel & 0x0F, n.pitch.min(127), clamp_tick(n.start), std::cmp::Reverse(ends.get(i).copied().unwrap_or(0)))
})
});
let mut prev: Option<usize> = None;
for &b in &order {
let Some(nb) = notes.get(b) else { continue };
if let Some(a) = prev
&& let Some(na) = notes.get(a)
&& na.channel & 0x0F == nb.channel & 0x0F
&& na.pitch.min(127) == nb.pitch.min(127)
{
let (sa, sb) = (clamp_tick(na.start), clamp_tick(nb.start));
if sa == sb {
if let Some(d) = dropped.get_mut(b) {
*d = true;
}
continue;
}
if let Some(ea) = ends.get_mut(a)
&& sb < *ea
{
*ea = sb;
}
}
prev = Some(b);
}
for (i, n) in notes.iter().enumerate() {
if dropped.get(i).copied().unwrap_or(false) {
continue;
}
let ch = n.channel & 0x0F;
let pitch = n.pitch.min(127);
let start = clamp_tick(n.start);
let end = clamp_tick(n.end()).max(start.saturating_add(1));
let end = ends.get(i).copied().unwrap_or(start.saturating_add(1));
ev.push(WEvent { tick: start, order: 3, bytes: vec![0x90 | ch, pitch, n.velocity.clamp(1, 127)] });
ev.push(WEvent { tick: end, order: 1, bytes: vec![0x80 | ch, pitch, n.release_velocity.min(127)] });
}
@@ -527,6 +577,19 @@ mod tests {
assert!(push_vlq(&mut Vec::new(), MAX_VLQ + 1).is_err());
}
#[test]
fn empty_smf_round_trips_with_no_tracks() {
let r = read_smf(&write_smf(&Smf::default()).unwrap()).unwrap();
assert_eq!(r.tracks.len(), 0);
let one = Smf { tracks: vec![SmfTrack::default()], ..Smf::default() };
let r = read_smf(&write_smf(&one).unwrap()).unwrap();
assert_eq!(r.tracks.len(), 1);
let cond = Smf { tempos: vec![(0, 90.0)], ..Smf::default() };
let r = read_smf(&write_smf(&cond).unwrap()).unwrap();
assert_eq!(r.tracks.len(), 0);
assert_eq!(r.tempos.len(), 1);
}
#[test]
fn spec_vlq_examples() {
let mut b = Vec::new();
@@ -536,4 +599,26 @@ mod tests {
push_vlq(&mut b, 0x2000).unwrap();
assert_eq!(b, [0xC0, 0x00]);
}
#[test]
fn same_pitch_overlaps_do_not_swap_ends() {
let mk = |start, length| Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start, length };
let smf = Smf {
format: 1,
tracks: vec![SmfTrack {
name: String::new(),
sequence: Sequence { notes: vec![mk(0, 1000), mk(100, 200), mk(1000, 100), mk(2000, 500), mk(2000, 900)], ..Sequence::default() },
}],
tempos: vec![],
meters: vec![],
markers: vec![],
key_sigs: vec![],
};
let back = read_smf(&write_smf(&smf).unwrap()).unwrap();
let mut got: Vec<(i64, i64)> = back.tracks.iter().flat_map(|t| t.sequence.notes.iter().map(|n| (n.start, n.length))).collect();
got.sort_unstable();
// The earlier note is trimmed to the start of the next same-pitch note; later notes keep their length;
// of two notes starting together only the longer survives.
assert_eq!(got, vec![(0, 100), (100, 200), (1000, 100), (2000, 900)]);
}
}
+256 -30
View File
@@ -248,21 +248,37 @@ struct Strip {
/// at most this, whatever its plugins claim, so a bogus value can't stall offline renders.
const MAX_LATENCY: usize = 1 << 20;
/// A multichannel delay line (samples), resized only when the delay changes.
/// A multichannel delay line (samples). The rings only grow (keeping their contents), so a delay
/// change in the callback moves the read offset instead of reallocating and zeroing.
#[derive(Default)]
struct DelayLine {
bufs: Vec<Vec<f32>>,
/// Write position in the rings.
idx: usize,
/// Current delay (samples).
len: usize,
}
impl DelayLine {
fn set(&mut self, len: usize, ch: usize) {
let len = len.min(MAX_LATENCY);
if len != self.len || self.bufs.len() != ch {
self.len = len;
self.idx = 0;
self.bufs = vec![vec![0.0; len]; ch];
self.len = len;
let cap = self.bufs.first().map_or(0, Vec::len);
if self.bufs.len() != ch {
self.bufs.resize_with(ch, || vec![0.0; cap]);
}
let need = if len == 0 { 0 } else { len + 1 };
if need > cap {
let new_cap = need.next_power_of_two().min(MAX_LATENCY + 1);
let start = self.idx.min(cap);
for ring in &mut self.bufs {
let mut grown = Vec::with_capacity(new_cap);
grown.extend_from_slice(ring.get(start..).unwrap_or(&[]));
grown.extend_from_slice(ring.get(..start).unwrap_or(&[]));
grown.resize(new_cap, 0.0);
*ring = grown;
}
self.idx = cap;
}
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
@@ -270,20 +286,29 @@ impl DelayLine {
return;
}
let start = self.idx;
let mut end = start;
for (c, ch) in io.iter_mut().enumerate() {
let Some(ring) = self.bufs.get_mut(c) else { continue };
let mut i = start;
let cap = ring.len();
if cap <= self.len {
continue;
}
let mut i = start % cap;
for x in ch.iter_mut().take(frames) {
if let Some(r) = ring.get_mut(i) {
std::mem::swap(r, x);
if let Some(w) = ring.get_mut(i) {
*w = *x;
}
if let Some(r) = ring.get((i + cap - self.len) % cap) {
*x = *r;
}
i += 1;
if i >= self.len {
if i >= cap {
i = 0;
}
}
end = i;
}
self.idx = (start + frames) % self.len.max(1);
self.idx = end;
}
fn clear(&mut self) {
self.bufs.iter_mut().for_each(|b| b.fill(0.0));
@@ -1045,9 +1070,8 @@ fn process_strip(
}
strip.gr = gr;
strip.latency = latency.min(MAX_LATENCY);
let auto_mute = t.mixer.automation_mode.reads() && t.lane(&AutoParam::Mute).is_some_and(|l| l.value_at(pos, 0.0) >= 0.5);
let soloed_out = any_solo && !t.mixer.solo && !t.mixer.solo_safe && !receives_solo(s, t);
strip.muted = t.mixer.mute || auto_mute || soloed_out;
strip.muted = t.mixer.mute || soloed_out;
if t.mixer.sends.iter().flatten().any(|x| x.pre_fader) {
strip.pre.resize_with(strip.buf.len(), Vec::new);
for (d, src) in strip.pre.iter_mut().zip(strip.buf.iter()) {
@@ -1088,6 +1112,23 @@ fn process_strip(
}
}
}
// Track mute gates the audio and its pre-fader tap without splitting processor blocks.
// Keep the existing curve interpolation, first-lane and mode policy, and render-position
// clock. Processor/bus age and Master/VCA automation policy are separate concerns.
if t.mixer.automation_mode.reads()
&& let Some(lane) = t.lane(&AutoParam::Mute).filter(|lane| !lane.points.is_empty())
{
for i in 0..frames {
let at = pos.saturating_add(i64::try_from(i).unwrap_or(i64::MAX));
if lane.value_at(at, 0.0) >= 0.5 {
for channel in strip.buf.iter_mut().chain(strip.pre.iter_mut()) {
if let Some(sample) = channel.get_mut(i) {
*sample = 0.0;
}
}
}
}
}
}
/// Silences a block the plugin filled with NaN or infinity and takes the plugin out until the
@@ -1317,26 +1358,97 @@ fn processing_order(s: &Session) -> Vec<usize> {
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| !matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
let mut auxes: Vec<usize> =
(0..s.tracks.len()).filter(|&i| s.tracks.get(i).is_some_and(|t| matches!(t.kind, TrackKind::Aux | TrackKind::Folder))).collect();
// Depth = how many aux hops feed this aux's input bus (bounded; cycles cap out).
let depth = |i: usize| -> usize {
let mut d = 0;
let mut cur = vec![i];
for _ in 0..8 {
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
if feeders.is_empty() {
break;
}
d += 1;
cur = feeders;
}
d
};
auxes.sort_by_key(|&i| depth(i));
if let Some(depths) = acyclic_aux_depths(s, &auxes) {
// Stable sorting preserves session order among auxes at the same longest-path depth.
auxes.sort_by_key(|&i| depths.get(i).copied().unwrap_or(0));
} else {
// A cycle keeps the existing bounded ordering for the entire graph, including any
// independent acyclic branches. Feedback does not have a finite longest-path depth.
auxes.sort_by_key(|&i| bounded_aux_depth(s, i));
}
order.extend(auxes);
order
}
/// Exact aux-hop depths when the whole Aux/Folder graph is acyclic. Bus joins retain only
/// producer counts and consumers, avoiding a producer-by-consumer adjacency matrix.
fn acyclic_aux_depths(s: &Session, auxes: &[usize]) -> Option<Vec<usize>> {
#[derive(Default)]
struct BusDependencies {
remaining: usize,
depth: usize,
last_producer: Option<usize>,
last_processed: Option<usize>,
consumers: Vec<usize>,
}
let mut busses = HashMap::<BusId, BusDependencies>::new();
for &i in auxes {
for bus in aux_output_busses(s.tracks.get(i)?) {
let dependency = busses.entry(bus).or_default();
// Output and multiple sends from this producer may name the same bus.
if dependency.last_producer != Some(i) {
dependency.last_producer = Some(i);
dependency.remaining = dependency.remaining.checked_add(1)?;
}
}
}
let mut depths = vec![0usize; s.tracks.len()];
let mut ready = Vec::with_capacity(auxes.len());
for &i in auxes {
if let Route::Bus(bus) = &s.tracks.get(i)?.mixer.input {
let dependency = busses.entry(*bus).or_default();
dependency.consumers.push(i);
if dependency.remaining > 0 {
continue;
}
}
ready.push(i);
}
let mut processed = 0usize;
while let Some(&i) = ready.get(processed) {
processed = processed.checked_add(1)?;
let next_depth = depths.get(i)?.checked_add(1)?;
for bus in aux_output_busses(s.tracks.get(i)?) {
let dependency = busses.get_mut(&bus)?;
if dependency.last_processed == Some(i) {
continue;
}
dependency.last_processed = Some(i);
dependency.depth = dependency.depth.max(next_depth);
dependency.remaining = dependency.remaining.checked_sub(1)?;
if dependency.remaining == 0 {
for &consumer in &dependency.consumers {
*depths.get_mut(consumer)? = dependency.depth;
ready.push(consumer);
}
}
}
}
(processed == auxes.len()).then_some(depths)
}
fn aux_output_busses(t: &Track) -> impl Iterator<Item = BusId> + '_ {
std::iter::once(&t.mixer.output)
.chain(t.mixer.sends.iter().flatten().map(|send| &send.target))
.filter_map(|route| if let Route::Bus(bus) = route { Some(*bus) } else { None })
}
/// The previous bounded depth calculation, retained unchanged for any cyclic graph.
fn bounded_aux_depth(s: &Session, i: usize) -> usize {
let mut d = 0;
let mut cur = vec![i];
for _ in 0..8 {
let inputs: Vec<&Route> = cur.iter().filter_map(|&k| s.tracks.get(k)).map(|t| &t.mixer.input).collect();
let feeders: Vec<usize> = auxes_feeding(s, &inputs);
if feeders.is_empty() {
break;
}
d += 1;
cur = feeders;
}
d
}
fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
s.tracks
.iter()
@@ -1349,6 +1461,60 @@ fn auxes_feeding(s: &Session, inputs: &[&Route]) -> Vec<usize> {
.collect()
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod processing_order_tests {
use super::*;
use soundcraft_model::SendSlot;
#[test]
fn acyclic_bus_joins_use_longest_depth_and_keep_session_order_for_ties() {
let mut s = Session::default();
s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
let consumer = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
let first_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
let second_root = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
let middle = s.add_track(TrackKind::Folder, ChannelFormat::Stereo, None);
let first_bus = s.add_bus("First", ChannelFormat::Stereo);
let join = s.add_bus("Join", ChannelFormat::Stereo);
s.track_mut(consumer).unwrap().mixer.input = Route::Bus(join);
let root = s.track_mut(first_root).unwrap();
root.mixer.output = Route::Main;
root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(first_bus)));
let root = s.track_mut(second_root).unwrap();
root.mixer.output = Route::Bus(join);
root.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join)));
root.mixer.sends[1] = Some(SendSlot::new(Route::Bus(join)));
let folder = s.track_mut(middle).unwrap();
folder.mixer.input = Route::Bus(first_bus);
folder.mixer.output = Route::Bus(join);
folder.mixer.sends[0] = Some(SendSlot::new(Route::Bus(join)));
assert_eq!(processing_order(&s), vec![0, 2, 3, 4, 1]);
}
#[test]
fn a_cycle_preserves_bounded_order_for_the_whole_graph() {
let mut s = Session::default();
let busses: Vec<_> = (0..10).map(|i| s.add_bus(&format!("Chain {i}"), ChannelFormat::Stereo)).collect();
let tracks: Vec<_> = (0..10).map(|_| s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None)).collect();
for (i, id) in tracks.iter().enumerate() {
let track = s.track_mut(*id).unwrap();
track.mixer.input = Route::Bus(busses[i]);
track.mixer.output = if i == 0 { Route::Main } else { Route::Bus(busses[i - 1]) };
}
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 1, 0]);
let cycle_bus = s.add_bus("Feedback", ChannelFormat::Stereo);
let cycle = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, None);
let track = s.track_mut(cycle).unwrap();
track.mixer.input = Route::Bus(cycle_bus);
track.mixer.output = Route::Bus(cycle_bus);
// Both deepest independent returns tie with the cycle at the legacy eight-hop cap.
assert_eq!(processing_order(&s), vec![9, 8, 7, 6, 5, 4, 3, 2, 0, 1, 10]);
}
}
/// Implicit solo: an aux is not muted when a soloed track feeds its input bus.
fn receives_solo(s: &Session, t: &Track) -> bool {
let Route::Bus(b) = &t.mixer.input else { return false };
@@ -1455,7 +1621,7 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
let x0 = f.floor();
let fr = (f - x0) as f32;
let a = if x0 >= 0.0 { src.get(x0 as usize).copied().unwrap_or(0.0) } else { 0.0 };
let b = if x0 + 1.0 >= 0.0 { src.get((x0 as usize).saturating_add(1)).copied().unwrap_or(0.0) } else { 0.0 };
let b = if x0 + 1.0 >= 0.0 { src.get((x0 + 1.0) as usize).copied().unwrap_or(0.0) } else { 0.0 };
a + (b - a) * fr
};
let t = (k - i) as f32 / n;
@@ -2233,6 +2399,13 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<
/// A built-in plugin, else a hosted CLAP plugin (`clap:<id>`).
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
// Tests never load real plug-ins; adopt a fake instead. Scanning the machine's plug-in folders
// loads every installed plug-in in-process, and some never unload, so a test that reached the
// real hosts would hang on a developer machine (CI has none installed).
#[cfg(test)]
if is_third_party(id) {
return None;
}
soundcraft_dsp::create(id)
.or_else(|| soundcraft_clap_host::create(id))
.or_else(|| soundcraft_vst3_host::create(id))
@@ -2285,6 +2458,10 @@ mod audio_thread_tests {
#[cfg(test)]
mod tests {
//! Rule: tests never call the real plug-in hosts. Third-party ids are not created here
//! (create_plugin returns None under cfg(test)); a test that needs a hosted plug-in
//! adopts a fake under a clap:test.* id via MixEngine::adopt.
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{Insert, SourceAudio, SourceId};
@@ -2305,6 +2482,31 @@ mod tests {
s
}
#[test]
fn delay_line_change_keeps_buffers_and_history() {
// #889: a latency change must not reallocate or zero the delay line in the callback.
let mut d = DelayLine::default();
d.set(480, 2);
let ptrs: Vec<_> = d.bufs.iter().map(|b| b.as_ptr()).collect();
let mut io = vec![(1..=480).map(|v| v as f32).collect::<Vec<f32>>(); 2];
d.process(&mut io, 480);
assert!(io.iter().all(|c| c.iter().all(|&x| x == 0.0)));
// Shorter delay: the line keeps its storage and outputs what it already holds.
d.set(240, 2);
let after: Vec<_> = d.bufs.iter().map(|b| b.as_ptr()).collect();
assert_eq!(ptrs, after, "delay line reallocated on a shorter delay");
let mut io = vec![vec![0.0f32; 240]; 2];
d.process(&mut io, 240);
let want: Vec<f32> = (241..=480).map(|v| v as f32).collect();
for c in &io {
assert_eq!(c, &want, "held samples lost on a latency change");
}
// Back to the longer delay: still the same storage.
d.set(480, 2);
let after: Vec<_> = d.bufs.iter().map(|b| b.as_ptr()).collect();
assert_eq!(ptrs, after, "delay line reallocated going back to its size");
}
#[test]
fn stored_instrument_params_reach_the_plugin() {
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
@@ -2382,6 +2584,30 @@ mod tests {
assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4);
}
#[test]
fn a_stretched_clip_before_its_source_start_reads_from_the_first_sample() {
// A clip that extends before its source (a negative offset) plays silence there. When it
// is stretched, the read position passes through [-1, 0), which must interpolate from
// that silence towards sample 0 of the file -- not towards sample 1.
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![1.0, 0.5, 0.25, 0.125]] };
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(buf)));
let id = s.new_clip_id();
let mut c = Clip::audio(id, "late", SourceId(901), -1, 0, 4);
c.stretch = 2.0;
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips.push(c);
}
let out = render_clips(&s, t, Range::new(0, 4));
// rel 0 -> source -1.0: silence; rel 1 -> source -0.5: halfway from silence to sample 0;
// rel 2 -> source 0.0: sample 0; rel 3 -> source 0.5: halfway to sample 1.
let want = [0.0, 0.5, 1.0, 0.75];
for (i, w) in want.iter().enumerate() {
assert!((out[0][i] - w).abs() < 1e-6, "frame {i}: {} vs {w}", out[0][i]);
}
}
#[test]
fn fade_curvature_reaches_the_rendered_audio() {
for fade_in in [true, false] {
@@ -2959,7 +3185,7 @@ mod tests {
s.track_mut(t).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("gain".into(), -12.0);
render_block(&mut eng, &s, 1024);
assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 2, "later session edits apply");
// Offline (not external): unknown third-party plugins are simply absent.
// Offline (not external): third-party plugins are simply absent (tests never load real ones).
let out = render_range(&s, Range::new(0, 1024), 512);
assert!(out[0][100] > 0.3);
}
+306
View File
@@ -0,0 +1,306 @@
//! Aux and routing-folder dependencies preserve PCM beyond eight hops. Cyclic graphs keep
//! the established whole-graph fallback, including its session-order-dependent audio.
use soundcraft_audio_io::AudioBuffer;
use soundcraft_mix::{MixEngine, render_range};
use soundcraft_model::{BusId, ChannelFormat, Clip, Route, SendSlot, Session, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
const FRAMES: usize = 4097;
const DEPTH: usize = 12;
fn signal() -> Vec<Vec<f32>> {
// Distinct, exactly representable channels expose lost, duplicated, swapped and delayed PCM.
vec![
(0..FRAMES).map(|frame| ((frame % 31) as i32 - 15) as f32 / 256.0).collect(),
(0..FRAMES).map(|frame| ((frame % 17) as i32 - 8) as f32 / 512.0).collect(),
]
}
fn scaled_signal(gain: f32) -> Vec<Vec<f32>> {
signal().into_iter().map(|channel| channel.into_iter().map(|value| value * gain).collect()).collect()
}
fn session(pdc: bool) -> Session {
let mut session = Session::default();
session.edit.click = false;
session.edit.delay_compensation = pdc;
session
}
fn add_source(session: &mut Session, output: Route) -> TrackId {
let source = SourceId(session.alloc());
session.pool.insert(source, Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: signal() })));
let clip = session.new_clip_id();
let track = session.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("PCM source"));
let track_state = session.track_mut(track).unwrap();
track_state.mixer.output = output;
track_state.playlist_mut().unwrap().clips.push(Clip::audio(clip, "PCM", source, 0, 0, FRAMES as i64));
track
}
fn add_aux(session: &mut Session, kind: TrackKind, input: BusId, output: Route) -> TrackId {
let track = session.add_track(kind, ChannelFormat::Stereo, None);
let mixer = &mut session.track_mut(track).unwrap().mixer;
mixer.input = Route::Bus(input);
mixer.output = output;
track
}
fn send(bus: BusId) -> SendSlot {
let mut send = SendSlot::new(Route::Bus(bus));
send.level_db = 0.0;
send
}
struct Chain {
session: Session,
buses: Vec<BusId>,
/// Logical routing order, deliberately different from the session's creation order.
tracks: Vec<TrackId>,
}
fn reverse_chain(pdc: bool, send_links: bool, folders: bool) -> Chain {
let mut session = session(pdc);
let buses: Vec<_> = (0..DEPTH).map(|stage| session.add_bus(&format!("Stage {stage}"), ChannelFormat::Stereo)).collect();
let mut tracks = Vec::new();
for stage in (0..DEPTH).rev() {
let kind = if folders && stage % 3 == 1 { TrackKind::Folder } else { TrackKind::Aux };
let output = if stage + 1 == DEPTH {
Route::Main
} else if send_links {
Route::None
} else {
Route::Bus(buses[stage + 1])
};
let track = add_aux(&mut session, kind, buses[stage], output);
if send_links && stage + 1 < DEPTH {
session.track_mut(track).unwrap().mixer.sends[0] = Some(send(buses[stage + 1]));
}
tracks.push(track);
}
tracks.reverse();
add_source(&mut session, Route::Bus(buses[0]));
Chain { session, buses, tracks }
}
fn assert_pcm(actual: &[Vec<f32>], expected: &[Vec<f32>], context: &str) {
assert_eq!(actual.len(), expected.len(), "{context}: channel count");
for (channel, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual.len(), expected.len(), "{context}: channel {channel} length");
for (frame, (actual, expected)) in actual.iter().zip(expected).enumerate() {
assert_eq!(actual, expected, "{context}: channel {channel}, frame {frame}");
}
}
}
fn render(session: &Session, block: usize) -> Vec<Vec<f32>> {
render_range(session, Range::new(0, FRAMES as i64), block)
}
fn engine_block(engine: &mut MixEngine, session: &Session) -> Vec<Vec<f32>> {
let mut out = vec![vec![0.0; 128]; 2];
engine.render(session, 0, 128, &mut out);
out
}
fn first_block(pcm: Vec<Vec<f32>>) -> Vec<Vec<f32>> {
pcm.into_iter().map(|channel| channel.into_iter().take(128).collect()).collect()
}
#[test]
fn reverse_created_twelve_aux_chain_preserves_every_sample() {
for pdc in [false, true] {
let chain = reverse_chain(pdc, false, false);
assert_pcm(&render(&chain.session, 1024), &signal(), "deep output chain");
}
}
#[test]
fn deep_dependencies_through_sends_without_outputs_preserve_every_sample() {
for pdc in [false, true] {
let chain = reverse_chain(pdc, true, false);
assert_pcm(&render(&chain.session, 97), &signal(), "deep send-only chain");
}
}
#[test]
fn output_and_send_to_the_same_bus_sum_audio_without_duplicate_dependency() {
for pdc in [false, true] {
let mut chain = reverse_chain(pdc, false, false);
chain.session.track_mut(chain.tracks[4]).unwrap().mixer.sends[0] = Some(send(chain.buses[5]));
assert_pcm(&render(&chain.session, 256), &scaled_signal(2.0), "duplicate destination");
}
}
#[test]
fn short_and_deep_branches_both_reach_the_join() {
for pdc in [false, true] {
let mut chain = reverse_chain(pdc, false, false);
add_aux(&mut chain.session, TrackKind::Aux, chain.buses[0], Route::Bus(chain.buses[DEPTH - 1]));
assert_pcm(&render(&chain.session, 512), &scaled_signal(2.0), "branch join");
}
}
#[test]
fn routing_folders_participate_in_deep_dependencies() {
for pdc in [false, true] {
let chain = reverse_chain(pdc, true, true);
assert_pcm(&render(&chain.session, 63), &signal(), "aux and folder send chain");
}
}
#[test]
fn output_edit_reorders_the_same_engine_before_the_next_block() {
let mut session = session(true);
let root = session.add_bus("Root", ChannelFormat::Stereo);
let join = session.add_bus("Join", ChannelFormat::Stereo);
add_aux(&mut session, TrackKind::Aux, join, Route::Main);
let producer = add_aux(&mut session, TrackKind::Aux, root, Route::Main);
add_source(&mut session, Route::Bus(root));
let mut engine = MixEngine::new(48_000.0, 128);
let expected = first_block(signal());
assert_pcm(&engine_block(&mut engine, &session), &expected, "before output edit");
session.track_mut(producer).unwrap().mixer.output = Route::Bus(join);
assert_pcm(&engine_block(&mut engine, &session), &expected, "after output edit");
}
#[test]
fn input_edit_reorders_the_same_engine_before_the_next_block() {
let mut session = session(true);
let root = session.add_bus("Root", ChannelFormat::Stereo);
let join = session.add_bus("Join", ChannelFormat::Stereo);
let consumer = add_aux(&mut session, TrackKind::Folder, root, Route::Main);
add_aux(&mut session, TrackKind::Aux, root, Route::Bus(join));
add_source(&mut session, Route::Bus(root));
let mut engine = MixEngine::new(48_000.0, 128);
let expected = first_block(signal());
assert_pcm(&engine_block(&mut engine, &session), &expected, "before input edit");
session.track_mut(consumer).unwrap().mixer.input = Route::Bus(join);
assert_pcm(&engine_block(&mut engine, &session), &expected, "after input edit");
}
#[test]
fn send_add_retarget_remove_and_readd_update_the_same_engine() {
let mut session = session(true);
let root = session.add_bus("Root", ChannelFormat::Stereo);
let join = session.add_bus("Join", ChannelFormat::Stereo);
let unused = session.add_bus("Unused", ChannelFormat::Stereo);
add_aux(&mut session, TrackKind::Aux, join, Route::Main);
let producer = add_aux(&mut session, TrackKind::Folder, root, Route::None);
add_source(&mut session, Route::Bus(root));
let mut engine = MixEngine::new(48_000.0, 128);
let expected = first_block(signal());
let silence = vec![vec![0.0; 128]; 2];
assert_pcm(&engine_block(&mut engine, &session), &silence, "before send");
session.track_mut(producer).unwrap().mixer.sends[0] = Some(send(join));
assert_pcm(&engine_block(&mut engine, &session), &expected, "send added");
session.track_mut(producer).unwrap().mixer.sends[0].as_mut().unwrap().target = Route::Bus(unused);
assert_pcm(&engine_block(&mut engine, &session), &silence, "send retargeted");
session.track_mut(producer).unwrap().mixer.sends[0].as_mut().unwrap().target = Route::Bus(join);
assert_pcm(&engine_block(&mut engine, &session), &expected, "send retargeted back");
session.track_mut(producer).unwrap().mixer.sends[0] = None;
assert_pcm(&engine_block(&mut engine, &session), &silence, "send removed");
session.track_mut(producer).unwrap().mixer.sends[0] = Some(send(join));
assert_pcm(&engine_block(&mut engine, &session), &expected, "send readded");
}
#[test]
fn track_reorder_removal_and_new_identity_rebuild_the_same_engine() {
let mut chain = reverse_chain(true, false, false);
let mut engine = MixEngine::new(48_000.0, 128);
let expected = first_block(signal());
assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "initial track order");
chain.session.tracks.reverse();
assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "tracks reordered");
chain.session.tracks.retain(|track| track.id != chain.tracks[6]);
let silence = vec![vec![0.0; 128]; 2];
assert_pcm(&engine_block(&mut engine, &chain.session), &silence, "track removed");
let replacement = add_aux(&mut chain.session, TrackKind::Aux, chain.buses[6], Route::Bus(chain.buses[7]));
assert_ne!(replacement, chain.tracks[6]);
assert_pcm(&engine_block(&mut engine, &chain.session), &expected, "track readded with new identity");
}
#[test]
fn strip_and_bus_format_edits_refresh_the_same_engine_workspace() {
let mut chain = reverse_chain(true, false, false);
let mut engine = MixEngine::new(48_000.0, 128);
let stereo = first_block(signal());
assert_pcm(&engine_block(&mut engine, &chain.session), &stereo, "initial stereo formats");
chain.session.track_mut(chain.tracks[5]).unwrap().format = ChannelFormat::Mono;
// A stereo bus folds to mono at -6 dB; the retained hard-left pan sends that mono to L.
let mono: Vec<_> = stereo[0].iter().zip(&stereo[1]).map(|(left, right)| 0.5 * (left + right)).collect();
assert_pcm(&engine_block(&mut engine, &chain.session), &[mono.clone(), vec![0.0; 128]], "mono strip");
chain.session.track_mut(chain.tracks[5]).unwrap().format = ChannelFormat::Stereo;
assert_pcm(&engine_block(&mut engine, &chain.session), &stereo, "stereo strip restored");
chain.session.busses.iter_mut().find(|bus| bus.id == chain.buses[5]).unwrap().format = ChannelFormat::Mono;
// A mono bus feeds both channels of the following stereo aux at unity.
assert_pcm(&engine_block(&mut engine, &chain.session), &[mono.clone(), mono], "mono bus");
}
#[test]
fn deep_routing_is_invariant_to_offline_and_callback_block_sizes() {
let chain = reverse_chain(true, true, true);
let expected = signal();
for block in [16, 63, 97, 256, 512, 1024] {
assert_pcm(&render(&chain.session, block), &expected, "offline block size");
}
let mut engine = MixEngine::new(48_000.0, 1024);
let callbacks = [1, 63, 257, 17, 511, 1024];
let mut out = vec![Vec::new(), Vec::new()];
let mut position = 0;
let mut callback = 0;
while position < FRAMES {
let frames = callbacks[callback % callbacks.len()].min(FRAMES - position);
let mut block = vec![vec![0.0; frames]; 2];
engine.render(&chain.session, position as i64, frames, &mut block);
for (destination, channel) in out.iter_mut().zip(block) {
destination.extend(channel);
}
position += frames;
callback += 1;
}
assert_pcm(&out, &expected, "varying callback sizes");
}
#[test]
fn self_loop_preserves_audio_and_selects_fallback_for_the_whole_graph() {
for pdc in [false, true] {
let mut chain = reverse_chain(pdc, false, false);
let loop_bus = chain.session.add_bus("Self loop", ChannelFormat::Stereo);
let aux = add_aux(&mut chain.session, TrackKind::Aux, loop_bus, Route::Main);
chain.session.track_mut(aux).unwrap().mixer.sends[0] = Some(send(loop_bus));
add_source(&mut chain.session, Route::Bus(loop_bus));
// The self-loop reads its source once, then writes back to the already-read bus.
// The disconnected deep chain remains silent under the established eight-hop fallback.
assert_pcm(&render(&chain.session, 97), &signal(), "self-loop whole-graph fallback");
}
}
#[test]
fn two_aux_cycle_preserves_session_order_audio_and_whole_graph_fallback() {
for pdc in [false, true] {
for reverse in [false, true] {
let mut chain = reverse_chain(pdc, false, false);
let a_bus = chain.session.add_bus("Cycle A", ChannelFormat::Stereo);
let b_bus = chain.session.add_bus("Cycle B", ChannelFormat::Stereo);
let (a, b) = if reverse {
let b = add_aux(&mut chain.session, TrackKind::Aux, b_bus, Route::Main);
let a = add_aux(&mut chain.session, TrackKind::Folder, a_bus, Route::Main);
(a, b)
} else {
let a = add_aux(&mut chain.session, TrackKind::Folder, a_bus, Route::Main);
let b = add_aux(&mut chain.session, TrackKind::Aux, b_bus, Route::Main);
(a, b)
};
chain.session.track_mut(a).unwrap().mixer.sends[0] = Some(send(b_bus));
chain.session.track_mut(b).unwrap().mixer.sends[0] = Some(send(a_bus));
add_source(&mut chain.session, Route::Bus(a_bus));
// A then B: both hear the source. B then A: B reads its still-empty bus.
// Buses clear each block, so neither order feeds a previous block back into this one.
let expected = scaled_signal(if reverse { 1.0 } else { 2.0 });
assert_pcm(&render(&chain.session, 256), &expected, "two-aux whole-graph fallback");
}
}
}
+4
View File
@@ -1,6 +1,10 @@
//! Integration tests, built as one test binary: each file is a module.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod aux_routing_order;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod bus_aux_interval;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod master_insert_latency;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod strip_mute_automation;
@@ -0,0 +1,242 @@
//! Ordinary strip mute changes sample gates without splitting processor blocks.
use soundcraft_audio_io::AudioBuffer;
use soundcraft_dsp::Plugin;
use soundcraft_mix::{MixEngine, PreparedInstance, render_range};
use soundcraft_model::{
AutoParam, AutomationLane, AutomationMode, ChannelFormat, Clip, Insert, Route, SendSlot, Session, SourceAudio, SourceId, TrackId, TrackKind,
};
use soundcraft_time::Range;
use std::sync::{Arc, Mutex};
const LEN: usize = 768;
fn fixture() -> (Session, TrackId) {
let mut s = Session::default();
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; LEN]; 2] })));
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
let clip = s.new_clip_id();
let tr = s.track_mut(t).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::audio(clip, "tone", SourceId(901), 0, 0, LEN as i64));
tr.mixer.pan = vec![-1.0, 1.0];
tr.mixer.output = Route::Main;
(s, t)
}
fn mute(s: &mut Session, t: TrackId, start: i64, end: i64) {
let lane = s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute);
for (at, value) in [(0, 0.0), (start - 1, 0.0), (start, 1.0), (end - 1, 1.0), (end, 0.0)] {
lane.set_point(at, value);
}
}
fn rendered(s: &Session, block: usize) -> Vec<Vec<f32>> {
render_range(s, Range::new(0, LEN as i64), block)
}
fn gated(actual: &[Vec<f32>], reference: &[Vec<f32>], start: usize, end: usize) {
assert_eq!(actual.len(), reference.len());
assert!(reference.iter().flatten().any(|x| x.abs() > 0.01));
for (got, want) in actual.iter().zip(reference) {
assert_eq!(got.len(), LEN);
for i in 0..LEN {
assert_eq!(got[i], if (start..end).contains(&i) { 0.0 } else { want[i] }, "frame {i}");
}
}
}
#[test]
fn internal_and_exact_block_edges_are_partition_independent() {
for (start, end) in [(101, 200), (128, 256), (1, 767)] {
let (mut s, t) = fixture();
let reference = rendered(&s, 512);
mute(&mut s, t, start, end);
for block in [16, 63, 128, 512, 1024] {
gated(&rendered(&s, block), &reference, start as usize, end as usize);
}
}
}
#[test]
fn existing_held_mute_curve_and_threshold_are_preserved() {
let (mut s, t) = fixture();
let reference = rendered(&s, 512);
let lane = s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute);
lane.set_point(0, 0.49);
lane.set_point(200, 0.5);
gated(&rendered(&s, 512), &reference, 200, LEN);
}
#[test]
fn pre_and_post_fader_send_audio_share_the_track_mute_gate() {
for pre in [false, true] {
let (mut s, t) = fixture();
let bus = s.add_bus("Send", ChannelFormat::Stereo);
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
s.track_mut(aux).unwrap().mixer.output = Route::Main;
let tr = s.track_mut(t).unwrap();
tr.mixer.output = Route::None;
tr.mixer.volume_db = -9.0;
let mut send = SendSlot::new(Route::Bus(bus));
send.pre_fader = pre;
send.level_db = -3.0;
tr.mixer.sends[0] = Some(send);
let reference = rendered(&s, 512);
mute(&mut s, t, 101, 200);
for block in [63, 512] {
gated(&rendered(&s, block), &reference, 101, 200);
}
}
}
#[test]
fn off_empty_and_first_empty_lane_keep_exact_unautomated_pcm() {
let (mut s, t) = fixture();
let reference = rendered(&s, 512);
s.track_mut(t).unwrap().lane_mut(&AutoParam::Mute);
assert_eq!(rendered(&s, 512), reference);
let mut second = AutomationLane::new(AutoParam::Mute);
second.set_point(0, 1.0);
s.track_mut(t).unwrap().automation.push(second);
assert_eq!(rendered(&s, 512), reference, "first empty lane remains authoritative");
s.track_mut(t).unwrap().automation.clear();
mute(&mut s, t, 101, 200);
s.track_mut(t).unwrap().mixer.automation_mode = AutomationMode::Off;
assert_eq!(rendered(&s, 512), reference);
}
#[test]
fn reading_modes_retain_existing_policy() {
for mode in AutomationMode::ALL.into_iter().filter(|mode| mode.reads()) {
let (mut s, t) = fixture();
let reference = rendered(&s, 512);
mute(&mut s, t, 101, 200);
s.track_mut(t).unwrap().mixer.automation_mode = mode;
gated(&rendered(&s, 512), &reference, 101, 200);
}
}
#[test]
fn static_mute_and_solo_suppression_cannot_be_reopened_by_automation() {
for solo in [false, true] {
let (mut s, t) = fixture();
if solo {
let other = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
s.track_mut(other).unwrap().mixer.solo = true;
} else {
s.track_mut(t).unwrap().mixer.mute = true;
}
let reference = rendered(&s, 512);
assert!(reference.iter().flatten().all(|x| x.abs() < 1e-6));
mute(&mut s, t, 101, 200);
gated_silent(&rendered(&s, 512));
}
}
fn gated_silent(output: &[Vec<f32>]) {
assert!(output.iter().flatten().all(|x| x.abs() < 1e-6));
}
#[test]
fn aux_and_folder_inputs_gate_their_audio_inside_the_block() {
for kind in [TrackKind::Aux, TrackKind::Folder] {
let (mut s, source) = fixture();
let bus = s.add_bus("Input", ChannelFormat::Stereo);
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
let target = s.add_track(kind, ChannelFormat::Stereo, Some("Return"));
s.track_mut(target).unwrap().mixer.input = Route::Bus(bus);
s.track_mut(target).unwrap().mixer.output = Route::Main;
let reference = rendered(&s, 512);
mute(&mut s, target, 101, 200);
gated(&rendered(&s, 512), &reference, 101, 200);
}
}
#[test]
fn no_active_mute_lane_preserves_fader_and_pan_schedules_exactly() {
let (mut s, t) = fixture();
for (param, a, b) in [(AutoParam::Volume, -12.0, -3.0), (AutoParam::Pan(0), -1.0, 0.5)] {
let lane = s.track_mut(t).unwrap().lane_mut(&param);
lane.set_point(0, a);
lane.set_point(LEN as i64, b);
}
for block in [63, 256, 512] {
let reference = rendered(&s, block);
let mut with_lane = s.clone();
with_lane.track_mut(t).unwrap().lane_mut(&AutoParam::Mute).set_point(0, 0.0);
assert_eq!(rendered(&with_lane, block), reference);
with_lane.track_mut(t).unwrap().mixer.automation_mode = AutomationMode::Off;
let mut no_lane = with_lane.clone();
no_lane.track_mut(t).unwrap().automation.retain(|lane| lane.param != AutoParam::Mute);
assert_eq!(rendered(&with_lane, block), rendered(&no_lane, block));
}
}
#[test]
fn static_vca_mute_remains_dominant_over_member_mute_automation() {
let (mut s, t) = fixture();
let vca = s.add_track(TrackKind::Vca, ChannelFormat::Stereo, Some("VCA"));
s.track_mut(vca).unwrap().mixer.mute = true;
s.track_mut(t).unwrap().mixer.vca = Some(vca.0);
let reference = rendered(&s, 512);
gated_silent(&reference);
mute(&mut s, t, 101, 200);
gated_silent(&rendered(&s, 512));
}
struct Counted {
inner: Box<dyn Plugin>,
calls: Arc<Mutex<Vec<usize>>>,
instrument: bool,
}
impl Plugin for Counted {
fn info(&self) -> &'static soundcraft_dsp::PluginInfo {
self.inner.info()
}
fn prepare(&mut self, sr: f32, block: usize, ch: usize) {
self.inner.prepare(sr, block, ch);
}
fn reset(&mut self) {
self.inner.reset();
}
fn set_param(&mut self, id: &str, value: f32) -> bool {
self.inner.set_param(id, value)
}
fn param(&self, id: &str) -> Option<f32> {
self.inner.param(id)
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
self.calls.lock().unwrap().push(frames);
if self.instrument {
for channel in io {
channel.iter_mut().take(frames).for_each(|sample| *sample = 0.25);
}
} else {
self.inner.process(io, frames);
}
}
}
#[test]
fn hosted_processing_keeps_the_callers_blocks_and_unmuted_pcm() {
for instrument in [false, true] {
let (mut s, t) = fixture();
let reference = rendered(&s, 256);
mute(&mut s, t, 101, 200);
let slot = if instrument {
s.track_mut(t).unwrap().kind = TrackKind::Instrument;
s.track_mut(t).unwrap().instrument = Some(Insert::new("clap:test.mute-cadence"));
soundcraft_mix::INSTRUMENT_SLOT
} else {
s.track_mut(t).unwrap().mixer.inserts[0] = Some(Insert::new("clap:test.mute-cadence"));
0
};
let calls = Arc::new(Mutex::new(Vec::with_capacity(4)));
let mut plugin = soundcraft_dsp::create("gain").unwrap();
plugin.prepare(48_000.0, 256, 2);
let mut mix = MixEngine::new(48_000.0, 256);
mix.set_external_instances(true);
mix.adopt(vec![PreparedInstance {
track: t,
slot,
id: "clap:test.mute-cadence".into(),
ch: 2,
plugin: Box::new(Counted { inner: plugin, calls: Arc::clone(&calls), instrument }),
}]);
let mut output = vec![Vec::new(); 2];
for (pos, frames) in [(0, 256), (256, 128), (384, 256), (640, 128)] {
let mut block = vec![vec![0.0; frames]; 2];
mix.render(&s, pos, frames, &mut block);
for (out, channel) in output.iter_mut().zip(block) {
out.extend(channel);
}
}
assert_eq!(*calls.lock().unwrap(), vec![256, 128, 256, 128]);
gated(&output, &reference, 101, 200);
}
}
+64 -7
View File
@@ -242,15 +242,33 @@ impl Clip {
g
}
/// Gain envelope value in dB at `rel` samples from the clip start (0 without an envelope).
pub fn env_db_at(&self, rel: Samples) -> f32 {
env_value(&self.gain_env, rel)
}
/// Move the start edge to `new_start` keeping material anchored (trim start).
pub fn trim_start_to(&mut self, new_start: Samples) {
let new_start = new_start.min(self.end() - 1);
let delta = new_start - self.start;
if let ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } = &mut self.content {
*offset = offset.saturating_add(delta);
// #907: these are public APIs, so they have to survive arguments a command would never
// pass (commands clamp to `MAX_POSITION` first). `Samples` is `i64`, so an extreme
// `new_start` against an extreme `start` wrapped in release and panicked in debug.
// Never so far back that the length would pass `MAX_POSITION`: trimming the start keeps the end.
let end = self.end();
let new_start = new_start.max(end.saturating_sub(MAX_POSITION)).clamp(-MAX_POSITION, MAX_POSITION).min(end.saturating_sub(1));
let delta = new_start.saturating_sub(self.start);
// The mixer plays a stretched audio clip from `offset + rel / stretch`.
let stretch = if self.stretch.is_finite() && self.stretch > 0.0 { self.stretch } else { 1.0 };
match &mut self.content {
ClipContent::Audio { offset, .. } if (stretch - 1.0).abs() >= 1e-9 => {
*offset = offset.saturating_add((delta as f64 / stretch).round() as Samples);
}
ClipContent::Audio { offset, .. } | ClipContent::Video { offset, .. } => {
*offset = offset.saturating_add(delta);
}
ClipContent::Midi { .. } => {}
}
// MIDI note ticks are re-anchored by the engine, which knows the tempo map.
self.length -= delta;
self.length = self.length.saturating_sub(delta).clamp(1, MAX_POSITION);
self.start = new_start;
if self.sync_point != 0 {
self.sync_point = self.sync_point.saturating_sub(delta);
@@ -267,8 +285,9 @@ impl Clip {
/// Move the end edge to `new_end`.
pub fn trim_end_to(&mut self, new_end: Samples) {
let new_end = new_end.max(self.start + 1);
self.length = new_end - self.start;
// #907: saturating, like `trim_start_to`.
let new_end = new_end.clamp(-MAX_POSITION, MAX_POSITION).max(self.start.saturating_add(1));
self.length = new_end.saturating_sub(self.start).clamp(1, MAX_POSITION);
self.drop_stray_sync();
self.clamp_fades();
}
@@ -479,4 +498,42 @@ mod tests {
c.trim_start_to(900);
assert!(c.fade_out.len <= c.length);
}
/// #907: `trim_start_to` / `trim_end_to` are public APIs, so they have to survive arguments a
/// command would never pass. `Samples` is `i64`, so an extreme `new_start` against an extreme
/// `start` wrapped in release and panicked in debug. Every position stays within
/// `±MAX_POSITION` and every length within `1..=MAX_POSITION`.
#[test]
fn trims_saturate_on_extreme_arguments() {
for &new_start in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] {
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 1000, MAX_POSITION / 2, 1000);
c.trim_start_to(new_start);
assert!((-(MAX_POSITION as i128)..=(MAX_POSITION as i128)).contains(&(c.start as i128)), "start {new_start}: {}", c.start);
assert!((1..=MAX_POSITION).contains(&c.length), "start {new_start}: length {}", c.length);
assert_eq!(c.end(), MAX_POSITION / 2 + 1000, "start {new_start} moved the end");
}
for &new_end in &[Samples::MIN, -(MAX_POSITION * 2), -1, 0, MAX_POSITION, MAX_POSITION * 2, Samples::MAX] {
let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 1000, -(MAX_POSITION / 2), 1000);
c.trim_end_to(new_end);
assert!((1..=MAX_POSITION).contains(&c.length), "end {new_end}: length {}", c.length);
assert!(c.end() - c.start == c.length, "end {new_end}: start {} end {}", c.start, c.end());
}
}
#[test]
fn trim_start_of_stretched_audio_advances_offset_by_source_time() {
// The mixer plays source `offset + rel / stretch`, so a split at timeline 1000 of a 2x clip
// must start the right half at source sample 500.
let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 0, 2000);
c.stretch = 2.0;
c.trim_start_to(1000);
assert_eq!(c.source_offset(), 500);
let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 2000);
c.stretch = 0.5;
c.trim_start_to(1000);
assert_eq!(c.source_offset(), 2000);
let mut c = Clip::audio(ClipId(3), "c", SourceId(1), 0, 0, 2000);
c.trim_start_to(1000);
assert_eq!(c.source_offset(), 1000);
}
}
+71
View File
@@ -541,6 +541,7 @@ impl Session {
}
let mut s: Session = serde_json::from_value(v).map_err(|e| SessionError::Format(e.to_string()))?;
s.sanitize();
s.validate()?;
Ok(s)
}
@@ -633,6 +634,25 @@ impl Session {
}
self.tempo.sanitize();
}
/// Reject a document whose ids are ambiguous or exhausted, run after [`Session::sanitize`] on
/// untrusted data. Duplicate ids in a namespace make commands, selections and source
/// associations act on an arbitrary one of the objects; a maximal `next_id` -- or a maximal
/// existing id, which `sanitize` folds into `next_id` -- makes every later allocation return
/// `u64::MAX` and repeat itself.
fn validate(&self) -> Result<(), SessionError> {
if self.next_id == u64::MAX {
return Err(SessionError::Invalid("the session id space is exhausted".into()));
}
distinct("track", self.tracks.iter().map(|t| t.id.0))?;
distinct("clip", self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| c.id.0))))?;
distinct("source", self.sources.iter().map(|s| s.id.0))?;
distinct("video", self.videos.iter().map(|v| v.id.0))?;
distinct("bus", self.busses.iter().map(|b| b.id.0))?;
distinct("marker", self.markers.iter().map(|m| m.id.0))?;
distinct("group", self.groups.iter().map(|g| g.id.0))?;
Ok(())
}
}
/// The most characters kept of a name read from a file: longer ones can't be shown, and drawing
@@ -648,6 +668,17 @@ pub fn truncate_chars(s: &mut String, max: usize) {
}
}
/// Every id in `ids` distinct, or an error naming the namespace and the repeated id.
fn distinct(kind: &str, ids: impl Iterator<Item = u64>) -> Result<(), SessionError> {
let mut seen = std::collections::HashSet::new();
for id in ids {
if !seen.insert(id) {
return Err(SessionError::Invalid(format!("duplicate {kind} id {id}")));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
@@ -681,6 +712,19 @@ mod tests {
assert_eq!(back, s);
}
#[test]
fn session_with_legacy_fit_height_loads() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, None);
if let Some(track) = s.track_mut(t) {
track.height = crate::TrackHeight::Large;
}
let text = s.to_json().unwrap().replace("\"height\": \"Large\"", "\"height\": \"Fit\"");
assert!(text.contains("\"height\": \"Fit\""));
let back = Session::from_json(&text).unwrap();
assert_eq!(back.track(t).map(|tr| tr.height), Some(crate::TrackHeight::Medium));
}
#[test]
fn video_clip_round_trips_and_old_sessions_load() {
let mut s = Session::default();
@@ -770,6 +814,33 @@ mod tests {
assert!(matches!(Session::from_json(&v.to_string()), Err(SessionError::Version(999))));
}
#[test]
fn duplicate_ids_are_rejected_on_load() {
let mut s = Session::default();
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut v = serde_json::to_value(&s).unwrap();
v["tracks"][1]["id"] = v["tracks"][0]["id"].clone();
let err = Session::from_json(&v.to_string()).unwrap_err();
assert!(matches!(&err, SessionError::Invalid(m) if m.contains("duplicate track id")), "{err}");
}
#[test]
fn an_exhausted_id_space_is_rejected_on_load() {
let mut s = Session::default();
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut v = serde_json::to_value(&s).unwrap();
v["next_id"] = serde_json::json!(u64::MAX);
let err = Session::from_json(&v.to_string()).unwrap_err();
assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}");
// A maximal existing id is exhausted too, however `next_id` was written: every later
// allocation would return `u64::MAX` again.
let mut v = serde_json::to_value(&s).unwrap();
v["tracks"][0]["id"] = serde_json::json!(u64::MAX);
let err = Session::from_json(&v.to_string()).unwrap_err();
assert!(matches!(&err, SessionError::Invalid(m) if m.contains("exhausted")), "{err}");
}
#[test]
fn sanitize_repairs() {
let mut s = Session::default();
+55
View File
@@ -84,6 +84,7 @@ impl TrackKind {
/// Track heights (View › Track Height presets).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize)]
#[serde(from = "TrackHeightRepr")]
pub enum TrackHeight {
/// Any height from Micro to Extreme, in points.
Custom(u16),
@@ -99,6 +100,51 @@ pub enum TrackHeight {
Fit(u16),
}
/// What a saved track height may look like: the current form, or the bare `"Fit"` string older sessions used.
#[derive(serde::Deserialize)]
#[serde(untagged)]
enum TrackHeightRepr {
Current(TrackHeightCurrent),
LegacyFit(LegacyFit),
}
#[derive(serde::Deserialize)]
enum LegacyFit {
Fit,
}
/// Mirror of `TrackHeight` that derives the current serialized form.
#[derive(serde::Deserialize)]
enum TrackHeightCurrent {
Custom(u16),
Micro,
Mini,
Small,
Medium,
Large,
Jumbo,
Extreme,
Fit(u16),
}
impl From<TrackHeightRepr> for TrackHeight {
fn from(repr: TrackHeightRepr) -> Self {
match repr {
TrackHeightRepr::Current(TrackHeightCurrent::Custom(p)) => TrackHeight::Custom(p),
TrackHeightRepr::Current(TrackHeightCurrent::Micro) => TrackHeight::Micro,
TrackHeightRepr::Current(TrackHeightCurrent::Mini) => TrackHeight::Mini,
TrackHeightRepr::Current(TrackHeightCurrent::Small) => TrackHeight::Small,
TrackHeightRepr::Current(TrackHeightCurrent::Medium) => TrackHeight::Medium,
TrackHeightRepr::Current(TrackHeightCurrent::Large) => TrackHeight::Large,
TrackHeightRepr::Current(TrackHeightCurrent::Jumbo) => TrackHeight::Jumbo,
TrackHeightRepr::Current(TrackHeightCurrent::Extreme) => TrackHeight::Extreme,
TrackHeightRepr::Current(TrackHeightCurrent::Fit(p)) => TrackHeight::Fit(p),
// Older sessions stored Fit with no size, drawn 88 points tall: the default Medium height.
TrackHeightRepr::LegacyFit(LegacyFit::Fit) => TrackHeight::Medium,
}
}
}
impl TrackHeight {
pub const ALL: [TrackHeight; 7] = [
TrackHeight::Micro,
@@ -328,6 +374,15 @@ impl Track {
mod tests {
use super::*;
#[test]
fn legacy_fit_string_loads_as_default_height() {
assert_eq!(serde_json::from_str::<TrackHeight>("\"Fit\"").unwrap(), TrackHeight::Medium);
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Fit\":120}").unwrap(), TrackHeight::Fit(120));
assert_eq!(serde_json::to_string(&TrackHeight::Fit(120)).unwrap(), "{\"Fit\":120}");
assert_eq!(serde_json::from_str::<TrackHeight>("\"Large\"").unwrap(), TrackHeight::Large);
assert_eq!(serde_json::from_str::<TrackHeight>("{\"Custom\":50}").unwrap(), TrackHeight::Custom(50));
}
#[test]
fn new_track_defaults() {
let t = Track::new(TrackId(1), "Gtr", TrackKind::Audio, ChannelFormat::Stereo, TRACK_COLORS[0]);
+123 -10
View File
@@ -362,8 +362,9 @@ impl AudioState {
let sample = |c: usize| {
let ch = self.pending.get(c);
let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0);
let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a);
a + (b - a) * fr
// `pending` is BLOCK long; only `pending_len` frames are valid. Hold the last one at the edge.
let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a };
a * (1.0 - fr) + b * fr
};
if channels < nch {
// Fold surround to stereo first; a mono device averages the two sides.
@@ -381,18 +382,15 @@ impl AudioState {
}
for (c, o) in frame.iter_mut().enumerate() {
*o = match c {
0 if channels == 1 => {
let mono = (0.5 * l + 0.5 * r).clamp(-1.0, 1.0);
if mono.is_finite() { mono } else { 0.0 }
}
0 => l.clamp(-1.0, 1.0),
1 => r.clamp(-1.0, 1.0),
0 if channels == 1 => out_sample(0.5 * l + 0.5 * r),
0 => out_sample(l),
1 => out_sample(r),
_ => 0.0,
};
}
} else {
for (c, o) in frame.iter_mut().enumerate() {
*o = if c < nch.max(1) && sounding { sample(c).clamp(-1.0, 1.0) } else { 0.0 };
*o = if c < nch.max(1) && sounding { out_sample(sample(c)) } else { 0.0 };
}
}
self.read += step;
@@ -400,6 +398,11 @@ impl AudioState {
}
}
/// Clamp to the device range, mapping NaN and infinities to silence.
fn out_sample(v: f32) -> f32 {
if v.is_finite() { v.clamp(-1.0, 1.0) } else { 0.0 }
}
/// UI-thread bookkeeping of the third-party instances the audio thread holds.
struct Side {
/// What was last created for each slot: (plugin id, channels, created ok).
@@ -684,6 +687,17 @@ impl Player {
self.side().capture_states()
}
/// The plugins that said their state changed since the last call, so the session can keep
/// changes made only in plugin editors.
pub fn changed_states(&self) -> Vec<(TrackId, usize)> {
self.side().changed_states()
}
/// Reads the state of the plugins in `slots`.
pub fn capture_states_of(&self, slots: &[(TrackId, usize)]) -> PluginStates {
self.side().capture_states_of(slots)
}
/// Per-UI-frame housekeeping: drops retired instances and services plugin editors. Returns
/// the parameter edits made in open editors since the last call.
pub fn idle(&self) -> Vec<EditorEdit> {
@@ -812,6 +826,24 @@ impl Side {
self.states.iter_mut().filter(|(k, _)| !self.refused.contains(*k)).filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?))).collect()
}
fn changed_states(&mut self) -> Vec<(TrackId, usize)> {
self.editors.iter_mut().filter_map(|(k, e)| e.state_changed().then_some(*k)).collect()
}
fn capture_states_of(&mut self, slots: &[(TrackId, usize)]) -> PluginStates {
self.states
.iter_mut()
.filter(|(k, _)| !self.refused.contains(*k) && slots.contains(k))
.filter_map(|(&(t, slot), r)| Some((t, slot, r.save_state()?)))
.collect()
}
/// A plugin that marks its state changed while it loads or restores it hasn't changed.
fn add_editor(&mut self, key: (TrackId, usize), mut e: Box<dyn PluginEditor>) {
let _ = e.state_changed();
self.editors.insert(key, e);
}
fn open_editor(&mut self, key: (TrackId, usize)) -> Result<(), String> {
let e = self.editors.get_mut(&key).ok_or_else(|| "this plugin has no editor (or it is not loaded)".to_string())?;
e.open()?;
@@ -848,7 +880,7 @@ impl Side {
self.shadow.insert(key, (spec.id.clone(), spec.ch, made.is_some()));
if let Some(soundcraft_mix::NewInstance { mut plugin, refused_state }) = made {
if let Some(e) = plugin.editor() {
self.editors.insert(key, e);
self.add_editor(key, e);
}
if let Some(r) = plugin.state_reader() {
self.states.insert(key, r);
@@ -1223,6 +1255,26 @@ mod tests {
}
}
#[test]
fn resampling_at_a_short_block_edge_does_not_mix_in_stale_frames() {
use soundcraft_model::ChannelFormat;
// DC for the first long block, then silence. The device runs slower than the session, so
// reads land between frames; a short block leaves the long block's DC past `pending_len`.
let mut st = audio_output_state(ChannelFormat::Stereo, vec![vec![0.8; BLOCK]; 2]);
st.device_rate = 44_100.0;
let mut long = vec![0.0; 2 * 470];
st.fill(&mut long, 2);
assert_eq!(st.pending_len, BLOCK);
let mut short = [0.0; 2 * 8];
for call in 0..20 {
st.fill(&mut short, 2);
assert!(st.pending_len < BLOCK);
// The first frame of the first short call still reads the DC block's last frame.
let from = if call == 0 { 2 } else { 0 };
assert!(short[from..].iter().all(|x| x.abs() < 1e-6), "call {call}: {short:?}");
}
}
#[test]
fn mono_device_preserves_centered_mono_level_and_includes_surround_right_channels() {
use soundcraft_model::ChannelFormat;
@@ -1253,6 +1305,18 @@ mod tests {
assert!(out.iter().all(|x| *x == 0.0), "overflow in the stereo fold-down must not send NaN to a mono device");
}
#[test]
fn interpolation_overflow_between_huge_samples_never_reaches_the_device() {
use soundcraft_model::ChannelFormat;
let alternating: Vec<f32> = (0..512).map(|i| if i % 2 == 0 { -3.4e38 } else { 3.4e38 }).collect();
for (format, nch, device) in [(ChannelFormat::Stereo, 2, 2), (ChannelFormat::Stereo, 2, 8), (ChannelFormat::Mono, 1, 1)] {
let mut st = audio_output_state(format, vec![alternating.clone(); nch]);
let mut out = vec![0.5; 64 * device];
st.fill(&mut out, device);
assert!(out.iter().all(|x| x.is_finite() && (-1.0..=1.0).contains(x)), "{format:?} on {device} channels: {out:?}");
}
}
#[test]
fn surround_main_maps_to_device_channels_or_folds_down() {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
@@ -1370,6 +1434,55 @@ mod tests {
assert_eq!(side.capture_states(), vec![(TrackId(1), 0, b"defaults".to_vec())]);
}
/// An editor whose plugin said its state changed (CLAP `state.mark_dirty`).
struct Dirty(bool);
impl PluginEditor for Dirty {
fn open(&mut self) -> Result<(), String> {
Ok(())
}
fn close(&mut self) {}
fn is_open(&self) -> bool {
true
}
fn idle(&mut self) -> Vec<(String, f32)> {
Vec::new()
}
fn state_changed(&mut self) -> bool {
std::mem::take(&mut self.0)
}
}
#[test]
fn changed_states_are_reported_once_and_read_for_the_session() {
let mut side = Side::new(48_000.0, channel().1);
let (a, b) = (TrackId(1), TrackId(2));
let synth = (a, soundcraft_mix::INSTRUMENT_SLOT);
side.states.insert((a, 0), Box::new(|| Some(b"a0".to_vec())));
side.states.insert((a, 1), Box::new(|| Some(b"a1".to_vec())));
side.states.insert(synth, Box::new(|| Some(b"synth".to_vec())));
side.states.insert((b, 0), Box::new(|| Some(b"b0".to_vec())));
side.editors.insert((a, 0), Box::new(Dirty(true)));
side.editors.insert((a, 1), Box::new(Dirty(false)));
side.editors.insert((b, 0), Box::new(Dirty(true)));
side.refused.insert((b, 0));
let mut changed = side.changed_states();
changed.sort();
assert_eq!(changed, vec![(a, 0), (b, 0)]);
assert!(side.changed_states().is_empty(), "each change is reported once");
let want = vec![(a, 0, b"a0".to_vec()), (a, soundcraft_mix::INSTRUMENT_SLOT, b"synth".to_vec())];
assert_eq!(side.capture_states_of(&[(a, 0), synth, (b, 0)]), want);
}
#[test]
fn a_state_marked_changed_while_loading_is_not_a_change() {
let mut side = Side::new(48_000.0, channel().1);
let k = (TrackId(1), 0);
side.states.insert(k, Box::new(|| Some(b"loaded".to_vec())));
side.add_editor(k, Box::new(Dirty(true)));
assert!(side.changed_states().is_empty());
}
#[test]
fn side_creates_each_missing_instance_once() {
use soundcraft_model::{ChannelFormat, Insert, TrackKind};
+13
View File
@@ -186,6 +186,19 @@ mod tests {
}
}
#[test]
fn parse_round_trips_timecode_negative() {
let (sr, map, _) = ctx();
let rate = FrameRate::Fps25;
for fmt in [TimeFormat::Timecode, TimeFormat::FeetFrames] {
for s in [-48_000i64, -96_000 * 3, -1_440_000] {
let text = format_position(s, fmt, sr, &map, rate, 0, 0);
let back = parse_position(&text, fmt, sr, &map, rate, 0, 0).unwrap();
assert_eq!(back, s, "{fmt:?} {s} → {text} → {back}");
}
}
}
#[test]
fn parse_rejects_garbage() {
let (sr, map, rate) = ctx();
+73 -4
View File
@@ -155,15 +155,49 @@ impl GridValue {
let k = (t - bar_tick).div_euclid(step);
map.tick_to_samples(bar_tick + k * step, sr)
}
_ => {
let step = self.step_samples(pos, sr, map, rate);
pos.div_euclid(step).saturating_mul(step)
}
_ => self.abs_line(self.abs_index_at_or_before(pos, sr, rate), sr, rate),
}
}
/// Position of absolute grid line `k` (Seconds, Frames, Samples), at an exact multiple of the step
/// rounded once, so rounding never accumulates from line to line.
fn abs_line(&self, k: i64, sr: SampleRate, rate: FrameRate) -> Samples {
match *self {
GridValue::Seconds(s) => to_samples(k as f64 * Self::seconds_step(s, sr)),
GridValue::Frames(f) => rate.samples_at_frame(k.saturating_mul(f.clamp(1, 1_000_000)), sr),
GridValue::Samples(n) => k.saturating_mul(n.max(1)),
GridValue::Note { .. } => 0,
}
}
/// Index of the absolute grid line at or before `pos`.
fn abs_index_at_or_before(&self, pos: Samples, sr: SampleRate, rate: FrameRate) -> i64 {
let mut k = match *self {
GridValue::Seconds(s) => (pos as f64 / Self::seconds_step(s, sr)).floor() as i64,
GridValue::Frames(f) => rate.frames_at(pos, sr).div_euclid(f.clamp(1, 1_000_000)),
GridValue::Samples(n) => return pos.div_euclid(n.max(1)),
GridValue::Note { .. } => return 0,
};
// Lines are rounded to whole samples, so the floored index can be one off either way.
if self.abs_line(k, sr, rate) > pos {
k = k.saturating_sub(1);
} else if self.abs_line(k.saturating_add(1), sr, rate) <= pos {
k = k.saturating_add(1);
}
k
}
/// The exact (fractional) Seconds step in samples, at least one sample.
fn seconds_step(s: f64, sr: SampleRate) -> f64 {
(s.clamp(0.000_01, 3600.0) * sr.as_f64()).max(1.0)
}
/// The first grid line strictly after `line`.
pub fn next_line(&self, line: Samples, sr: SampleRate, map: &TempoMap, rate: FrameRate) -> Samples {
if !matches!(self, GridValue::Note { .. }) {
let next = self.abs_line(self.abs_index_at_or_before(line, sr, rate).saturating_add(1), sr, rate);
return if next > line { next } else { line.saturating_add(1) };
}
let step = self.step_samples(line, sr, map, rate);
let candidate = self.line_at_or_before(line.saturating_add(step), sr, map, rate);
if candidate > line { candidate } else { line.saturating_add(step) }
@@ -222,6 +256,41 @@ mod tests {
assert_eq!(GridValue::Samples(0).step_samples(0, sr, &map, rate), 1);
}
#[test]
fn seconds_grid_lines_land_on_whole_seconds() {
let (sr, map, rate) = (SampleRate::HZ_44100, TempoMap::default(), FrameRate::Fps30);
for g in [GridValue::Seconds(0.001), GridValue::Seconds(0.01)] {
for n in 0..20 {
let s = n * 44_100;
assert_eq!(g.snap(s, sr, &map, rate), s, "{g:?} at {n} s");
assert_eq!(g.line_at_or_before(s, sr, &map, rate), s, "{g:?} at {n} s");
}
}
}
#[test]
fn frame_grid_lines_land_on_frame_starts() {
let sr = SampleRate::HZ_48000;
let map = TempoMap::default();
for rate in [FrameRate::Fps29_97, FrameRate::Fps23_976] {
for f in [1, 10] {
let g = GridValue::Frames(f);
for k in (0..5000).step_by(37) {
let s = rate.samples_at_frame(k, sr);
let line = rate.samples_at_frame(k.div_euclid(f) * f, sr);
assert_eq!(g.line_at_or_before(s, sr, &map, rate), line, "{rate:?} {f} frames at frame {k}");
if k % f == 0 {
assert_eq!(g.snap(s, sr, &map, rate), s, "{rate:?} {f} frames at frame {k}");
}
}
let start = rate.samples_at_frame(4000, sr);
let end = rate.samples_at_frame(4000 + 20 * f, sr);
let want: Vec<_> = (0..20).map(|i| rate.samples_at_frame(4000 + i * f, sr)).collect();
assert_eq!(g.lines(start, end, sr, &map, rate, 100), want, "{rate:?} {f} frames");
}
}
}
#[test]
fn lines_are_capped() {
let (sr, map, rate) = (SampleRate::HZ_48000, TempoMap::default(), FrameRate::Fps25);
+15 -1
View File
@@ -108,7 +108,9 @@ impl FrameRate {
if n == 0 {
return 0;
}
let v = (i128::from(f) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
// Round half away from zero so negative frames mirror positive ones.
let mag = (i128::from(f.unsigned_abs()) * i128::from(sr.hz()) * i128::from(d) + i128::from(n) / 2) / i128::from(n);
let v = if f < 0 { -mag } else { mag };
i64::try_from(v).unwrap_or(if v < 0 { i64::MIN } else { i64::MAX })
}
@@ -229,6 +231,18 @@ mod tests {
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), 48_000 * 3661);
}
#[test]
fn negative_timecode_rounds_symmetrically() {
let sr = SampleRate::HZ_48000;
let tc = Timecode::parse("-00:00:01:00").unwrap();
assert_eq!(tc.to_samples(FrameRate::Fps25, sr), -48_000);
for r in [FrameRate::Fps25, FrameRate::Fps29_97, FrameRate::Fps29_97Drop, FrameRate::Fps23_976] {
for f in [1i64, 7, 25, 1799, 107_892] {
assert_eq!(r.samples_at_frame(-f, sr), -r.samples_at_frame(f, sr), "{r:?} frame {f}");
}
}
}
#[test]
fn drop_frame_labels() {
let r = FrameRate::Fps29_97Drop;
+3
View File
@@ -27,6 +27,9 @@ serde_json.workspace = true
log.workspace = true
image.workspace = true
[target.'cfg(target_os = "linux")'.dependencies]
zbus = "5"
[dev-dependencies]
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
+135
View File
@@ -0,0 +1,135 @@
//! The desktop's dark/light setting for the System theme on Linux, where eframe/winit reports no
//! system theme to egui.
//!
//! The XDG settings portal (`org.freedesktop.appearance` / `color-scheme`) is read once on a worker
//! thread, which then follows its `SettingChanged` signal and wakes the UI only when the value
//! changes. Nothing is polled. Elsewhere egui's own report (`Context::system_theme`) is enough.
/// egui's own report when it has one, otherwise the desktop's.
pub fn resolve(reported: Option<egui::Theme>, desktop: impl FnOnce() -> Option<egui::Theme>) -> Option<egui::Theme> {
reported.or_else(desktop)
}
/// The desktop's appearance, or `None` when it is unknown or this platform has no portal.
/// Unit tests never ask the real desktop, so they do not depend on the machine's setting.
pub fn desktop_theme(ctx: &egui::Context) -> Option<egui::Theme> {
#[cfg(all(target_os = "linux", not(test)))]
{
linux::current(ctx)
}
#[cfg(any(not(target_os = "linux"), test))]
{
let _ = ctx;
None
}
}
#[cfg(target_os = "linux")]
#[cfg_attr(test, allow(dead_code))]
mod linux {
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::{Arc, OnceLock};
const PORTAL_NAMESPACE: &str = "org.freedesktop.appearance";
const PORTAL_KEY: &str = "color-scheme";
/// The last `color-scheme` seen: 0 = unknown or no preference, 1 = dark, 2 = light.
static STATE: OnceLock<Arc<AtomicU8>> = OnceLock::new();
pub fn current(ctx: &egui::Context) -> Option<egui::Theme> {
let state = STATE.get_or_init(|| {
let value = Arc::new(AtomicU8::new(0));
let (worker_value, worker_ctx) = (Arc::clone(&value), ctx.clone());
// If the thread cannot start, the value stays 0 and System keeps following egui only.
let _ = std::thread::Builder::new().name("appearance-portal".into()).spawn(move || watch(&worker_value, &worker_ctx));
value
});
decode(u32::from(state.load(Ordering::Relaxed)))
}
fn portal_proxy() -> Option<zbus::blocking::Proxy<'static>> {
let conn = zbus::blocking::connection::Builder::session().ok()?.method_timeout(std::time::Duration::from_secs(2)).build().ok()?;
zbus::blocking::Proxy::new(&conn, "org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop", "org.freedesktop.portal.Settings").ok()
}
fn watch(value: &AtomicU8, ctx: &egui::Context) {
let Some(proxy) = portal_proxy() else { return };
// Subscribe before Read so a change during startup is queued rather than lost.
let Ok(signals) = proxy.receive_signal("SettingChanged") else { return };
let initial: Option<zbus::zvariant::OwnedValue> = proxy.call("Read", &(PORTAL_NAMESPACE, PORTAL_KEY)).ok();
publish(value, ctx, initial.and_then(portal_code).and_then(decode));
// Blocks until the portal sends a signal: no polling.
for message in signals {
let Ok((namespace, key, changed)) = message.body().deserialize::<(String, String, zbus::zvariant::OwnedValue)>() else {
continue;
};
if namespace == PORTAL_NAMESPACE && key == PORTAL_KEY {
publish(value, ctx, portal_code(changed).and_then(decode));
}
}
}
fn publish(value: &AtomicU8, ctx: &egui::Context, theme: Option<egui::Theme>) {
if value.swap(code_of(theme), Ordering::Relaxed) != code_of(theme) {
ctx.request_repaint();
}
}
/// The `u32` inside a portal value. `Settings.Read` answers a variant, and some portals nest
/// another variant inside it.
fn portal_code(value: zbus::zvariant::OwnedValue) -> Option<u32> {
let mut value: zbus::zvariant::Value<'_> = value.into();
for _ in 0..4 {
match value {
zbus::zvariant::Value::Value(inner) => value = *inner,
other => return u32::try_from(other).ok(),
}
}
None
}
/// The portal's `color-scheme`: 0 = no preference, 1 = prefer dark, 2 = prefer light.
pub(super) fn decode(code: u32) -> Option<egui::Theme> {
match code {
1 => Some(egui::Theme::Dark),
2 => Some(egui::Theme::Light),
_ => None,
}
}
fn code_of(theme: Option<egui::Theme>) -> u8 {
match theme {
Some(egui::Theme::Dark) => 1,
Some(egui::Theme::Light) => 2,
None => 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_setting_is_used_only_when_egui_reports_nothing() {
assert_eq!(resolve(None, || Some(egui::Theme::Light)), Some(egui::Theme::Light));
assert_eq!(resolve(None, || None), None);
assert_eq!(resolve(Some(egui::Theme::Dark), || Some(egui::Theme::Light)), Some(egui::Theme::Dark));
let asked = std::cell::Cell::new(false);
let theme = resolve(Some(egui::Theme::Light), || {
asked.set(true);
None
});
assert_eq!(theme, Some(egui::Theme::Light));
assert!(!asked.get(), "must not ask the desktop");
}
#[cfg(target_os = "linux")]
#[test]
fn portal_color_scheme_codes() {
assert_eq!(linux::decode(1), Some(egui::Theme::Dark));
assert_eq!(linux::decode(2), Some(egui::Theme::Light));
assert_eq!(linux::decode(0), None);
assert_eq!(linux::decode(7), None);
}
}
+29 -10
View File
@@ -12,7 +12,7 @@ use soundcraft_time::Samples;
pub enum Dialog {
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
NewSession { name: String, sample_rate: u32, demo: bool },
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
Bounce { path: String, format: String, bit_depth: String, normalize: bool, dither: bool },
RenameTrack { id: TrackId, name: String },
RenameClip { id: ClipId, name: String },
RenameMarker { id: soundcraft_model::MarkerId, name: String },
@@ -113,7 +113,13 @@ impl Dialogs {
.and_then(|p| std::path::Path::new(p).parent())
.filter(|d| !d.as_os_str().is_empty())
.map_or(home.clone(), |d| d.display().to_string());
Dialog::Bounce { path: format!("{dir}/{name} Bounce.wav"), format: "wav".into(), bit_depth: "24".into(), normalize: false }
Dialog::Bounce {
path: format!("{dir}/{name} Bounce.wav"),
format: "wav".into(),
bit_depth: "24".into(),
normalize: false,
dither: false,
}
}
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
@@ -307,6 +313,7 @@ fn default_dir() -> String {
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
audiosuite_window(app, ctx);
crate::bounce::show(app, ctx);
crate::import::show(app, ctx);
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
@@ -370,8 +377,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
},
);
});
ui.horizontal(|ui| {
ui.label("Name:");
ui.add(egui::TextEdit::singleline(name).desired_width(140.0));
ui.add(egui::TextEdit::singleline(name).desired_width(240.0));
});
if buttons(ui, "Create", enter) {
action =
@@ -396,7 +405,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
));
}
}
Dialog::Bounce { path, format, bit_depth, normalize } => {
Dialog::Bounce { path, format, bit_depth, normalize, dither } => {
ui.horizontal(|ui| {
ui.label("File");
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
@@ -426,6 +435,11 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
});
});
crate::widgets::checkbox(ui, normalize, "Normalize to -0.1 dBFS");
// 32-bit float keeps every sample as it is: there is nothing to dither.
ui.add_enabled_ui(bit_depth.as_str() != "32f", |ui| {
crate::widgets::checkbox(ui, dither, "Dither (TPDF)")
.on_hover_text("Adds ±1 LSB triangular dither when writing 16- or 24-bit files. Leave it off when the master chain already dithers.");
});
ui.label(
egui::RichText::new("Bounces the edit selection, or the whole session when nothing is selected.")
.small()
@@ -435,7 +449,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let p = std::path::Path::new(path.as_str()).with_extension(if format == "aiff" { "aif" } else { format.as_str() });
crate::bounce::start(
app,
&json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize}),
&json!({"path": p.to_string_lossy(), "format": format, "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
);
keep = false;
}
@@ -666,12 +680,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
if let Some((cmd, params)) = action {
keep = false;
match run_then(app, &cmd, params) {
Ok(v) if cmd == "file.export_clips" => {
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
if cmd == "file.import_audio" {
// Decoded in the background so a long file doesn't freeze the app (#597).
crate::import::start(app, params);
} else {
match run_then(app, &cmd, params) {
Ok(v) if cmd == "file.export_clips" => {
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
}
Ok(_) => {}
Err(e) => app.ui.status = e,
}
Ok(_) => {}
Err(e) => app.ui.status = e,
}
}
if saving
+238 -31
View File
@@ -115,26 +115,81 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
crate::docking::show(app, ui, crate::MainWindow::Edit);
}
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
fn universe(app: &mut SoundApp, ui: &mut Ui) {
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
let s = app.engine.session();
/// The Universe's clip blocks for one document revision and area: each track's clips merged into
/// runs of one colour. A drum edit holds tens of thousands of clips a pixel or less wide here;
/// painting one rect per clip every frame would cost more than all the rest of the window.
#[derive(Clone)]
struct UniverseRuns {
revision: u64,
area: Rect,
runs: std::sync::Arc<Vec<(Rect, Color32)>>,
}
/// Each clip covers `[x0, max(x1, x0 + 1)]`; neighbours of one colour that touch or overlap are
/// painted as one rect, which covers exactly the same pixels in the same order.
fn universe_runs(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32;
let tracks: Vec<&Track> = s.tracks.iter().filter(|t| !t.hidden).collect();
let n = tracks.len().max(1) as f32;
let rh = (r.height() / n).max(1.0);
let mut runs = Vec::new();
for (i, t) in tracks.iter().enumerate() {
let y = r.min.y + i as f32 * rh;
let block = |x0: f32, x1: f32, color| (Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1, y + rh - 0.5)), color);
let mut run: Option<(f32, f32, Color32)> = None;
for c in t.clips() {
let x0 = r.min.x + c.start as f32 / total * r.width();
let x1 = r.min.x + c.end() as f32 / total * r.width();
ui.painter().rect_filled(
Rect::from_min_max(pos2(x0, y + 0.5), pos2(x1.max(x0 + 1.0), y + rh - 0.5)),
0.0,
rgb(c.color.unwrap_or(t.color)),
);
let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0);
let color = rgb(c.color.unwrap_or(t.color));
run = match run {
Some((a, b, col)) if col == color && x0 >= a && x0 <= b => Some((a, b.max(x1), col)),
Some((a, b, col)) => {
runs.push(block(a, b, col));
Some((x0, x1, color))
}
None => Some((x0, x1, color)),
};
}
if let Some((a, b, col)) = run {
runs.push(block(a, b, col));
}
}
runs
}
/// [`Session::content_end`], worked out once per document revision: it walks every clip.
fn content_end(app: &SoundApp, ctx: &egui::Context) -> Samples {
let key = egui::Id::new("content_end");
let revision = app.engine.revision;
if let Some((at, end)) = ctx.memory(|m| m.data.get_temp::<(u64, Samples)>(key))
&& at == revision
{
return end;
}
let end = app.engine.session().content_end();
ctx.memory_mut(|m| m.data.insert_temp(key, (revision, end)));
end
}
/// Universe: the whole session in miniature; the box is the visible area (drag or click to move).
fn universe(app: &mut SoundApp, ui: &mut Ui) {
let r = ui.max_rect().shrink2(vec2(8.0, 4.0));
let key = egui::Id::new("universe_runs");
let revision = app.engine.revision;
let runs = match ui.ctx().memory(|m| m.data.get_temp::<UniverseRuns>(key)).filter(|u| u.revision == revision && u.area == r) {
Some(u) => u.runs,
None => {
let runs = std::sync::Arc::new(universe_runs(app.engine.session(), r));
ui.ctx().memory_mut(|m| m.data.insert_temp(key, UniverseRuns { revision, area: r, runs: runs.clone() }));
runs
}
};
for &(rect, color) in runs.iter() {
ui.painter().rect_filled(rect, 0.0, color);
}
let end = content_end(app, ui.ctx());
let s = app.engine.session();
let total = (end + s.sample_rate.samples(10.0)).max(1) as f32;
let vis0 = s.edit.zoom.scroll as f32 / total;
let tlw = app.edit_layout.timeline[2] - app.edit_layout.timeline[0];
let vis1 = vis0 + (f64::from(tlw.max(100.0)) * s.edit.zoom.samples_per_px) as f32 / total;
@@ -239,6 +294,9 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
draw_rulers(app, ui, rulers_rect, tl, &rulers);
views_button(app, ui, rulers_rect);
column_titles(ui, rulers_rect, &cols);
if let Some(id) = app.edit_scroll_into_view.take() {
scroll_into_view(app, id, tl.height());
}
// Tracks.
let mut y = tl.min.y - app.edit_layout.scroll_y;
app.edit_layout.rows.clear();
@@ -289,6 +347,25 @@ fn main_area_content(app: &mut SoundApp, ui: &mut Ui) {
}
}
/// Scroll Into View: track `id` to the top, as far as the scroll bar reaches. A track the Edit
/// window doesn't show (hidden, or in a closed folder) leaves the view where it is.
fn scroll_into_view(app: &mut SoundApp, id: TrackId, view_h: f32) {
let s = app.engine.session();
let (mut top, mut total, mut found) = (0.0, 0.0, false);
for t in s.tracks.iter().filter(|t| in_edit_window(s, t)) {
found |= t.id == id;
if !found {
top += track_height(t) + 1.0;
}
total += track_height(t) + 1.0;
}
if found {
let l = &mut app.edit_layout;
l.content_h = total;
l.scroll_by(top - l.scroll_y, view_h);
}
}
fn in_edit_window(s: &Session, t: &Track) -> bool {
ops::shown(s, t)
}
@@ -373,9 +450,10 @@ fn pan(app: &mut SoundApp, px: f64) {
fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
let t = Tokens::current();
ui.painter().rect_filled(sb, 0.0, t.panel_bg);
let end = content_end(app, ui.ctx());
let s = app.engine.session();
let vis = f64::from(tl.width()) * s.edit.zoom.samples_per_px;
let total = (s.content_end().saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis);
let total = (end.saturating_add(s.sample_rate.samples(30.0)).max(1) as f64).max(s.edit.zoom.scroll as f64 + vis);
let frac = (vis / total).clamp(0.02, 1.0) as f32;
let off = (s.edit.zoom.scroll as f64 / total).clamp(0.0, 1.0) as f32;
let thumb = Rect::from_min_size(pos2(sb.min.x + sb.width() * off, sb.min.y + 3.0), vec2((sb.width() * frac).max(24.0), sb.height() - 6.0));
@@ -838,11 +916,12 @@ fn track_row(app: &mut SoundApp, ui: &mut Ui, id: TrackId, row: Rect, tl: Rect,
}
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
let auto_view = AutoParam::parse(&track.view);
let sorted = track.clips().is_sorted_by_key(|c| c.start);
for clip in track.clips() {
if !clip.range().overlaps(&view) {
continue;
}
draw_clip(app, &painter, &s, track, clip, tl, lane, auto_view.is_some());
draw_clip(app, &painter, &s, track, sorted, clip, tl, lane, auto_view.is_some());
}
draw_live_take(app, &painter, &s, track, tl, lane);
crate::video_track::draw_lane(app, &painter, &s, track, tl, lane);
@@ -885,8 +964,10 @@ fn playlist_lanes(app: &mut SoundApp, ui: &mut Ui, track: &Track, row: Rect, mai
ui.painter().rect_filled(lane, 0.0, t.playlist_lane);
ui.painter().text(pos2(head.min.x + 4.0, head.center().y), Align2::LEFT_CENTER, &pl.name, regular(10.5), t.text_dim);
let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect()));
for c in &pl.clips {
draw_clip(app, &painter, &s, track, c, tl, lane, false);
let sorted = track.clips().is_sorted_by_key(|c| c.start);
let view = Range::new(sample_at(&s, tl, tl.min.x), sample_at(&s, tl, tl.max.x));
for c in pl.clips.iter().filter(|c| c.range().overlaps(&view)) {
draw_clip(app, &painter, &s, track, sorted, c, tl, lane, false);
}
let resp = ui.interact(lane, ui.id().with(("pl_lane", track.id.0, i)), Sense::click());
if resp.clicked()
@@ -1456,6 +1537,10 @@ pub(crate) fn track_context_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId) {
if app.engine.session().track(id).is_some_and(|track| track.folder.is_some()) && ui.button("Remove from Folder").clicked() {
let _ = app.run("track.remove_from_folder", json!({"tracks": selection}));
}
// Only the track clicked: the command has one target, and the selection stays as it is.
if ui.button("Scroll Into View").clicked() {
let _ = app.run("view.scroll_into_view", json!({"track": id.0}));
}
ui.separator();
ui.menu_button("Track Height", |ui| {
for h in TrackHeight::ALL {
@@ -1511,9 +1596,13 @@ fn fade_curve_points(fade: soundcraft_model::Fade, fade_in: bool, a: f32, b: f32
let scale = if pixels_per_point.is_finite() { pixels_per_point.clamp(0.5, 4.0) } else { 1.0 };
let extent = (b - a).abs().max(body.height().abs()) * scale;
let extent = if extent.is_finite() { extent.clamp(64.0, 4096.0) } else { 64.0 };
// A narrow fade needs fewer: the curve between two samples stays inside a box `width / segments`
// wide, so it is never further than that from its chord. Zoomed out on an edited drum take,
// thousands of fades are a pixel or two wide.
let narrow = ((b - a).abs() * scale * 4.0).ceil() + 1.0;
// Physical-pixel sampling avoids visible corners, even for steep bends.
// Keep an exact midpoint and cap allocations for extreme zoom/clip sizes.
let segments = (extent.ceil() as usize).div_ceil(2) * 2;
let segments = (extent.min(narrow).max(2.0).ceil() as usize).div_ceil(2) * 2;
(0..=segments)
.map(|i| {
let k = i as f32 / segments as f32;
@@ -1582,7 +1671,8 @@ fn grid_veil(line: Color32, bg: Color32) -> Color32 {
}
}
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
/// `sorted`: whether `track.clips()` is sorted by start (worked out once per track and frame).
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, sorted: bool, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
let x0 = x_of(s, tl, clip.start);
let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);
let r = Rect::from_min_max(pos2(x0, lane.min.y + 1.0), pos2(x1, lane.max.y - 1.0));
@@ -1604,13 +1694,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
let nb = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + name_h));
painter.rect_filled(nb, CornerRadius { nw: 2, ne: 2, sw: 0, se: 0 }, if clip.muted { Color32::from_rgb(70, 70, 70) } else { bar });
let name_clip = nb.intersect(lane);
painter.with_clip_rect(name_clip).text(
pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),
Align2::LEFT_CENTER,
&clip.name,
bold(10.5),
Color32::from_rgb(12, 12, 12),
);
// Narrower than this, no glyph would show: the name starts 4 points in.
if name_clip.width() > 6.0 {
painter.with_clip_rect(name_clip).text(
pos2(nb.min.x.max(lane.min.x) + 4.0, nb.center().y),
Align2::LEFT_CENTER,
&clip.name,
bold(10.5),
Color32::from_rgb(12, 12, 12),
);
}
}
let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max);
let vis = body_r.intersect(lane);
@@ -1772,8 +1865,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
painter.text(pos2(r.max.x - 6.0, r.max.y - 7.0), Align2::RIGHT_CENTER, "FX", bold(9.0), Color32::from_rgb(250, 230, 120));
}
if s.edit.flag("view.clip.overlap_shadows") {
// Shade where a later clip covers this one (crossfades and layered edits).
for o in track.clips() {
// Shade where a later clip covers this one (crossfades and layered edits). Only clips that
// start inside this one can: on a sorted track, find them instead of checking every clip.
let clips = track.clips();
let later = if sorted {
let from = clips.partition_point(|o| o.start <= clip.start);
clips.get(from..clips.partition_point(|o| o.start < clip.end()).max(from)).unwrap_or_default()
} else {
clips
};
for o in later {
if o.id != clip.id && o.start > clip.start && o.start < clip.end() {
let x0 = x_of(s, tl, o.start);
let x1 = x_of(s, tl, clip.end().min(o.end()));
@@ -2226,8 +2327,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
"automation.set_point",
json!({"track": track.id.0, "param": automation_id(param), "at": at, "value": lo + (hi - lo) * k}),
);
} else if track.kind.is_midi() && hit.is_none() {
new_midi_clip(app, &s, track.id, at, at);
} else if track.kind.is_midi() {
match &hit {
Some(c) => {
let _ = app.run("edit.select", json!({"clips": [c.id.0]}));
}
None => new_midi_clip(app, &s, track.id, at, at),
}
}
}
_ if !selector_press => place_insertion(app, ui, &s, track.id, at, mods.shift),
@@ -2471,13 +2577,18 @@ fn automation_id(p: &AutoParam) -> String {
AutoParam::Pan(0) => "pan".into(),
AutoParam::Pan(_) => "pan2".into(),
AutoParam::Mute => "mute".into(),
AutoParam::SendLevel(i) => format!("send_{}_level", (b'a' + i) as char),
AutoParam::SendPan(i) => format!("send_{}_pan", (b'a' + i) as char),
AutoParam::SendMute(i) => format!("send_{}_mute", (b'a' + i) as char),
AutoParam::Plugin { slot, param } => format!("plugin:{}:{param}", (b'a' + slot) as char),
AutoParam::SendLevel(i) => format!("send_{}_level", slot_id(*i)),
AutoParam::SendPan(i) => format!("send_{}_pan", slot_id(*i)),
AutoParam::SendMute(i) => format!("send_{}_mute", slot_id(*i)),
AutoParam::Plugin { slot, param } => format!("plugin:{}:{param}", slot_id(*slot)),
}
}
/// A slot as `AutoParam::parse` reads it: a letter for 0..=25, the number beyond that.
fn slot_id(i: u8) -> String {
if i < 26 { char::from(b'a' + i).to_string() } else { i.to_string() }
}
/// Grid snapping while dragging; holding Cmd/Ctrl swaps Grid and Slip.
fn drag_snap(s: &Session, at: Samples, flip: bool) -> Samples {
if s.edit.edit_mode.is_grid() != flip { s.edit.grid.snap(at, s.sample_rate, &s.tempo, s.frame_rate) } else { at }
@@ -2776,11 +2887,76 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) {
}
}
#[cfg(test)]
mod universe_tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipId, SourceId, TrackKind};
/// The pixel columns each block covers, per colour, as the old one-rect-per-clip painting did.
fn covered(blocks: &[(Rect, Color32)]) -> Vec<(i32, Color32)> {
let mut px: Vec<(i32, Color32)> = Vec::new();
for (r, c) in blocks {
for x in (r.min.x * 4.0).round() as i32..(r.max.x * 4.0).round() as i32 {
px.retain(|p| p.0 != x);
px.push((x, *c));
}
}
px.sort_by_key(|p| p.0);
px
}
fn per_clip(s: &Session, r: Rect) -> Vec<(Rect, Color32)> {
let total = (s.content_end() + s.sample_rate.samples(10.0)).max(1) as f32;
let t = &s.tracks[0];
t.clips()
.iter()
.map(|c| {
let x0 = r.min.x + c.start as f32 / total * r.width();
let x1 = (r.min.x + c.end() as f32 / total * r.width()).max(x0 + 1.0);
(Rect::from_min_max(pos2(x0, r.min.y + 0.5), pos2(x1, r.max.y - 0.5)), rgb(c.color.unwrap_or(t.color)))
})
.collect()
}
#[test]
fn a_sliced_track_is_a_few_runs_covering_the_same_pixels() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let mut clips = Vec::new();
let mut at = 0;
for i in 0..5000u64 {
let mut c = Clip::audio(ClipId(i + 1), "hit", SourceId(1), 0, at, 200 + (i % 7) as i64 * 50);
// A recoloured stretch, and a gap, in the middle.
if (2000..2100).contains(&i) {
c.color = Some([200, 40, 40]);
}
at = c.end() + if i == 3000 { 2_000_000 } else { 0 };
clips.push(c);
}
s.track_mut(t).unwrap().playlist_mut().unwrap().clips = clips;
let r = Rect::from_min_size(pos2(8.0, 4.0), vec2(900.0, 56.0));
let runs = universe_runs(&s, r);
assert!(runs.len() <= 6, "{} rects for 5000 clips", runs.len());
assert_eq!(covered(&runs), covered(&per_clip(&s, r)));
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::Harness;
#[test]
fn automation_id_round_trips_for_high_slots() {
for slot in [0u8, 25, 26, 155, 156, 255] {
for p in
[AutoParam::SendLevel(slot), AutoParam::SendPan(slot), AutoParam::SendMute(slot), AutoParam::Plugin { slot, param: "trim".into() }]
{
assert_eq!(AutoParam::parse(&automation_id(&p)), Some(p));
}
}
}
#[test]
fn follow_jumps_to_offscreen_playhead_while_playing() {
// Steady view, follow mode, playhead left the screen: jump to it.
@@ -3444,6 +3620,37 @@ mod tests {
}
}
#[test]
fn narrow_fades_take_few_points_and_stay_within_a_quarter_pixel() {
for width in [0.2f32, 1.0, 3.0, 12.0] {
for height in [40.0f32, 320.0] {
let body = Rect::from_min_size(Pos2::ZERO, vec2(width, height));
for scale in [1.0, 2.0] {
for shape in soundcraft_model::FadeShape::ALL {
for curvature in [-1.0, 0.0, 1.0] {
let fade = soundcraft_model::Fade { len: 1000, shape, curvature };
let points = fade_curve_points(fade, true, 0.0, width, body, scale);
assert!(points.len() <= ((width * scale * 4.0).ceil() as usize + 4), "{width} wide: {} points", points.len());
assert_eq!((points.len() - 1) % 2, 0, "an exact midpoint");
let segments = (points.len() - 1) as f32;
for (i, pair) in points.windows(2).enumerate() {
let line = pair[1] - pair[0];
for fraction in [0.1, 0.25, 0.5, 0.75, 0.9] {
let t = (i as f32 + fraction) / segments;
let actual = pos2(width * t, height - height * fade.gain(t));
let along =
if line.length_sq() > 0.0 { ((actual - pair[0]).dot(line) / line.length_sq()).clamp(0.0, 1.0) } else { 0.0 };
let distance = actual.distance(pair[0] + line * along) * scale;
assert!(distance < 0.25, "{width}x{height}, scale={scale}, {shape:?}, curve={curvature}, error={distance}");
}
}
}
}
}
}
}
}
fn painted_fade_control(h: &Harness<SoundApp>, clip_rect: Rect) -> Pos2 {
let controls: Vec<_> = h
.output()
+235
View File
@@ -0,0 +1,235 @@
//! Import Audio reads and decodes on a worker thread, so the app keeps drawing and stays usable
//! while a long file loads (issue #597). When the audio is ready it is handed to the engine
//! (`Engine::offer_import`) and `file.import_audio` runs as before, now only placing the clip:
//! undo, the journal, the CLI, MCP and the control channel see the same command, and calls from
//! the control channel still run synchronously.
use crate::SoundApp;
use serde_json::Value;
use std::collections::VecDeque;
/// Imports waiting and the one decoding now. One file decodes at a time: each can take gigabytes.
#[derive(Default)]
pub struct Imports {
queue: VecDeque<Value>,
#[cfg(not(target_arch = "wasm32"))]
running: Option<Running>,
}
#[cfg(not(target_arch = "wasm32"))]
struct Running {
params: Value,
name: String,
/// The session rate the audio is decoded for.
rate: u32,
rx: std::sync::mpsc::Receiver<Result<soundcraft_engine::io::DecodedImport, String>>,
}
impl Imports {
/// Whether a file is decoding or waiting.
pub fn busy(&self) -> bool {
#[cfg(not(target_arch = "wasm32"))]
if self.running.is_some() {
return true;
}
!self.queue.is_empty()
}
/// The file decoding now and how many wait after it.
fn label(&self) -> Option<(String, usize)> {
#[cfg(not(target_arch = "wasm32"))]
if let Some(r) = &self.running {
return Some((r.name.clone(), self.queue.len()));
}
None
}
}
/// Imports the audio file `params["path"]` names (the `file.import_audio` params) in the
/// background. Anything else (several `paths`, no path, the browser) runs the command now.
pub fn start(app: &mut SoundApp, params: Value) {
if cfg!(target_arch = "wasm32") || params.get("path").and_then(Value::as_str).is_none() {
run_now(app, params);
return;
}
app.imports.queue.push_back(params);
}
/// Starts the next import, and places a finished one. Called every frame from `logic`.
pub fn step(app: &mut SoundApp, ctx: &egui::Context) {
#[cfg(not(target_arch = "wasm32"))]
{
finish(app);
if app.imports.running.is_none()
&& let Some(params) = app.imports.queue.pop_front()
{
spawn(app, params, ctx);
}
}
#[cfg(target_arch = "wasm32")]
{
let _ = ctx;
while let Some(params) = app.imports.queue.pop_front() {
run_now(app, params);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(app: &mut SoundApp, params: Value, ctx: &egui::Context) {
let path = params.get("path").and_then(Value::as_str).unwrap_or_default().to_string();
let name = std::path::Path::new(&path).file_name().map_or_else(|| path.clone(), |n| n.to_string_lossy().into_owned());
let rate = app.engine.session().sample_rate.hz();
let (tx, rx) = std::sync::mpsc::channel();
let ctx = ctx.clone();
let work = move || {
let r = soundcraft_engine::io::decode_import(&path, rate).map_err(|e| e.to_string());
let _ = tx.send(r);
ctx.request_repaint();
};
match std::thread::Builder::new().name("audio-import".into()).spawn(work) {
// No status line here: it may hold the previous file's failure, and the progress note names this file.
Ok(_) => app.imports.running = Some(Running { params, name, rate, rx }),
// No thread to spare: import the old way rather than not at all.
Err(_) => {
run_now(app, params);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn finish(app: &mut SoundApp) {
let Some(r) = &app.imports.running else { return };
let done = match r.rx.try_recv() {
Ok(done) => done,
Err(std::sync::mpsc::TryRecvError::Empty) => return,
Err(std::sync::mpsc::TryRecvError::Disconnected) => Err(format!("{}: the import stopped", r.name)),
};
let Some(Running { params, name, rate, .. }) = app.imports.running.take() else { return };
// The session changed rate meanwhile (another session was opened): decode again for it.
if rate != app.engine.session().sample_rate.hz() {
app.imports.queue.push_front(params);
return;
}
match done {
Ok(decoded) => {
app.engine.offer_import(decoded);
if run_now(app, params) {
app.ui.status = format!("Imported {name}");
}
app.engine.drop_offered_imports();
}
Err(e) => app.ui.status = e,
}
}
/// Runs `file.import_audio` on this thread; the status line shows a failure. True on success.
fn run_now(app: &mut SoundApp, params: Value) -> bool {
match app.run("file.import_audio", params) {
Ok(_) => true,
Err(e) => {
app.ui.status = e;
false
}
}
}
/// A small, non-blocking note while files import: the rest of the app stays usable.
pub fn show(app: &SoundApp, ctx: &egui::Context) {
let Some((name, waiting)) = app.imports.label() else { return };
egui::Area::new(egui::Id::new("audio_import_progress")).anchor(egui::Align2::RIGHT_BOTTOM, egui::vec2(-16.0, -40.0)).interactable(false).show(
ctx,
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new());
let more = if waiting > 0 { format!(" ({waiting} more)") } else { String::new() };
ui.label(format!("Importing {name}{more}"));
});
});
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Services, SoundApp};
use serde_json::json;
fn tone_wav(name: &str) -> String {
let dir = std::env::temp_dir().join(format!("sc-ui-import-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.2).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone.clone(), tone] };
let bytes = soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap();
let path = dir.join(name);
std::fs::write(&path, bytes).unwrap();
path.to_string_lossy().into_owned()
}
fn settle(app: &mut SoundApp, ctx: &egui::Context) {
for _ in 0..2_000 {
step(app, ctx);
if !app.imports.busy() {
return;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
panic!("import never finished");
}
/// Issue #597: starting an import returns at once; the clip lands, as one undo step, later.
#[test]
fn import_runs_in_the_background_and_places_one_undoable_clip() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let wav = tone_wav("bg.wav");
start(&mut app, json!({"path": wav, "at": 480}));
assert!(app.imports.busy());
assert!(app.engine.session().tracks.is_empty(), "nothing is placed before the audio is ready");
settle(&mut app, &ctx);
let s = app.engine.session();
assert_eq!(s.tracks.len(), 1);
assert_eq!(s.tracks[0].clips()[0].start, 480);
assert_eq!(s.sources[0].frames, 9_600);
assert!(app.ui.status.starts_with("Imported bg.wav"), "{}", app.ui.status);
app.run("edit.undo", json!({})).unwrap();
assert!(app.engine.session().tracks.is_empty());
}
#[test]
fn imports_queue_and_a_bad_file_reports_without_stopping_the_rest() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let junk = tone_wav("junk.wav");
std::fs::write(&junk, b"not audio").unwrap();
let good = tone_wav("good.wav");
start(&mut app, json!({"path": junk}));
start(&mut app, json!({"path": good}));
step(&mut app, &ctx);
assert_eq!(app.imports.label().map(|l| l.1), Some(1), "the second waits for the first");
settle(&mut app, &ctx);
assert_eq!(app.engine.session().tracks.len(), 1, "the good file still imports");
}
#[test]
fn a_failed_import_stays_on_the_status_line_while_the_next_one_starts() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let junk = tone_wav("junk-status.wav");
std::fs::write(&junk, b"not audio").unwrap();
start(&mut app, json!({"path": junk}));
start(&mut app, json!({"path": tone_wav("next-status.wav")}));
for _ in 0..2_000 {
step(&mut app, &ctx);
if app.imports.label().is_some_and(|(name, _)| name == "next-status.wav") {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(app.imports.label().is_some_and(|(name, _)| name == "next-status.wav"), "the second import never started");
assert!(!app.ui.status.is_empty() && !app.ui.status.starts_with("Importing"), "the failure was overwritten: {}", app.ui.status);
settle(&mut app, &ctx);
}
}
+256 -8
View File
@@ -4,6 +4,7 @@
//! executing commands. It can be replaced without touching anything below it.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod appearance;
pub mod bounce;
pub mod control;
pub mod credits;
@@ -13,6 +14,7 @@ pub mod edit_window;
pub mod extra_windows;
pub mod fonts;
pub mod icons;
pub mod import;
pub mod menus;
pub mod midi_editor;
pub mod mix_window;
@@ -295,6 +297,10 @@ pub struct SoundApp {
pub palette: palette::PaletteState,
/// Bring the separate Mix window to the front on the next frame.
pub(crate) focus_mix: bool,
/// Scroll Into View: the track each window brings to its top (Edit) or left (Mix) when it
/// next draws, so a window not on screen catches up when it is shown.
pub(crate) edit_scroll_into_view: Option<TrackId>,
pub(crate) mix_scroll_into_view: Option<TrackId>,
pub synthetic: Vec<egui::Event>,
/// Where a dropped audio file loads into a track's sampler (last frame's rects).
pub sample_drop_targets: Vec<(egui::Rect, TrackId)>,
@@ -322,6 +328,8 @@ pub struct SoundApp {
touched: std::collections::HashSet<TrackId>,
/// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped.
last_write_at: Samples,
/// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`).
editor_states: Vec<(TrackId, usize)>,
pub frame_ms: f32,
last_frame: Option<f64>,
last_meter_poll: f64,
@@ -329,12 +337,37 @@ pub struct SoundApp {
pub video: video_track::VideoPool,
/// The Bounce Mix in progress, rendered a slice per frame.
pub bounce: Option<soundcraft_engine::io::Bounce>,
/// Audio files importing in the background (issue #597).
pub imports: import::Imports,
/// Test-only: owns the process-wide palette lock while this app exists, so frames painted
/// and colour assertions in one test can never straddle another test's palette change.
#[cfg(test)]
_palette_lock: theme::TestPaletteGuard,
}
/// The plugins whose state the session takes this frame, out of those changed in their editors
/// (`pending`, kept across frames). A plugin editor doesn't say when a gesture ends: a plugin's
/// gesture lasts while its editor reports a change every frame, and its state is read on the first
/// frame its editor reports none, or as soon as the editor is closed. A drag reads and stores the
/// state once, not every frame, and a plugin that keeps changing doesn't hold back the others.
fn editor_states_due(
pending: &mut Vec<(TrackId, usize)>,
edits: &[soundcraft_playback::EditorEdit],
changed: Vec<(TrackId, usize)>,
closed: impl Fn((TrackId, usize)) -> bool,
) -> Vec<(TrackId, usize)> {
let instruments = edits.iter().filter(|e| e.1 == soundcraft_mix::INSTRUMENT_SLOT).map(|e| (e.0, e.1));
let reported: Vec<(TrackId, usize)> = instruments.chain(changed).collect();
for k in &reported {
if !pending.contains(k) {
pending.push(*k);
}
}
let (due, rest) = std::mem::take(pending).into_iter().partition(|&k| !reported.contains(&k) || closed(k));
*pending = rest;
due
}
/// Whether another automation-write pass is due: the transport moved at least
/// `step` since `last` in either direction. `last == i64::MIN` marks "never
/// written", so the first pass after (re)start is always due. Saturating
@@ -381,6 +414,8 @@ impl SoundApp {
extra: extra_windows::ExtraState::default(),
palette: palette::PaletteState::default(),
focus_mix: false,
edit_scroll_into_view: None,
mix_scroll_into_view: None,
synthetic: Vec::new(),
sample_drop_targets: Vec::new(),
sample_reads: Vec::new(),
@@ -399,11 +434,13 @@ impl SoundApp {
arrange_request: false,
touched: std::collections::HashSet::new(),
last_write_at: i64::MIN,
editor_states: Vec::new(),
frame_ms: 0.0,
last_frame: None,
last_meter_poll: f64::NEG_INFINITY,
video: video_track::VideoPool::default(),
bounce: None,
imports: import::Imports::default(),
#[cfg(test)]
_palette_lock: theme::lock_test_palette(),
}
@@ -422,13 +459,17 @@ impl SoundApp {
if let Some(r) = menus::run_ui_command(self, id, &params) {
return r;
}
if id.starts_with("session.save") || id.starts_with("file.bounce") {
if id.starts_with("session.save") || id.starts_with("file.bounce") || id == "track.save_preset" {
// Third-party plugins keep state the session can't see: store it first.
self.capture_plugin_states();
}
let replacing = matches!(id, "session.new" | "session.open" | "session.close" | "session.revert");
match self.engine.execute(id, &params) {
Ok(v) => {
if replacing {
// Note indices and gestures belong to the old session's clips.
self.midi = midi_editor::MidiEditorState::default();
}
if replacing || (id.starts_with("session.save") && !self.engine.is_dirty()) {
self.release_autosave(false);
}
@@ -600,6 +641,13 @@ impl SoundApp {
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
// The takes are unsaved work: the folder they go into under the autosave root is
// owner-only, like the autosaves, before the recorder makes anything in it.
if self.engine.path.is_none() && self.autosave_dir.is_some() {
for dir in plan.iter().filter_map(|(_, _, path)| path.parent()) {
soundcraft_engine::autosave::create_folder(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
}
let takes = plan
.iter()
.map(|(track, inputs, path)| soundcraft_engine::io::Take { track: *track, path: path.clone(), channels: inputs.len() })
@@ -612,10 +660,21 @@ impl SoundApp {
/// (`mix.insert_state`), so saves and bounces include it.
pub fn capture_plugin_states(&mut self) {
let Some(p) = &self.player else { return };
for (t, slot, data) in p.capture_states() {
let states = p.capture_states();
self.store_plugin_states(states);
}
fn store_plugin_states(&mut self, states: soundcraft_playback::PluginStates) {
for (t, slot, data) in states {
let slot = if slot == soundcraft_mix::INSTRUMENT_SLOT { json!("instrument") } else { json!(slot) };
let state = soundcraft_model::b64::encode(&data);
if let Err(e) = self.engine.execute("mix.insert_state", &json!({"track": t.0, "slot": slot, "state": state})) {
let params = json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(&data)});
// A state read while a knob is dragged (or a pass recorded) joins that gesture, so it
// stays one undo step.
let stored = match self.engine.merge_key().map(str::to_string) {
Some(key) => self.engine.execute_merged("mix.insert_state", &params, &key),
None => self.engine.execute("mix.insert_state", &params),
};
if let Err(e) = stored {
log::warn!("storing plugin state: {e}");
}
}
@@ -643,15 +702,36 @@ impl SoundApp {
}
/// Parameter edits made in plugin editors go into the session (one undo step per gesture).
/// Hosted instruments keep their parameters in their own state, which the session takes
/// instead, as it does for plugins that said their state changed: either way the session is
/// modified, so it is autosaved and asks to be saved. The state is read once per gesture (see
/// `editor_states_due`).
fn apply_editor_edits(&mut self) {
let Some(p) = &self.player else { return };
for (t, slot, param, value) in p.idle() {
let Some(p) = &self.player else {
self.editor_states.clear();
return;
};
let edits = p.idle();
let due = editor_states_due(&mut self.editor_states, &edits, p.changed_states(), |(t, slot)| !p.editor_open(t, slot));
let states = if due.is_empty() { Vec::new() } else { p.capture_states_of(&due) };
self.store_editor_changes(edits, states);
}
fn store_editor_changes(&mut self, edits: Vec<soundcraft_playback::EditorEdit>, states: soundcraft_playback::PluginStates) {
for (t, slot, param, value) in edits {
if slot == soundcraft_mix::INSTRUMENT_SLOT {
continue;
}
let key = format!("param:{}:{slot}:{param}", t.0);
let _ = self.engine.execute_merged("mix.insert_param", &json!({"track": t.0, "slot": slot, "param": param, "value": value}), &key);
}
self.store_plugin_states(states);
}
/// The session is modified, or a plugin editor gesture still holds a state the session hasn't
/// taken (see `editor_states_due`): both read the plugins' states before autosaving.
fn has_unsaved_changes(&self) -> bool {
self.engine.is_dirty() || !self.editor_states.is_empty()
}
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
@@ -659,7 +739,7 @@ impl SoundApp {
fn autosave(&mut self) {
let Some(dir) = self.autosave_dir.clone() else { return };
let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now);
if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording {
if last.elapsed().as_secs() < 120 || !self.has_unsaved_changes() || self.engine.transport.recording {
return;
}
self.last_autosave = Some(std::time::Instant::now());
@@ -722,7 +802,7 @@ impl SoundApp {
if recording {
self.stop_play();
}
let keep = self.engine.is_dirty() && (recording || !self.quit_requested);
let keep = self.has_unsaved_changes() && (recording || !self.quit_requested);
if keep {
self.capture_plugin_states();
self.write_autosave();
@@ -1159,6 +1239,7 @@ impl SoundApp {
self.note_recent_session();
self.apply_editor_edits();
bounce::step(self, ctx);
import::step(self, ctx);
if !ctx.input(|i| i.pointer.any_down()) {
if matches!(self.gesture, Some(Gesture::Fader { .. })) {
self.gesture = None;
@@ -1330,6 +1411,7 @@ fn failed_plugin_status(s: &soundcraft_model::Session, track: TrackId, slot: usi
mod tests {
use super::{Services, SoundApp, automation_step_due};
use serde_json::json;
use soundcraft_model::TrackId;
#[test]
fn new_session_stops_playback_and_the_old_clock() {
@@ -1370,6 +1452,17 @@ mod tests {
assert_eq!(super::failed_plugin_status(&s, t, soundcraft_mix::INSTRUMENT_SLOT), None);
}
#[test]
fn replacing_the_session_drops_the_midi_note_selection() {
for command in ["session.new", "session.close"] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.midi.selected = vec![0, 1];
app.midi.clip = Some(soundcraft_model::ClipId(1));
app.run(command, json!({})).unwrap();
assert!(app.midi.selected.is_empty() && app.midi.clip.is_none(), "{command}");
}
}
#[test]
fn new_session_cancels_play_queued_before_the_next_frame() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -1417,6 +1510,41 @@ mod tests {
let _ = std::fs::remove_dir_all(dir);
}
/// A clean demo session with its autosave folder, and an editor gesture still open on Keys
/// when `gesture` is set (its state hasn't reached the session yet).
fn app_with_editor_gesture(name: &str, gesture: bool) -> (SoundApp, std::path::PathBuf) {
let dir = std::env::temp_dir().join(format!("soundcraft-{name}-{}-{gesture}", std::process::id()));
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.autosave_dir = Some(dir.clone());
if gesture {
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
app.editor_states.push((keys, soundcraft_mix::INSTRUMENT_SLOT));
}
assert!(!app.engine.is_dirty());
(app, dir)
}
#[test]
fn an_open_plugin_editor_gesture_is_autosaved() {
for gesture in [false, true] {
let (mut app, dir) = app_with_editor_gesture("gesture-autosave", gesture);
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
app.autosave();
assert_eq!(app.latest_autosave().is_some(), gesture);
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn closing_during_a_plugin_editor_gesture_keeps_the_autosave() {
for gesture in [false, true] {
let (mut app, dir) = app_with_editor_gesture("gesture-close", gesture);
app.close_autosave();
assert_eq!(app.latest_autosave().is_some(), gesture);
let _ = std::fs::remove_dir_all(dir);
}
}
#[test]
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -1526,6 +1654,126 @@ mod tests {
assert_eq!(app.engine.undo_history().len(), steps + 1);
}
#[test]
fn plugin_states_read_during_a_drag_keep_it_one_undo_step() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.run("mix.insert", json!({"track": kick.0, "plugin": "gain", "slot": 0})).unwrap();
let steps = app.engine.undo_history().len();
for frame in 0..60u8 {
// What a plugin editor reports each frame of a drag, and the state the plugin marked dirty.
let gain = -10.0 + f32::from(frame) / 10.0;
app.engine
.execute_merged("mix.insert_param", &json!({"track": kick.0, "slot": 0, "param": "gain", "value": gain}), "param:drag")
.unwrap();
app.store_plugin_states(vec![(kick, 0, vec![frame, 1])]);
}
assert_eq!(app.engine.undo_history().len(), steps + 1);
assert_eq!(app.engine.session().track(kick).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes(), Some(vec![59, 1]));
}
/// One UI frame of plugin editors: the instruments edited in theirs, the plugins that said
/// their state changed, the editors closed, and the state every plugin has now. Returns the
/// plugins whose state was read.
fn editor_frame(app: &mut SoundApp, edited: &[TrackId], changed: &[(TrackId, usize)], closed: &[TrackId], now: u8) -> Vec<(TrackId, usize)> {
let slot = soundcraft_mix::INSTRUMENT_SLOT;
let edits: Vec<_> = edited.iter().map(|&t| (t, slot, "cutoff".to_string(), f32::from(now))).collect();
let due = super::editor_states_due(&mut app.editor_states, &edits, changed.to_vec(), |(t, _)| closed.contains(&t));
let states = due.iter().map(|&(t, slot)| (t, slot, vec![now, 1])).collect();
app.store_editor_changes(edits, states);
due
}
fn instrument_state(app: &SoundApp, t: TrackId) -> Option<Vec<u8>> {
app.engine.session().track(t).unwrap().instrument.as_ref().unwrap().state_bytes()
}
#[test]
fn an_instrument_editor_drag_reads_and_stores_its_state_once() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
let mut reads = Vec::new();
let mut revisions = Vec::new();
for now in 0..60 {
reads.extend(editor_frame(&mut app, &[keys], &[], &[], now));
revisions.push(app.engine.revision);
}
reads.extend(editor_frame(&mut app, &[], &[], &[], 59));
revisions.push(app.engine.revision);
assert_eq!(reads, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]);
// The session goes to the audio thread on each new revision: once, not every frame.
revisions.dedup();
assert_eq!(revisions.len(), 2, "{revisions:?}");
assert_eq!(instrument_state(&app, keys), Some(vec![59, 1]));
}
#[test]
fn two_instrument_editor_drags_store_its_state_twice() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
let mut reads = Vec::new();
for now in 0..10 {
reads.extend(editor_frame(&mut app, &[keys], &[], &[], now));
}
reads.extend(editor_frame(&mut app, &[], &[], &[], 9));
assert_eq!(instrument_state(&app, keys), Some(vec![9, 1]));
for now in 20..30 {
reads.extend(editor_frame(&mut app, &[keys], &[], &[], now));
}
reads.extend(editor_frame(&mut app, &[], &[], &[], 29));
assert_eq!(reads.len(), 2);
assert_eq!(instrument_state(&app, keys), Some(vec![29, 1]));
}
#[test]
fn closing_an_instrument_editor_mid_gesture_still_stores_its_state() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
let lead = app.engine.session().track_by_name("Lead").unwrap().id;
let lead_before = instrument_state(&app, lead);
for now in 0..10 {
editor_frame(&mut app, &[keys, lead], &[], &[], now);
}
// Keys' editor closes on a frame it still reports a change in.
let reads = editor_frame(&mut app, &[keys, lead], &[], &[keys], 10);
assert_eq!(reads, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]);
assert_eq!(instrument_state(&app, keys), Some(vec![10, 1]));
assert_eq!(instrument_state(&app, lead), lead_before);
}
#[test]
fn a_plugin_that_keeps_changing_does_not_hold_back_another_ones_state() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let keys = app.engine.session().track_by_name("Keys").unwrap().id;
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.run("mix.insert", json!({"track": kick.0, "plugin": "gain", "slot": 0})).unwrap();
// A plugin on Kick marks its state changed on every frame while Keys is dragged, then
// keeps doing it after the drag.
for now in 0..10 {
editor_frame(&mut app, &[keys], &[(kick, 0)], &[], now);
}
let reads = editor_frame(&mut app, &[], &[(kick, 0)], &[], 10);
assert_eq!(reads, [(keys, soundcraft_mix::INSTRUMENT_SLOT)]);
assert_eq!(instrument_state(&app, keys), Some(vec![10, 1]));
assert_eq!(app.editor_states, [(kick, 0)]);
}
#[test]
fn a_preset_changed_in_an_effect_editor_is_stored_once() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.run("mix.insert", json!({"track": kick.0, "plugin": "gain", "slot": 0})).unwrap();
// No parameter edits: the plugin only says its state changed, frame after frame.
let mut reads = Vec::new();
for now in 0..5 {
reads.extend(editor_frame(&mut app, &[], &[(kick, 0)], &[], now));
}
reads.extend(editor_frame(&mut app, &[], &[], &[], 4));
assert_eq!(reads, [(kick, 0)]);
let state = app.engine.session().track(kick).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes();
assert_eq!(state, Some(vec![4, 1]));
}
#[test]
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
+25 -1
View File
@@ -69,6 +69,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, Option<&str>)] = &[
("window.io_setup", "I/O Setup", "", None),
("window.shortcuts", "Keyboard Shortcuts", "", None),
("view.mix_section", "Mix Window View", "", None),
("view.scroll_into_view", "Scroll Into View", "", None),
("ui.new_tracks_dialog", "New Tracks…", "", None),
("ui.bounce_dialog", "Bounce Mix…", "", None),
("ui.set", "Set UI State", "", None),
@@ -130,7 +131,7 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
("AudioSuite > Pitch Shift > Vari-Fi", "varispeed"),
("AudioSuite > Reverb > D-Verb", "room_reverb"),
("AudioSuite > Delay > Mod Delay III", "mod_delay"),
("AudioSuite > Modulation > Sci-Fi", "flanger"),
("AudioSuite > Modulation > Sci-Fi", "ring_mod"),
("AudioSuite > Harmonic > Eleven Lite", "saturator"),
("AudioSuite > Harmonic > Lo-Fi", "lofi"),
("AudioSuite > Harmonic > Recti-Fi", "rectifier"),
@@ -647,6 +648,20 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
}
json!({"mix_views": app.ui.mix_views})
}
"view.scroll_into_view" => {
// `track` (id or name), else the first selected track: the palette passes no params.
let track = match soundcraft_engine::cmd::track_param(&app.engine, id, p, "track") {
Ok(Some(t)) => t,
Ok(None) => match app.engine.session().edit.selected_tracks.first() {
Some(t) => *t,
None => return Some(Err("view.scroll_into_view: pass `track` or select a track".into())),
},
Err(e) => return Some(Err(e.to_string())),
};
app.edit_scroll_into_view = Some(track);
app.mix_scroll_into_view = Some(track);
json!({"track": track.0})
}
"ui.new_tracks_dialog" => {
return Some(Err("ui.new_tracks_dialog opens a dialog from the menu only; call track.new with parameters".into()));
}
@@ -757,4 +772,13 @@ mod tests {
e.execute("edit.select", &json!({"tracks": [audio, inst]})).unwrap();
assert_eq!(instrument_track(e.session()).map(|t| t.0), Some(inst));
}
#[test]
fn audiosuite_sci_fi_opens_the_ring_modulator() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
invoke_menu(&mut app, "audiosuite.process", "AudioSuite > Modulation > Sci-Fi");
assert_eq!(app.ui.audiosuite.as_deref(), Some("ring_mod"));
let info = soundcraft_dsp::plugin_info("ring_mod").unwrap();
assert!(info.audiosuite && info.category == soundcraft_dsp::Category::Modulation);
}
}
+34 -2
View File
@@ -21,6 +21,19 @@ pub struct MidiEditorState {
pub band: Option<egui::Pos2>,
}
impl MidiEditorState {
/// Start editing `id`. Note indices only mean something inside one clip, so the selection and
/// any gesture in progress are dropped when the clip changes.
fn switch_clip(&mut self, id: ClipId) {
if self.clip != Some(id) {
self.selected.clear();
self.drag = None;
self.band = None;
}
self.clip = Some(id);
}
}
#[derive(Debug, Clone)]
pub struct NoteDrag {
pub index: usize,
@@ -148,7 +161,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72);
let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48);
app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H;
app.midi.clip = Some(cid);
app.midi.switch_clip(cid);
}
if ui.rect_contains_pointer(roll) {
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
@@ -346,7 +359,10 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
0
};
if (semi != 0 || ticks != 0) && !app.midi.selected.is_empty() {
let _ = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
let moved = app.run("midi.notes_move", json!({"clip": cid.0, "indices": app.midi.selected, "ticks": ticks, "semitones": semi}));
// The engine re-sorts the notes: follow the moved ones to their new indices, so the
// selection never points at other notes and the arrows can keep nudging it.
app.midi.selected = moved.ok().and_then(|v| serde_json::from_value(v["indices"].clone()).ok()).unwrap_or_default();
}
}
if !app.midi.selected.is_empty()
@@ -376,3 +392,19 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.engine.execute_merged("midi.note_edit", &json!({"clip": cid.0, "index": i, "velocity": v}), &format!("vel:{}", cid.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn switching_clip_drops_selection_and_gestures() {
let mut st = MidiEditorState { selected: vec![0, 2], clip: Some(ClipId(1)), band: Some(pos2(1.0, 2.0)), ..Default::default() };
st.switch_clip(ClipId(1));
assert_eq!(st.selected, vec![0, 2]);
st.switch_clip(ClipId(2));
assert!(st.selected.is_empty());
assert!(st.drag.is_none() && st.band.is_none());
assert_eq!(st.clip, Some(ClipId(2)));
}
}
+11 -2
View File
@@ -47,11 +47,14 @@ pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
// Per-channel peaks for multichannel meters (read once per frame).
let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
// Taken for this frame only: a strip that isn't drawn (hidden track) must not scroll the Mix
// whenever it comes back. A request made during this frame (the strip's menu) waits for the next.
let scroll_to = app.mix_scroll_into_view.take();
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
for id in ids {
strip(app, ui, id, snap.as_ref());
strip(app, ui, id, snap.as_ref(), scroll_to == Some(id));
}
let rest = Rect::from_min_max(pos2(ui.cursor().min.x, ui.clip_rect().min.y), ui.clip_rect().max);
if rest.width() > 4.0 && ui.interact(rest, ui.id().with("past_strips"), Sense::click()).double_clicked() {
@@ -149,7 +152,7 @@ pub(crate) fn route_popup(app: &mut SoundApp, resp: &egui::Response, id: TrackId
egui::Popup::menu(resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, input));
}
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>) {
fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapshot>, scroll_here: bool) {
let t = Tokens::current();
let session = app.engine.session_arc();
let Some(track) = session.track(id) else { return };
@@ -157,6 +160,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let w = strip_width(narrow);
let h = ui.available_height().max(560.0);
let (r, _) = ui.allocate_exact_size(vec2(w, h), Sense::hover());
if scroll_here {
// egui stops `item_spacing` short of the edge (the scroll area's, not the strips' 1 pt),
// which would leave a sliver of the previous strip showing.
let margin = ui.ctx().global_style().spacing.item_spacing.x;
ui.scroll_to_rect(r.translate(vec2(margin, 0.0)), Some(egui::Align::Min));
}
let selected = app.engine.session().edit.selected_tracks.contains(&id);
let bg = if selected { t.strip_selected } else { t.strip_bg };
vgradient(ui.painter(), r, shade(bg, 0.06), bg);
+113 -7
View File
@@ -30,6 +30,10 @@ pub struct OpsState {
pub rtp_transpose: i64,
pub rtp_duration: f64,
pub rtp_delay: i64,
/// The MIDI tracks the Real-Time Properties form was last filled from, and the values it read:
/// the form re-reads the tracks when either changes (#444).
pub rtp_targets: Vec<soundcraft_model::TrackId>,
pub rtp_loaded: Option<RtpValues>,
pub identify_bars: u32,
}
@@ -57,6 +61,8 @@ impl Default for OpsState {
rtp_transpose: 0,
rtp_duration: 100.0,
rtp_delay: 0,
rtp_targets: Vec::new(),
rtp_loaded: None,
identify_bars: 1,
}
}
@@ -261,15 +267,16 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
if !open {
return;
}
let targets = app.engine.target_tracks();
sync_rtp_form(&mut app.ops, app.engine.session(), targets, ctx.dragged_id().is_some());
egui::Window::new("MIDI Real-Time Properties").open(&mut open).default_size(vec2(340.0, 200.0)).show(ctx, |ui| {
ui.label("Non-destructive, applied at playback to the selected MIDI tracks.");
let mut changed = false;
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed();
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed();
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed();
changed |= ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed();
if changed {
let p = json!({"velocity": app.ops.rtp_velocity, "transpose": app.ops.rtp_transpose, "duration": app.ops.rtp_duration, "delay": app.ops.rtp_delay});
let mut changed = RtpChanged::default();
changed.velocity = ui.add(egui::Slider::new(&mut app.ops.rtp_velocity, -127..=127).text("Velocity +")).changed();
changed.transpose = ui.add(egui::Slider::new(&mut app.ops.rtp_transpose, -48..=48).text("Transpose")).changed();
changed.duration = ui.add(egui::Slider::new(&mut app.ops.rtp_duration, 1.0..=400.0).text("Duration %")).changed();
changed.delay = ui.add(egui::Slider::new(&mut app.ops.rtp_delay, -960..=960).text("Delay (ticks)")).changed();
if let Some(p) = rtp_params(changed, &app.ops) {
let _ = app.engine.execute_merged("event.midi_rtp", &p, "rtp");
}
ui.horizontal(|ui| {
@@ -288,6 +295,66 @@ fn rtp(app: &mut SoundApp, ctx: &egui::Context) {
app.ui.show_rtp = open;
}
/// Which of the form's controls the user changed this frame.
#[derive(Default, Clone, Copy)]
struct RtpChanged {
velocity: bool,
transpose: bool,
duration: bool,
delay: bool,
}
/// A form change submits only the properties the user touched: the command stores each key it is
/// sent, so sending untouched ones would overwrite the target's values with the form's (#444).
fn rtp_params(changed: RtpChanged, ops: &OpsState) -> Option<Value> {
let mut p = serde_json::Map::new();
if changed.velocity {
p.insert("velocity".into(), json!(ops.rtp_velocity));
}
if changed.transpose {
p.insert("transpose".into(), json!(ops.rtp_transpose));
}
if changed.duration {
p.insert("duration".into(), json!(ops.rtp_duration));
}
if changed.delay {
p.insert("delay".into(), json!(ops.rtp_delay));
}
(!p.is_empty()).then_some(Value::Object(p))
}
/// Velocity, transpose, duration % and delay, as the form shows them.
pub type RtpValues = (i64, i64, f64, i64);
/// Fills the form from the tracks it edits whenever the targets or their stored values change
/// (selection, undo, a command), so it never shows (and a control change never writes back) stale
/// values (#444). A slider being dragged keeps its value until it is released.
fn sync_rtp_form(ops: &mut OpsState, s: &soundcraft_model::Session, targets: Vec<soundcraft_model::TrackId>, dragging: bool) {
let values = rtp_form_values(s, &targets);
if ops.rtp_targets != targets || (ops.rtp_loaded != Some(values) && !dragging) {
let (velocity, transpose, duration, delay) = values;
ops.rtp_targets = targets;
ops.rtp_loaded = Some(values);
ops.rtp_velocity = velocity;
ops.rtp_transpose = transpose;
ops.rtp_duration = duration;
ops.rtp_delay = delay;
}
}
/// The values the form shows for `targets`: the first target's own Real-Time Properties, or the
/// neutral defaults when nothing is selected.
fn rtp_form_values(s: &soundcraft_model::Session, targets: &[soundcraft_model::TrackId]) -> RtpValues {
let Some(&t) = targets.first() else { return (0, 0, 100.0, 0) };
let value = |key: &str, d: f64| s.edit.value(&format!("rtp.{}.{key}", t.0), d);
(
value("velocity", 0.0).clamp(-127.0, 127.0) as i64,
value("transpose", 0.0).clamp(-127.0, 127.0) as i64,
value("duration", 100.0).clamp(1.0, 1000.0),
value("delay", 0.0).clamp(-1e7, 1e7) as i64,
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -308,4 +375,43 @@ mod tests {
assert_eq!(id, "event.tempo_linear");
assert_eq!(p["end_bpm"], 140.0);
}
/// One control moved: only its property is submitted, so the command cannot overwrite the
/// target's other Real-Time Properties with the form's values (#444).
#[test]
fn rtp_submits_only_the_property_that_changed() {
let ops = OpsState { rtp_velocity: 12, rtp_transpose: 7, rtp_duration: 50.0, rtp_delay: 480, ..OpsState::default() };
let only_velocity = RtpChanged { velocity: true, ..RtpChanged::default() };
assert_eq!(rtp_params(only_velocity, &ops), Some(json!({"velocity": 12})));
let only_duration = RtpChanged { duration: true, ..RtpChanged::default() };
assert_eq!(rtp_params(only_duration, &ops), Some(json!({"duration": 50.0})));
assert_eq!(rtp_params(RtpChanged::default(), &ops), None, "no control moved, no command");
}
/// The form reads the target's own values when the window opens or the selection changes.
#[test]
fn the_rtp_form_shows_the_target_tracks_values() {
let mut e = soundcraft_engine::Engine::default();
e.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap();
let t = e.session().tracks.last().unwrap().id;
e.execute("event.midi_rtp", &json!({"track": t.0, "velocity": -12, "transpose": 7, "duration": 50.0, "delay": 480})).unwrap();
assert_eq!(rtp_form_values(e.session(), &[]), (0, 0, 100.0, 0), "no target: the neutral defaults");
assert_eq!(rtp_form_values(e.session(), &[t]), (-12, 7, 50.0, 480));
}
/// Undo keeps the same targets but changes their stored values: the form follows.
#[test]
fn the_rtp_form_follows_an_undo_on_the_same_targets() {
let mut e = soundcraft_engine::Engine::default();
e.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap();
let t = e.session().tracks.last().unwrap().id;
let mut ops = OpsState::default();
sync_rtp_form(&mut ops, e.session(), vec![t], false);
e.execute("event.midi_rtp", &json!({"track": t.0, "velocity": -12})).unwrap();
sync_rtp_form(&mut ops, e.session(), vec![t], false);
assert_eq!(ops.rtp_velocity, -12);
e.execute("edit.undo", &json!({})).unwrap();
sync_rtp_form(&mut ops, e.session(), vec![t], false);
assert_eq!(ops.rtp_velocity, 0, "the form kept the undone value");
}
}
+97 -19
View File
@@ -77,7 +77,13 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
let _ = app.run("track.hide", json!({"tracks": [id], "hidden": shown}));
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
ui.painter().with_clip_rect(r).text(
pos2(r.min.x + 30.0, r.center().y),
Align2::LEFT_CENTER,
&name,
regular(11.5),
if shown { t.text } else { t.text_dim },
);
if resp.clicked() {
crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true);
}
@@ -118,7 +124,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
bold(10.0),
if active { t.text_dark } else { t.text_dim },
);
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text);
ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), t.text);
if resp.clicked() {
let _ = app.run("track.group_toggle", json!({"group": id}));
}
@@ -184,32 +190,75 @@ fn gutter_scroll<R>(ui: &Ui, gutter: Rect, out: &egui::scroll_area::ScrollAreaOu
}
}
/// One row of the clip list: a whole file, or a clip on a track (positions in the session).
#[derive(Clone, Copy)]
enum ClipRow {
Source(usize),
Clip { track: usize, clip: usize },
}
/// The clip list's rows for one document revision and filter. A drum edit holds tens of thousands
/// of clips: the rows are listed (and filtered) when either changes, not every frame.
#[derive(Clone)]
struct ClipRows {
revision: u64,
filter: String,
rows: std::sync::Arc<Vec<ClipRow>>,
}
fn clip_rows(s: &soundcraft_model::Session, needle: &str) -> Vec<ClipRow> {
let shown = |name: &str| needle.is_empty() || name.to_lowercase().contains(needle);
// Whole files first (bold), then clips on tracks.
let sources =
s.sources.iter().enumerate().filter(|(_, src)| shown(&format!("{} ({}ch)", src.name, src.channels))).map(|(i, _)| ClipRow::Source(i));
let clips =
s.tracks.iter().enumerate().flat_map(|(track, tr)| {
tr.clips().iter().enumerate().filter(|(_, c)| shown(&c.name)).map(move |(clip, _)| ClipRow::Clip { track, clip })
});
sources.chain(clips).collect()
}
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
panel_header(ui, "CLIPS");
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
let mut v = Vec::new();
// Whole files first (bold), then clips on tracks.
for src in &s.sources {
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
}
for tr in &s.tracks {
for c in tr.clips() {
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
}
}
v
};
let fkey = egui::Id::new("clip_filter");
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
let needle = filter.to_lowercase();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
let rkey = egui::Id::new("clip_rows");
let revision = app.engine.revision;
let cached = ui.ctx().memory(|m| m.data.get_temp::<ClipRows>(rkey)).filter(|c| c.revision == revision && c.filter == needle);
let rows = match cached {
Some(c) => c.rows,
None => {
let rows = std::sync::Arc::new(clip_rows(app.engine.session(), &needle));
ui.ctx().memory_mut(|m| m.data.insert_temp(rkey, ClipRows { revision, filter: needle, rows: rows.clone() }));
rows
}
};
let row_h = 17.0;
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show_rows(ui, row_h, rows.len(), |ui, shown| {
// Only the rows in view: what they show is read now, before any of them acts on the session.
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
rows.get(shown)
.unwrap_or_default()
.iter()
.filter_map(|row| match *row {
ClipRow::Source(i) => s.sources.get(i).map(|src| {
(src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id))
}),
ClipRow::Clip { track, clip } => {
let tr = s.tracks.get(track)?;
let c = tr.clips().get(clip)?;
Some((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)))
}
})
.collect()
};
for (id, name, whole, color, selected) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), if whole { Sense::click_and_drag() } else { Sense::click() });
resp.context_menu(|ui| {
if whole {
if ui.button("Place on New Track").clicked() {
@@ -712,3 +761,32 @@ fn preset_menu(
});
ui.ctx().memory_mut(|m| m.data.insert_temp(key, name));
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, Session, TrackKind};
#[test]
fn clip_rows_list_every_clip_in_track_order_and_filter_by_name() {
let mut s = Session::default();
for names in [["Kick 1", "Snare 1"], ["Kick 2", "Tom"]] {
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
for (i, n) in names.into_iter().enumerate() {
let c = Clip::audio(s.new_clip_id(), n, SourceId(1), 0, i as i64 * 100, 100);
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(c);
}
}
let names = |rows: Vec<ClipRow>| -> Vec<String> {
rows.into_iter()
.filter_map(|r| match r {
ClipRow::Clip { track, clip } => Some(s.tracks[track].clips()[clip].name.clone()),
ClipRow::Source(_) => None,
})
.collect()
};
assert_eq!(names(clip_rows(&s, "")), ["Kick 1", "Snare 1", "Kick 2", "Tom"]);
assert_eq!(names(clip_rows(&s, "kick")), ["Kick 1", "Kick 2"]);
assert!(clip_rows(&s, "cowbell").is_empty());
}
}
+3 -2
View File
@@ -393,12 +393,13 @@ pub fn is_light() -> bool {
LIGHT_THEME.load(Ordering::Relaxed)
}
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
/// Resolve each frame from the integration's current OS appearance, or on Linux the desktop's own
/// setting when egui reports none. Unknown uses Dark.
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
match mode {
ThemeMode::Light => true,
ThemeMode::Dark => false,
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
ThemeMode::System => crate::appearance::resolve(ctx.system_theme(), || crate::appearance::desktop_theme(ctx)) == Some(egui::Theme::Light),
}
}
+2 -1
View File
@@ -353,7 +353,8 @@ fn transport(app: &mut SoundApp, ui: &mut Ui) {
let (r, resp) = ui.allocate_exact_size(vec2(38.0, 28.0), Sense::click());
key(ui.painter(), r, &resp, if recording { Color32::from_rgb(110, 30, 30) } else { key_fill(&resp) });
icons::draw(ui.painter(), r.shrink(5.0), "record", t.rec);
let resp = resp.on_hover_text("Record (Cmd+Space)");
// macOS gives Cmd+Space to Spotlight unless the user frees it, so name F12 first.
let resp = resp.on_hover_text("Record (F12 or Cmd+Space)");
if let Some(why) = app.record_hint() {
// Below the whole group, so the hint covers none of its buttons.
let below = Rect::from_min_max(r.min, pos2(r.max.x, g.max.y));
+1 -1
View File
@@ -363,7 +363,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
let _ = app.run("file.import_midi", json!({"path": path}));
} else {
let at = app.engine.session().edit.selection.start;
let _ = app.run("file.import_audio", json!({"path": path, "at": at}));
crate::import::start(app, json!({"path": path, "at": at}));
}
}
}
+54
View File
@@ -114,3 +114,57 @@ fn cancel_or_escape_stops_a_bounce_and_leaves_no_file() {
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0);
let _ = std::fs::remove_dir_all(&dir);
}
/// The largest gap between `int` and `float` in 16-bit LSBs.
fn max_error_lsb(int: &Path, float: &Path) -> f64 {
let (_, a) = soundcraft_audio_io::decode(&std::fs::read(int).unwrap(), None).unwrap();
let (_, b) = soundcraft_audio_io::decode(&std::fs::read(float).unwrap(), None).unwrap();
let mut worst = 0.0f64;
for (i, f) in a.channels.iter().zip(&b.channels) {
for (x, y) in i.iter().zip(f) {
worst = worst.max((f64::from(*x) - f64::from(y.clamp(-1.0, 1.0))).abs() * 32768.0);
}
}
worst
}
/// Bounces from the dialog at `bit_depth`, ticking Dither when `dither`, and waits for the file.
fn bounce_with(h: &mut Harness<'_, SoundApp>, file: &Path, bit_depth: &str, dither: bool) {
let app = h.state_mut();
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
if let Some(Dialog::Bounce { path, bit_depth: depth, .. }) = &mut app.dialogs.open {
*path = file.to_string_lossy().into_owned();
*depth = bit_depth.into();
}
h.run_steps(2);
if dither {
h.get_by_label("Dither (TPDF)").click();
h.run_steps(2);
}
h.get_by_label("Bounce").click();
h.run_steps(3);
h.run();
assert!(h.state().bounce.is_none());
assert!(file.exists(), "{}", h.state().ui.status);
}
#[test]
fn the_bounce_dialog_dithers_only_when_dither_is_ticked() {
let dir = scratch("bounce-dither");
let mut h = harness();
let app = h.state_mut();
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
match &app.dialogs.open {
Some(Dialog::Bounce { dither, .. }) => assert!(!dither, "Dither starts off"),
other => panic!("{other:?}"),
}
app.dialogs.open = None;
bounce_with(&mut h, &dir.join("Float.wav"), "32f", false);
bounce_with(&mut h, &dir.join("Plain.wav"), "16", false);
bounce_with(&mut h, &dir.join("Dithered.wav"), "16", true);
let plain = max_error_lsb(&dir.join("Plain.wav"), &dir.join("Float.wav"));
let dithered = max_error_lsb(&dir.join("Dithered.wav"), &dir.join("Float.wav"));
assert!(plain <= 0.5 + 1e-3, "{plain}");
assert!(dithered > 0.6, "{dithered}");
let _ = std::fs::remove_dir_all(&dir);
}
+4
View File
@@ -35,18 +35,22 @@ mod plugin_window;
mod pro_tools_keys;
mod record_hint;
mod rename_dialog;
mod rtp_window;
mod sampler_window;
mod save_as_replace;
mod save_changes;
mod scroll_into_view;
mod send_window;
mod shared_controls;
mod shortcuts;
mod side_lists_access;
mod side_lists_text;
mod toolbar_scroll;
mod toolbar_tempo;
mod track_height;
mod track_list;
mod track_name_menu;
mod track_preset_state;
mod trackpad;
mod tracks_scroll_bar;
mod waveform;
+15
View File
@@ -135,3 +135,18 @@ fn a_click_in_the_empty_midi_editor_adds_a_clip_at_the_insertion_point() {
assert_eq!(r.clips(), vec![(r.bar(3), r.bar(4))]);
assert_eq!(midi_editor::target_clip(&r.app), Some(r.app.engine.session().tracks[0].clips()[0].id));
}
#[test]
fn a_pencil_click_on_a_clip_selects_it_for_the_midi_editor() {
let mut r = Rig::new("instrument");
r.app.ui.show_midi_editor = true;
r.drag((r.bar(2) + r.bar(3)) / 2, (r.bar(3) + r.bar(4)) / 2);
r.drag((r.bar(6) + r.bar(7)) / 2, (r.bar(7) + r.bar(8)) / 2);
let ids: Vec<_> = r.app.engine.session().tracks[0].clips().iter().map(|c| c.id).collect();
assert_eq!(r.clips(), vec![(r.bar(2), r.bar(4)), (r.bar(6), r.bar(8))]);
assert_eq!(midi_editor::target_clip(&r.app), Some(ids[1]));
r.click((r.bar(2) + r.bar(3)) / 2);
assert_eq!(r.clips().len(), 2);
assert_eq!(r.app.engine.session().edit.selected_clips, vec![ids[0]]);
assert_eq!(midi_editor::target_clip(&r.app), Some(ids[0]));
}
+13 -1
View File
@@ -1,9 +1,10 @@
//! New Tracks opens from the Tracks list's + button, and from a double-click below the last
//! track (Edit) or right of the last strip (Mix).
use egui::accesskit::Role;
use egui::{Pos2, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use egui_kittest::kittest::{Queryable, by};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
fn harness(tracks: usize) -> Harness<'static, SoundApp> {
@@ -65,3 +66,14 @@ fn double_click_right_of_the_last_strip_opens_new_tracks() {
click(&mut h, at);
assert_eq!(h.state().dialogs.open_name(), Some("new_tracks"));
}
#[test]
fn the_new_tracks_name_field_is_wide_enough_to_read_a_name() {
let mut h = harness(0);
h.get_by_label_contains("New Tracks").click();
h.run_steps(4);
assert_eq!(h.state().dialogs.open_name(), Some("new_tracks"));
// The field's text run carries the same value as the field itself.
let width = h.get(by().role(Role::TextInput).value("Audio")).rect().width();
assert!(width >= 200.0, "the Name field is only {width}px wide");
}
+38
View File
@@ -233,6 +233,44 @@ fn the_piano_roll_leaves_modified_arrows_to_the_shortcuts() {
assert_eq!(pitch(&app), before + 1);
}
#[test]
fn the_piano_roll_selection_follows_notes_the_arrows_move_past_others() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.ui.show_midi_editor = true;
app.run("edit.select", json!({"tracks": ["Keys"]})).unwrap();
let ctx = egui::Context::default();
let frame = |app: &mut SoundApp, events: Vec<Event>| {
let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
})
.textures_delta
.clear();
};
for _ in 0..6 {
frame(&mut app, vec![]);
}
let body = ctx.data(|d| d.get_temp::<Rect>(egui::Id::new("soundcraft-midi-body"))).unwrap();
frame(&mut app, vec![Event::PointerMoved(body.center())]);
let notes = |app: &SoundApp| {
let clip = soundcraft_ui_egui::midi_editor::target_clip(app).unwrap();
match &app.engine.session().find_clip(clip).unwrap().1.content {
ClipContent::Midi { sequence } => sequence.notes.clone(),
_ => panic!("not a MIDI clip"),
}
};
// The first chord's lowest note, an octave up, sorts after the other chord notes.
let first = notes(&app)[0];
app.midi.selected = vec![0];
for octaves in 1..=2u8 {
frame(&mut app, keys(Key::ArrowUp, Modifiers::SHIFT));
let &[i] = app.midi.selected.as_slice() else { panic!("selection {:?}", app.midi.selected) };
assert_ne!(i, 0);
assert_eq!((notes(&app)[i].start, notes(&app)[i].pitch), (first.start, first.pitch + 12 * octaves));
}
}
#[test]
fn shift_tab_extends_the_selection_and_tab_to_transient_stops_on_hits() {
for (os, cmd) in OSES {
+54
View File
@@ -0,0 +1,54 @@
use egui::accesskit::Role;
use egui::{Event, Modifiers, PointerButton, Pos2, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
fn press(h: &mut Harness<'_, SoundApp>, pos: Pos2, pressed: bool) {
h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
/// #444: the MIDI Real-Time Properties window submitted all four properties from its form on any
/// change, so editing Velocity reset the target's Transpose, Duration and Delay to the form's
/// (stale) defaults — and the form never showed the target's own values.
#[test]
fn editing_rtp_velocity_leaves_the_other_properties_alone() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
app.engine.execute("track.new", &json!({"kind": "instrument", "name": "Synth"})).unwrap();
let t = app.engine.session().tracks.last().unwrap().id;
app.engine.execute("event.midi_rtp", &json!({"track": t.0, "transpose": 7, "duration": 50.0})).unwrap();
app.run("edit.select", json!({"tracks": [t.0], "exact": true})).unwrap();
menus::invoke_menu(&mut app, "window.rtp", "Event > MIDI Real-Time Properties");
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
assert!(h.state().ui.show_rtp, "the window did not open");
let rtp = |h: &Harness<'_, SoundApp>, key: &str, d: f64| h.state().engine.session().edit.value(&format!("rtp.{}.{key}", t.0), d);
// Drag the Velocity slider right: the velocity changes, the rest must stay as the track had it.
let rect = h.get_by_role_and_label(Role::Slider, "Velocity +").rect();
let from = rect.center();
let to = from + vec2(30.0, 0.0);
h.event(Event::PointerMoved(from));
h.run_steps(1);
press(&mut h, from, true);
h.event(Event::PointerMoved(to));
h.run_steps(2);
press(&mut h, to, false);
assert!(rtp(&h, "velocity", 0.0) > 0.0, "the velocity drag did not reach the engine");
assert_eq!(rtp(&h, "transpose", 0.0), 7.0, "editing Velocity reset Transpose");
assert_eq!(rtp(&h, "duration", 100.0), 50.0, "editing Velocity reset Duration");
assert_eq!(rtp(&h, "delay", 0.0), 0.0, "editing Velocity reset Delay");
// The form shows the target track's values, not its defaults.
assert_eq!(h.state().ops.rtp_transpose, 7, "the form did not show the track's Transpose");
assert_eq!(h.state().ops.rtp_duration, 50.0, "the form did not show the track's Duration");
}
+168
View File
@@ -0,0 +1,168 @@
//! Scroll Into View, in a track name's right-click menu: the track goes to the top of the Edit
//! window and to the left of the Mix window, both windows at once.
use egui::{Event, Modifiers, PointerButton, Pos2, Shape, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::{MainWindow, Services, SoundApp};
const SIZE: (f32, f32) = (1400.0, 900.0);
fn harness(tracks: usize) -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
for i in 1..=tracks {
app.run("track.new", json!({"name": format!("T{i:02}")})).unwrap();
}
// Uneven heights, so an offset counted as "n rows" can't pass for the real one.
app.run("track.height", json!({"tracks": ["T02"], "height": "large"})).unwrap();
app.run("track.height", json!({"tracks": ["T04"], "height": "small"})).unwrap();
let mut h = Harness::builder().with_size(vec2(SIZE.0, SIZE.1)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(4);
h
}
fn right_click(h: &mut Harness<'_, SoundApp>, at: Pos2) {
h.hover_at(at);
for pressed in [true, false] {
h.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
}
fn scroll_into_view(h: &mut Harness<'_, SoundApp>) {
h.get_by_label("Scroll Into View").click();
h.run_steps(4);
}
fn track_id(h: &Harness<'_, SoundApp>, name: &str) -> u64 {
h.state().engine.session().tracks.iter().find(|t| t.name == name).unwrap().id.0
}
fn edit_row(h: &Harness<'_, SoundApp>, name: &str) -> [f32; 4] {
let id = track_id(h, name);
h.state().edit_layout.rows.iter().find(|(t, _)| *t == id).unwrap().1
}
/// Height of the Edit window's track area, as last drawn.
fn view_h(h: &Harness<'_, SoundApp>) -> f32 {
let [_, y0, _, y1] = h.state().edit_layout.timeline;
y1 - y0
}
fn max_scroll(h: &Harness<'_, SoundApp>) -> f32 {
h.state().edit_layout.content_h - view_h(h) + 40.0
}
/// The centre of a Mix strip's name plate: its name is painted centred on it, at the bottom of
/// the strip (the galley's box, not the glyphs, so "T01" and "T08" line up alike).
fn plate(h: &Harness<'_, SoundApp>, name: &str) -> Option<Pos2> {
h.output().shapes.iter().find_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.job.text == name && text.pos.y > SIZE.1 - 40.0 => Some(text.pos + text.galley.size() / 2.0),
_ => None,
})
}
#[test]
fn the_track_goes_to_the_top_of_the_edit_window() {
let mut h = harness(20);
assert_eq!(h.state().edit_layout.scroll_y, 0.0);
let row = edit_row(&h, "T06");
right_click(&mut h, pos2(row[0] + 60.0, row[1] + 10.0));
scroll_into_view(&mut h);
let above: f32 = ["T01", "T02", "T03", "T04", "T05"].iter().map(|n| edit_row(&h, n)).map(|r| r[3] - r[1] + 1.0).sum();
assert_eq!(h.state().edit_layout.scroll_y, above);
assert_eq!(edit_row(&h, "T06")[1], h.state().edit_layout.timeline[1], "T06 is the first row in view");
}
#[test]
fn near_the_end_it_scrolls_only_as_far_as_the_scroll_bar_goes() {
let mut h = harness(20);
let max = max_scroll(&h);
h.state_mut().edit_layout.scroll_y = max - 60.0;
h.run_steps(3);
let row = edit_row(&h, "T19");
assert!(row[1] - h.state().edit_layout.timeline[1] < max, "the fixture puts T19 past the furthest scroll");
right_click(&mut h, pos2(row[0] + 60.0, row[1] + 10.0));
scroll_into_view(&mut h);
assert_eq!(h.state().edit_layout.scroll_y, max);
}
#[test]
fn from_the_mix_the_clicked_strip_goes_left_and_the_edit_window_follows() {
let mut h = harness(20);
let t03 = track_id(&h, "T03");
h.state_mut().engine.session_mut().edit.selected_tracks = vec![soundcraft_model::TrackId(t03)];
h.state_mut().ui.window = MainWindow::Mix;
h.run_steps(4);
let first = plate(&h, "T01").expect("the first strip's name").x;
let target = plate(&h, "T08").expect("T08 is on screen");
right_click(&mut h, target);
scroll_into_view(&mut h);
let now = plate(&h, "T08").expect("T08 still on screen").x;
assert!((now - first).abs() < 1.0, "T08 is the leftmost strip: at {now}, the first was at {first}");
assert_eq!(h.state().engine.session().edit.selected_tracks, vec![soundcraft_model::TrackId(t03)], "the selection is left alone");
h.state_mut().ui.window = MainWindow::Edit;
h.run_steps(4);
assert_eq!(edit_row(&h, "T08")[1], h.state().edit_layout.timeline[1], "the Edit window scrolled too");
}
#[test]
fn a_track_in_a_closed_folder_scrolls_the_mix_but_not_the_edit_window() {
let mut h = harness(20);
h.state_mut().run("track.move_to_new_folder", json!({"tracks": ["T08"], "name": "F"})).unwrap();
h.state_mut().run("track.folder_toggle", json!({"track": "F", "open": false})).unwrap();
h.state_mut().ui.window = MainWindow::Mix;
h.run_steps(4);
let target = plate(&h, "T08").expect("the Mix shows tracks of a closed folder");
right_click(&mut h, target);
scroll_into_view(&mut h);
h.state_mut().ui.window = MainWindow::Edit;
h.run_steps(4);
assert_eq!(h.state().edit_layout.scroll_y, 0.0);
}
#[test]
fn the_command_takes_a_track_by_name_from_the_control_channel() {
let mut h = harness(20);
h.state_mut().ui.window = MainWindow::Mix;
h.run_steps(4);
let first = plate(&h, "T01").expect("the first strip's name").x;
h.state_mut().run("view.scroll_into_view", json!({"track": "T08"})).unwrap();
h.run_steps(4);
let now = plate(&h, "T08").expect("T08 on screen").x;
assert!((now - first).abs() < 1.0, "T08 is the leftmost strip: at {now}, the first was at {first}");
h.state_mut().ui.window = MainWindow::Edit;
h.run_steps(4);
assert_eq!(edit_row(&h, "T08")[1], h.state().edit_layout.timeline[1], "the Edit window scrolled too");
assert!(h.state_mut().run("view.scroll_into_view", json!({"track": "T99"})).is_err());
}
#[test]
fn a_hidden_track_leaves_no_scroll_waiting_in_the_mix() {
let mut h = harness(20);
h.state_mut().ui.window = MainWindow::Mix;
h.state_mut().run("track.hide", json!({"tracks": ["T08"], "hidden": true})).unwrap();
h.run_steps(4);
let first = plate(&h, "T01").expect("the first strip's name").x;
h.state_mut().run("view.scroll_into_view", json!({"track": "T08"})).unwrap();
h.run_steps(4);
// Shown again later, T08 must not pull the Mix to itself: the request was for frames long gone.
h.state_mut().run("track.hide", json!({"tracks": ["T08"], "hidden": false})).unwrap();
h.run_steps(4);
let now = plate(&h, "T01").expect("T01 still on screen").x;
assert!((now - first).abs() < 1.0, "the Mix stayed put: T01 at {now}, was at {first}");
}
@@ -0,0 +1,57 @@
//! #990: track and group names in the side lists are clipped to their row, so a long name cannot
//! paint across the scrollbar gutter and over the panel's edge.
use egui::{Shape, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp};
fn harness() -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let first = app.engine.session().tracks.first().map(|t| t.id.0).unwrap_or_default();
app.run("track.rename", json!({"track": first, "name": LONG_TRACK})).unwrap();
app.run("track.group", json!({"name": LONG_GROUP, "tracks": [first]})).unwrap();
app.engine.session_mut().edit.selected_tracks.clear();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(3);
h
}
const LONG_TRACK: &str = "24H2026_27_JANVIER_SESSION_AUDIO_BED_A";
const LONG_GROUP: &str = "Rhythm section drums bass and percussion group";
/// The name's paint must not be clipped any further right than its row: a wider clip lets the text
/// run over the lists' scrollbar gutter and across the panel's edge. Only the paint inside the row
/// counts — the name is drawn in its own clipped column in the edit window's headers, too.
fn name_is_clipped_to_its_row(h: &Harness<'_, SoundApp>, name: &str) {
let row = h.get_by_label(name).rect();
let painted = h.output().shapes.iter().filter_map(|s| match &s.shape {
Shape::Text(t) if t.galley.text().contains(name) && s.clip_rect.intersects(row) => Some(s.clip_rect),
_ => None,
});
let mut count = 0;
for clip in painted {
count += 1;
assert!(clip.right() <= row.right() + 0.5, "the name's paint is clipped at x {}, past its row's right edge {}", clip.right(), row.right());
}
assert!(count > 0, "no painted text found for {name:?}");
}
#[test]
fn a_long_track_name_stays_inside_its_row() {
let h = harness();
name_is_clipped_to_its_row(&h, LONG_TRACK);
}
#[test]
fn a_long_group_name_stays_inside_its_row() {
let h = harness();
name_is_clipped_to_its_row(&h, LONG_GROUP);
}
@@ -0,0 +1,36 @@
//! Track presets capture the hosted plugin's current state before writing the model.
#[path = "../../../clap-host/tests/common/mod.rs"]
mod common;
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, Insert, TrackKind, b64};
use soundcraft_playback::Player;
use soundcraft_ui_egui::{Services, SoundApp};
#[test]
fn track_preset_captures_live_plugin_state_before_serializing() {
common::fixture_dir();
let mut engine = Engine::default();
let track = engine.session_mut().add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Vox"));
engine.session_mut().track_mut(track).unwrap().mixer.inserts[0] = Some(Insert::new("clap:org.soundcraft.test.gain"));
let player = Player::new(engine.session_arc());
let states = player.capture_states();
assert_eq!(states.len(), 1, "the synthetic CLAP plugin must load even without an audio device");
assert_eq!((states[0].0, states[0].1), (track, 0));
let live = states[0].2.clone();
// The model still holds the earlier capture while the live plugin has its current settings.
let stale: Vec<u8> = [0.25_f64, 0.0].into_iter().flat_map(f64::to_le_bytes).collect();
assert_ne!(live, stale);
engine.session_mut().track_mut(track).unwrap().mixer.inserts[0].as_mut().unwrap().set_state_bytes(&stale);
let mut app = SoundApp::new(engine, Some(player), Services::default());
assert_eq!(app.engine.session().track(track).unwrap().mixer.inserts[0].as_ref().unwrap().state_bytes(), Some(stale));
let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("track-preset-state-{}.json", std::process::id()));
app.run("track.save_preset", json!({"track": track.0, "path": path.to_string_lossy()})).unwrap();
let preset: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
std::fs::remove_file(path).unwrap();
assert_eq!(preset["mixer"]["inserts"][0]["state"], b64::encode(&live), "the preset must contain the current hosted state");
}
+3 -3
View File
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
let mut w = [0u64; N];
for (i, w) in w.iter_mut().enumerate() {
let o = (y + i) * stride + x - 4;
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else {
return;
};
*w = u64::from_le_bytes(b);
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
let base = (y - 4) * stride + x;
let mut s = [[0i16; N]; 8];
for (k, row) in s.iter_mut().enumerate() {
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::<N>) else {
return;
};
for i in 0..N {
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
filter_lanes(&mut s, ep, chroma);
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::<N>) else {
return;
};
for i in 0..N {
+1 -1
View File
@@ -319,7 +319,7 @@ fn plane_allocator() -> fn(usize) -> Vec<u8> {
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
if w <= N
&& x0 >= 0
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
&& let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::<N>)
&& let Some(dst) = out.first_chunk_mut::<N>()
{
*dst = *src;
+67 -12
View File
@@ -13,6 +13,10 @@ use crate::h264::slice::{SliceHeader, SliceType};
use crate::h264::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8};
use crate::h264::transform::{self, LevelScale};
fn skip_run_fits(addr: usize, run: usize, total: usize) -> bool {
addr < total && run <= total - addr
}
/// Per-slice data kept with the picture (deblocking, motion field export).
#[derive(Clone, Debug, Default)]
pub struct SliceInfo {
@@ -566,6 +570,19 @@ impl<'a> SliceDecoder<'a> {
(refs, mvs, false)
}
/// Temporal direct motion vectors (8.4.1.2.1): mvL0 = (mvCol * DistScaleFactor + 128) >> 8
/// and mvL1 = mvL0 − mvCol. Motion vectors are always within the spec's −2^15 to 2^15 − 1
/// range, which crafted streams leave (stored mvs wrap by design), so the derivation runs in
/// i32 and clamps to that range instead of overflowing.
fn temporal_direct_mvs(dsf: i32, mv_col: [i16; 2]) -> ([i16; 2], [i16; 2]) {
const MV_MIN: i32 = -(1 << 15);
const MV_MAX: i32 = (1 << 15) - 1;
// mvL1 comes from the unclamped scaled value, as in the spec; only the results are clamped.
let m0 = mv_col.map(|c| (dsf * i32::from(c) + 128) >> 8);
let clamp = |v: i32| v.clamp(MV_MIN, MV_MAX) as i16;
(m0.map(clamp), [clamp(m0[0] - i32::from(mv_col[0])), clamp(m0[1] - i32::from(mv_col[1]))])
}
/// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB.
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
let spatial = self.sh.direct_spatial_mv_pred;
@@ -600,13 +617,7 @@ impl<'a> SliceDecoder<'a> {
}
};
let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true));
let (mv0, mv1) = if ltr {
(mv_col, [0, 0])
} else {
let s = |c: i16| ((dsf * c as i32 + 128) >> 8) as i16;
let m0 = [s(mv_col[0]), s(mv_col[1])];
(m0, [m0[0] - mv_col[0], m0[1] - mv_col[1]])
};
let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) };
([ref0, 0], [mv0, mv1])
};
self.set_motion(x, y, unit, unit, refs, mvs);
@@ -907,7 +918,7 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (raster & 3) * 4;
let py = self.mb_y * 16 + (raster >> 2) * 4;
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
return;
};
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
@@ -921,7 +932,7 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (b8 & 1) * 8;
let py = self.mb_y * 16 + (b8 >> 1) * 8;
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[_]>::first_chunk_mut::<64>) else {
return;
};
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
@@ -940,7 +951,7 @@ impl<'a> SliceDecoder<'a> {
}
let px = self.mb_x * 8 + (b & 1) * 4;
let py = self.mb_y * 8 + (b >> 1) * 4;
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
continue;
};
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
@@ -1241,7 +1252,7 @@ impl<'a> SliceDecoder<'a> {
let pos = r.byte_pos();
let data = r.data();
ensure!(data.len() >= pos + 384, "truncated I_PCM macroblock");
let Some(&samples) = data.get(pos..).and_then(|d| d.first_chunk::<384>()) else {
let Some(&samples) = data.get(pos..).and_then(<[_]>::first_chunk::<384>) else {
return invalid("truncated I_PCM macroblock");
};
r.skip(384 * 8)?;
@@ -1444,7 +1455,7 @@ impl<'a> SliceDecoder<'a> {
ensure!(addr < total, "macroblock address {addr} out of range");
if !intra_slice {
let run = r.read_ue()? as usize;
ensure!(addr + run <= total, "mb_skip_run out of range");
ensure!(skip_run_fits(addr, run, total), "mb_skip_run out of range");
for _ in 0..run {
self.start_mb(addr);
self.decode_skip()?;
@@ -1467,3 +1478,47 @@ impl<'a> SliceDecoder<'a> {
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Temporal direct (8.4.1.2.1) with co-located mvs at the i16 extremes: crafted streams get
/// there because stored mvs wrap by design (mvd is truncated, the mvp add wraps), so the
/// derivation must stay within the spec's −2^15 to 2^15 − 1 motion-vector range instead of
/// overflowing (it panicked in debug/overflow-checks builds).
#[test]
fn temporal_direct_mvs_at_i16_extremes_stay_in_the_spec_range() {
// DistScaleFactor extremes from the clamp in SliceDecoder::new (±1024/1023).
for dsf in [-1024, -1, 0, 1, 1023] {
for c0 in [i16::MAX, i16::MIN, 0, 40, -4] {
for c1 in [i16::MAX, i16::MIN, 0, 40, -4] {
let (m0, m1) = SliceDecoder::temporal_direct_mvs(dsf, [c0, c1]);
for mv in m0.into_iter().chain(m1) {
assert!((-(1 << 15)..=(1 << 15) - 1).contains(&i32::from(mv)));
}
}
}
}
// mvL0 = (mvCol * DistScaleFactor + 128) >> 8, mvL1 = mvL0 − mvCol (8.4.1.2.1).
let (m0, m1) = SliceDecoder::temporal_direct_mvs(-1024, [-32768, 32767]);
assert_eq!(m0, [32767, -32768]);
assert_eq!(m1, [32767, -32768]);
// mvL1 is derived from the unclamped mvL0: (512 * 32767 + 128) >> 8 = 65534, minus 32767.
let (m0, m1) = SliceDecoder::temporal_direct_mvs(512, [32767, 0]);
assert_eq!(m0, [32767, 0]);
assert_eq!(m1, [32767, 0]);
// DistScaleFactor 256 (tb == td) reproduces the co-located mv: mvL1 = 0.
let (m0, m1) = SliceDecoder::temporal_direct_mvs(256, [40, -4]);
assert_eq!(m0, [40, -4]);
assert_eq!(m1, [0, 0]);
}
#[test]
fn skip_run_must_fit_remaining_macroblocks_without_overflow() {
assert!(skip_run_fits(1, 98, 99));
assert!(!skip_run_fits(1, 99, 99));
assert!(!skip_run_fits(1, usize::MAX, 99));
assert!(!skip_run_fits(99, 0, 99));
}
}
+45 -7
View File
@@ -91,6 +91,14 @@ fn clip_u8(v: i32) -> u8 {
v.clamp(0, 255) as u8
}
/// Clamp a dequantised coefficient to the spec's r range: the inverse-transform output is
/// unspecified when r falls outside −2^15 to 2^15 − 1 (8.5.12.1, likewise 8.5.13.1), and crafted
/// streams get there because scale4/scale8 wrap. Clamping keeps the butterflies below overflow.
#[inline(always)]
fn r_clamp(v: i32) -> i32 {
v.clamp(-(1 << 15), (1 << 15) - 1)
}
/// Inverse 4x4 transform of raster-order `d` and add to `dst` (8.5.12.2, 8.5.14).
///
/// Rows first, then columns (the spec's order: the `>> 1` steps make it matter). The column
@@ -99,10 +107,11 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
let mut t = [[0i32; 4]; 4];
for (i, row) in t.iter_mut().enumerate() {
let r = &d[i * 4..i * 4 + 4];
let e = r[0] + r[2];
let f = r[0] - r[2];
let g = (r[1] >> 1) - r[3];
let h = r[1] + (r[3] >> 1);
let (r0, r1, r2, r3) = (r_clamp(r[0]), r_clamp(r[1]), r_clamp(r[2]), r_clamp(r[3]));
let e = r0 + r2;
let f = r0 - r2;
let g = (r1 >> 1) - r3;
let h = r1 + (r3 >> 1);
*row = [e + h, f + g, f - g, e - h];
}
let mut o = [[0i32; 4]; 4];
@@ -117,7 +126,7 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
o[3][j] = e - h;
}
for (i, row) in o.iter().enumerate() {
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else {
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<4>) else {
return;
};
for (p, &v) in line.iter_mut().zip(row) {
@@ -163,7 +172,7 @@ fn idct8_1d(d: [i32; 8]) -> [i32; 8] {
pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
let mut t = [[0i32; 8]; 8];
for (row, src) in t.iter_mut().zip(d.as_chunks::<8>().0) {
*row = idct8_1d(*src);
*row = idct8_1d(std::array::from_fn(|k| r_clamp(src[k])));
}
let mut o = [[0i32; 8]; 8];
for j in 0..8 {
@@ -173,7 +182,7 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
}
}
for (i, row) in o.iter().enumerate() {
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else {
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<8>) else {
return;
};
for (p, &v) in line.iter_mut().zip(row) {
@@ -292,6 +301,35 @@ mod tests {
assert_eq!(scale8(1, 320, 30), (320 + 1) >> 1);
}
/// Crafted streams scale levels far outside the spec's r range (scale4/scale8 wrap by
/// design, so r can be any i32): the spec leaves the inverse-transform output unspecified
/// when r falls outside −2^15 to 2^15 − 1 (8.5.12.1), so the IDCT must clamp its input into
/// the defined range instead of overflowing the butterfly arithmetic (it panicked in
/// debug/overflow-checks builds).
#[test]
fn idct4_huge_coefficients_are_clamped_to_the_spec_r_range() {
let mut d = [0i32; 16];
for (i, v) in d.iter_mut().enumerate() {
*v = if i % 2 == 0 { i32::MAX } else { i32::MIN };
}
let (mut a, mut b) = ([0u8; 16], [0u8; 16]);
idct4_add(&[i32::MAX; 16], &mut a, 4);
idct4_add(&[32767; 16], &mut b, 4);
assert_eq!(a, b);
let (mut a, mut b) = ([0u8; 16], [0u8; 16]);
idct4_add(&d, &mut a, 4);
idct4_add(&std::array::from_fn(|i| if i % 2 == 0 { 32767 } else { -32768 }), &mut b, 4);
assert_eq!(a, b);
}
#[test]
fn idct8_huge_coefficients_are_clamped_to_the_spec_r_range() {
let (mut a, mut b) = ([0u8; 64], [0u8; 64]);
idct8_add(&[i32::MIN; 64], &mut a, 8);
idct8_add(&[-32768; 64], &mut b, 8);
assert_eq!(a, b);
}
/// Corrupt streams can carry huge coefficient levels: dequantisation wraps (as release
/// builds always did) instead of panicking with "attempt to multiply with overflow" in
/// debug builds (found by mutation fuzzing an H.264 transport stream).
+1 -1
View File
@@ -492,7 +492,7 @@ fn put_block(f: &[f32; 64], plane: &mut [u16], stride: usize, x0: usize, y0: usi
if xn == 8 && (y0 + 7) * step + parity < rows {
for yy in 0..8 {
let o = ((y0 + yy) * step + parity) * stride + x0;
let (Some(row), Some(src)) = (plane.get_mut(o..).and_then(|p| p.first_chunk_mut::<8>()), f.as_chunks::<8>().0.get(yy)) else {
let (Some(row), Some(src)) = (plane.get_mut(o..).and_then(<[_]>::first_chunk_mut::<8>), f.as_chunks::<8>().0.get(yy)) else {
return;
};
for x in 0..8 {
+260 -5
View File
@@ -646,13 +646,32 @@ impl IHostApplicationTrait for HostContext {
}
}
unsafe fn createInstance(&self, _cid: *mut sb::TUID, _iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult {
// IMessage / IAttributeList are not provided yet; plugins must cope with that.
/// Creates the objects plugins may ask the host for: `IMessage` (what a controller and its
/// processor send each other through `IConnectionPoint::notify`) and `IAttributeList`.
/// Plugins that talk between their halves this way fail without them (Line 6 Helix
/// reported "Failed to get content database recovery info (Out of memory condition)").
unsafe fn createInstance(&self, cid: *mut sb::TUID, iid: *mut sb::TUID, obj: *mut *mut c_void) -> sb::tresult {
// SAFETY: `obj` is the plugin's out-pointer (or null, handled).
if let Some(o) = unsafe { obj.as_mut() } {
*o = std::ptr::null_mut();
let Some(out) = (unsafe { obj.as_mut() }) else { return sb::kInvalidArgument };
*out = std::ptr::null_mut();
// SAFETY: `cid` and `iid` point to 16-byte TUIDs (or are null, handled).
let (Some(cid), Some(iid)) = (unsafe { cid.as_ref() }, unsafe { iid.as_ref() }) else { return sb::kInvalidArgument };
let (cid, iid) = (tuid_bytes(cid), tuid_bytes(iid));
let wants = |class: [u8; 16]| cid == class && (iid == class || iid == FUnknown::IID);
let made = if wants(sv::IMessage::IID) {
ComWrapper::new(HostMessage::default()).to_com_ptr::<sv::IMessage>().map(|p| p.into_raw().cast::<c_void>())
} else if wants(sv::IAttributeList::IID) {
ComWrapper::new(HostAttributes::default()).to_com_ptr::<sv::IAttributeList>().map(|p| p.into_raw().cast::<c_void>())
} else {
None
};
match made {
Some(p) => {
*out = p;
sb::kResultOk
}
None => sb::kResultFalse,
}
sb::kResultFalse
}
}
@@ -684,6 +703,191 @@ impl IComponentHandlerTrait for HostContext {
}
}
/// Longest string attribute kept, in UTF-16 units (longer ones are cut).
const ATTR_STRING_MAX: usize = 1 << 20;
/// Largest binary attribute kept.
const ATTR_BINARY_MAX: usize = 256 << 20;
/// One `IAttributeList` value. Strings and blobs live in their own allocation, so a pointer
/// handed out by `getBinary` stays valid while the list holds the value.
enum Attr {
Int(i64),
Float(f64),
Str(Box<[u16]>),
Bin(Box<[u8]>),
}
/// The host's `IAttributeList`: what a plugin puts in an `IMessage`.
#[derive(Default)]
pub(crate) struct HostAttributes {
values: Mutex<std::collections::HashMap<Vec<u8>, Attr>>,
}
impl Class for HostAttributes {
type Interfaces = (sv::IAttributeList,);
}
/// An attribute id's bytes (`None` for a null id).
fn attr_key(id: sv::IAttributeList_::AttrID) -> Option<Vec<u8>> {
// SAFETY: a non-null AttrID is a NUL-terminated C string owned by the caller.
(!id.is_null()).then(|| unsafe { std::ffi::CStr::from_ptr(id) }.to_bytes().to_vec())
}
impl HostAttributes {
fn values(&self) -> std::sync::MutexGuard<'_, std::collections::HashMap<Vec<u8>, Attr>> {
self.values.lock().unwrap_or_else(PoisonError::into_inner)
}
fn set(&self, id: sv::IAttributeList_::AttrID, v: Attr) -> sb::tresult {
let Some(k) = attr_key(id) else { return sb::kInvalidArgument };
self.values().insert(k, v);
sb::kResultOk
}
}
impl sv::IAttributeListTrait for HostAttributes {
unsafe fn setInt(&self, id: sv::IAttributeList_::AttrID, value: sb::int64) -> sb::tresult {
self.set(id, Attr::Int(value))
}
unsafe fn getInt(&self, id: sv::IAttributeList_::AttrID, value: *mut sb::int64) -> sb::tresult {
// SAFETY: `value` is the caller's out-pointer (or null, handled).
match (
attr_key(id).and_then(|k| match self.values().get(&k) {
Some(Attr::Int(v)) => Some(*v),
_ => None,
}),
unsafe { value.as_mut() },
) {
(Some(v), Some(out)) => {
*out = v;
sb::kResultOk
}
_ => sb::kResultFalse,
}
}
unsafe fn setFloat(&self, id: sv::IAttributeList_::AttrID, value: f64) -> sb::tresult {
self.set(id, Attr::Float(value))
}
unsafe fn getFloat(&self, id: sv::IAttributeList_::AttrID, value: *mut f64) -> sb::tresult {
// SAFETY: `value` is the caller's out-pointer (or null, handled).
match (
attr_key(id).and_then(|k| match self.values().get(&k) {
Some(Attr::Float(v)) => Some(*v),
_ => None,
}),
unsafe { value.as_mut() },
) {
(Some(v), Some(out)) => {
*out = v;
sb::kResultOk
}
_ => sb::kResultFalse,
}
}
unsafe fn setString(&self, id: sv::IAttributeList_::AttrID, string: *const sv::TChar) -> sb::tresult {
if string.is_null() {
return sb::kInvalidArgument;
}
let mut s = Vec::new();
// SAFETY: a non-null string is NUL-terminated UTF-16 owned by the caller; reading stops at
// the NUL or at ATTR_STRING_MAX units.
while s.len() < ATTR_STRING_MAX {
let c = unsafe { *string.add(s.len()) };
if c == 0 {
break;
}
s.push(c);
}
self.set(id, Attr::Str(s.into_boxed_slice()))
}
unsafe fn getString(&self, id: sv::IAttributeList_::AttrID, string: *mut sv::TChar, size_in_bytes: sb::uint32) -> sb::tresult {
let units = size_in_bytes as usize / 2;
if string.is_null() || units == 0 {
return sb::kInvalidArgument;
}
let Some(k) = attr_key(id) else { return sb::kInvalidArgument };
let values = self.values();
let Some(Attr::Str(s)) = values.get(&k) else { return sb::kResultFalse };
let n = s.len().min(units - 1);
// SAFETY: the caller's buffer holds `units` UTF-16 units; `n + 1 <= units` are written.
unsafe {
std::ptr::copy_nonoverlapping(s.as_ptr(), string, n);
*string.add(n) = 0;
}
sb::kResultOk
}
unsafe fn setBinary(&self, id: sv::IAttributeList_::AttrID, data: *const c_void, size_in_bytes: sb::uint32) -> sb::tresult {
let n = size_in_bytes as usize;
if n > ATTR_BINARY_MAX {
return sb::kOutOfMemory;
}
let bytes: Box<[u8]> = if n == 0 || data.is_null() {
Box::new([])
} else {
// SAFETY: the caller's `data` holds `n` readable bytes.
unsafe { std::slice::from_raw_parts(data.cast::<u8>(), n) }.into()
};
self.set(id, Attr::Bin(bytes))
}
unsafe fn getBinary(&self, id: sv::IAttributeList_::AttrID, data: *mut *const c_void, size_in_bytes: *mut sb::uint32) -> sb::tresult {
let Some(k) = attr_key(id) else { return sb::kInvalidArgument };
let values = self.values();
let Some(Attr::Bin(b)) = values.get(&k) else { return sb::kResultFalse };
// SAFETY: both are the caller's out-pointers (or null, handled). The pointer handed out
// stays valid until this attribute is replaced or the list is released (VST3 contract).
match (unsafe { data.as_mut() }, unsafe { size_in_bytes.as_mut() }) {
(Some(d), Some(sz)) => {
*d = b.as_ptr().cast::<c_void>();
*sz = u32::try_from(b.len()).unwrap_or(u32::MAX);
sb::kResultOk
}
_ => sb::kInvalidArgument,
}
}
}
/// The host's `IMessage`: an id and an attribute list, created for a plugin through
/// `IHostApplication::createInstance`.
pub(crate) struct HostMessage {
id: Mutex<std::ffi::CString>,
attributes: ComWrapper<HostAttributes>,
}
impl Default for HostMessage {
fn default() -> Self {
HostMessage { id: Mutex::new(std::ffi::CString::default()), attributes: ComWrapper::new(HostAttributes::default()) }
}
}
impl Class for HostMessage {
type Interfaces = (sv::IMessage,);
}
impl sv::IMessageTrait for HostMessage {
unsafe fn getMessageID(&self) -> sb::FIDString {
// The string's buffer lives until the next `setMessageID`, as the interface expects.
self.id.lock().unwrap_or_else(PoisonError::into_inner).as_ptr()
}
unsafe fn setMessageID(&self, id: sb::FIDString) {
// SAFETY: a non-null id is a NUL-terminated C string owned by the caller.
let s = if id.is_null() { std::ffi::CString::default() } else { unsafe { std::ffi::CStr::from_ptr(id) }.to_owned() };
*self.id.lock().unwrap_or_else(PoisonError::into_inner) = s;
}
unsafe fn getAttributes(&self) -> *mut sv::IAttributeList {
// Not add-ref'ed: the list belongs to the message (VST3 contract).
self.attributes.as_com_ref::<sv::IAttributeList>().map_or(std::ptr::null_mut(), |r| r.as_ptr())
}
}
/// One parameter's points for one block. SoundCraft sends at most one point per block; a
/// plugin writing output changes may add more, of which only the last is kept.
#[derive(Default)]
@@ -2151,6 +2355,57 @@ mod window {
#[cfg(test)]
mod tests {
use super::*;
use sv::{IAttributeListTrait as _, IMessageTrait as _};
fn tuid(iid: [u8; 16]) -> sb::TUID {
iid.map(|b| b as c_char)
}
/// Asks the host for an object the way a plugin does.
fn create<I: Interface>(host: &ComWrapper<HostContext>, cid: [u8; 16]) -> Option<ComPtr<I>> {
let app = host.to_com_ptr::<sv::IHostApplication>()?;
let (mut c, mut i) = (tuid(cid), tuid(I::IID));
let mut obj: *mut c_void = std::ptr::null_mut();
// SAFETY: valid TUIDs and out-pointer, as a plugin passes them.
let r = unsafe { app.createInstance(&mut c, &mut i, &mut obj) };
// SAFETY: on success `obj` is an owned (add-ref'ed) pointer to `I`.
(r == sb::kResultOk).then(|| unsafe { ComPtr::from_raw(obj.cast::<I>()) }).flatten()
}
#[test]
fn plugins_get_messages_and_attribute_lists_from_the_host() {
let host = ComWrapper::new(HostContext::default());
let msg = create::<sv::IMessage>(&host, sv::IMessage::IID).expect("IMessage");
// SAFETY: calls through the interface with valid C strings and buffers.
unsafe {
msg.setMessageID(c"DatabaseInfo".as_ptr());
assert_eq!(std::ffi::CStr::from_ptr(msg.getMessageID()).to_str(), Ok("DatabaseInfo"));
let attrs = vst3::ComRef::from_raw(msg.getAttributes()).expect("attributes");
assert_eq!(attrs.setInt(c"count".as_ptr(), 42), sb::kResultOk);
assert_eq!(attrs.setFloat(c"gain".as_ptr(), 0.5), sb::kResultOk);
let text: Vec<u16> = "Helix".encode_utf16().chain([0]).collect();
assert_eq!(attrs.setString(c"name".as_ptr(), text.as_ptr()), sb::kResultOk);
assert_eq!(attrs.setBinary(c"blob".as_ptr(), [1u8, 2, 3].as_ptr().cast(), 3), sb::kResultOk);
let (mut i, mut f) = (0i64, 0f64);
assert_eq!(attrs.getInt(c"count".as_ptr(), &mut i), sb::kResultOk);
assert_eq!(attrs.getFloat(c"gain".as_ptr(), &mut f), sb::kResultOk);
assert_eq!((i, f), (42, 0.5));
assert_eq!(attrs.getInt(c"gain".as_ptr(), &mut i), sb::kResultFalse, "a float is not an int");
assert_eq!(attrs.getInt(c"missing".as_ptr(), &mut i), sb::kResultFalse);
// A buffer of 4 units takes 3 characters and the NUL.
let mut buf = [0xFFFFu16; 4];
assert_eq!(attrs.getString(c"name".as_ptr(), buf.as_mut_ptr(), 8), sb::kResultOk);
assert_eq!(buf, ['H' as u16, 'e' as u16, 'l' as u16, 0]);
let (mut p, mut n): (*const c_void, u32) = (std::ptr::null(), 0);
assert_eq!(attrs.getBinary(c"blob".as_ptr(), &mut p, &mut n), sb::kResultOk);
assert_eq!(std::slice::from_raw_parts(p.cast::<u8>(), n as usize), [1, 2, 3]);
assert_eq!(attrs.setInt(std::ptr::null(), 1), sb::kInvalidArgument, "a null id is refused");
}
assert!(create::<sv::IAttributeList>(&host, sv::IAttributeList::IID).is_some());
// Anything else is still not provided.
assert!(create::<sv::IMessage>(&host, sv::IComponentHandler::IID).is_none());
}
#[test]
fn the_transport_fills_the_process_context() {
+47 -6
View File
@@ -169,11 +169,28 @@ impl Vst3Plugin {
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
/// allocates on the audio thread).
fn push_note(&mut self, e: NoteEvent) {
if self.notes.len() >= self.notes.capacity() {
return;
}
let pos = self.notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
self.notes.insert(pos, e);
push_sorted(&mut self.notes, e);
}
}
fn push_sorted(notes: &mut Vec<NoteEvent>, e: NoteEvent) {
if notes.len() >= notes.capacity() {
return;
}
let pos = notes.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
notes.insert(pos, e);
}
/// Keeps only the pending releases, one per key and moved to the start of the next block, so at
/// most 128 remain and `all_notes_off` always has room in the queue.
fn keep_releases(notes: &mut Vec<NoteEvent>) {
let mut seen = [false; 128];
notes.retain(|e| match *e {
NoteEvent::Off { key, .. } => seen.get_mut(usize::from(key)).is_some_and(|s| !std::mem::replace(s, true)),
_ => false,
});
for e in notes.iter_mut() {
e.set_time(0);
}
}
@@ -214,8 +231,11 @@ impl Plugin for Vst3Plugin {
}
fn reset(&mut self) {
self.notes.clear();
// Keep pending releases and release whatever is still held, so the processor's voices stop
// on the next block instead of sounding on while the host forgets them.
keep_releases(&mut self.notes);
self.controls.clear();
self.all_notes_off();
self.held = [false; 128];
}
@@ -517,3 +537,24 @@ impl PluginEditor for Vst3Editor {
if units.is_empty() || text.trim_end().to_lowercase().ends_with(&units.to_lowercase()) { Some(text) } else { Some(format!("{text} {units}")) }
}
}
#[cfg(test)]
mod tests {
use super::*;
/// 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 notes = Vec::with_capacity(EVENT_CAP);
for i in 0..EVENT_CAP {
push_sorted(&mut notes, NoteEvent::Off { time: i as u32, key: (i % 4) as u8 });
}
keep_releases(&mut notes);
for key in 0..128u8 {
push_sorted(&mut notes, NoteEvent::Off { time: 0, key });
}
for key in 0..128u8 {
assert!(notes.iter().any(|e| matches!(*e, NoteEvent::Off { key: k, .. } if k == key)), "key {key} was never released");
}
}
}
+23
View File
@@ -160,6 +160,29 @@ fn synth_plays_notes_at_their_offsets() {
assert!(mono[0].iter().all(|&x| (x - 1.0).abs() < 1e-6), "the note is still held");
}
#[test]
fn reset_after_all_notes_off_still_releases_held_voices() {
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Synth")).unwrap();
p.prepare(48_000.0, 64, 2);
p.note_on(0, 60, 127);
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
p.process(&mut io, 64);
assert!((io[0][63] - 1.0).abs() < 1e-6, "the note sounds");
// Mixer::reset calls these two in a row on an instrument (stop / seek).
p.all_notes_off();
p.reset();
let mut io = vec![vec![9.0f32; 64], vec![9.0f32; 64]];
p.process(&mut io, 64);
assert!(io[0].iter().all(|x| x.abs() < 1e-6), "voice still sounding after reset: {}", io[0][63]);
// A reset alone also releases what the host still holds.
p.note_on(0, 61, 127);
p.process(&mut io, 64);
assert!((io[0][63] - 1.0).abs() < 1e-6);
p.reset();
p.process(&mut io, 64);
assert!(io[0].iter().all(|x| x.abs() < 1e-6), "voice still sounding after reset: {}", io[0][63]);
}
#[test]
fn many_instances_create_and_drop_cleanly() {
let (g, s) = (id_of("Fixture Gain"), id_of("Fixture Synth"));
+17
View File
@@ -0,0 +1,17 @@
# Aux and Folder routing order
Updated: 2026-10-14
Aux and Folder strips read audio from busses populated by earlier strips. The mixer prepares their execution order when the existing session-structure fingerprint changes, then reuses that order for subsequent blocks.
For an acyclic routing graph, preparation computes the longest producer path to every Aux or Folder input. Producers reach a bus before its consumers read it, even when tracks were created in reverse routing order or the chain contains more than eight returns. Output and send routes both create dependencies. Multiple routes from the same producer to the same bus contribute audio normally but count as one producer dependency. Strips at the same depth retain session order, preserving the previous ordering for ordinary short acyclic graphs.
The earlier ordering calculation capped every depth at eight. In a sufficiently deep reverse-created chain, that made a consumer tie with its producer and execute first. Because bus buffers are cleared each block, the consumer could receive silence. Exact acyclic depths remove that tie.
If any cycle exists, the whole graph retains the previous bounded depth calculation and deterministic session-order ties. Preparation does not reject a cyclic session or change its feedback policy. This increment does not alter PDC accounting, plugin processing or failure containment, render-helper APIs, pan coefficients, Master insert order, playback ownership, or device selection.
This is the routing-order increment of the remaining runtime work discussed in #394. Further runtime changes require independent treatment of prepared routing coefficients, workspace and instance lifetime, hosted automation delivery, graph latency, and explicit render failures. This increment does not claim to complete that rewrite or move all preparation off the audio callback.
Regression tests use synthetic source samples and public mixer APIs. They cover deep reverse-created chains, output/send dependencies, duplicate routes, branching, Folder consumers, callback-size invariance, edits on an existing mixer and preserved cyclic behavior with PDC enabled or disabled. The same public fixture can run against the previous main to demonstrate the depth-cap failure independently of the new preparation helper.
Revision history: 2026-10-14 — exact acyclic routing order with the existing whole-graph cyclic fallback.
+41
View File
@@ -0,0 +1,41 @@
# Track Commit channel-format integrity
**Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13
Track Commit retains the source track's exact `ChannelFormat`, including surround, height and
ambisonic layouts. Channel count alone cannot identify a layout: 5.1 and 6.0 both have six
channels, while Quad, LCRS and first-order ambisonics all have four.
The shared pre-fader renderer already returns multichannel audio in the track's own layout.
Commit copies that format to the new audio track and checks the returned channel count and
frame count before adding the source or hiding the original track. Formats wider than the
renderer’s 16-channel limit are rejected before rendering. The fixed two-second tail uses
checked arithmetic; overflowing ranges or requests beyond the renderer’s sample limit return
an error. A command committing several tracks rolls back all of them if any selected track
fails. Current routing and mixer inheritance remain unchanged.
The regression fixtures use distinct bipolar signals on each channel across all 30 declared
formats within the 16-channel limit, with an insert applied before committing. The four higher
ambisonic orders are rejected without replacing the source track. Additional cases check
bus/send routing, undo/redo, session save/reopen and float WAV export, including its output
speaker mask.
Mono uses a two-channel frame limit because the existing renderer temporarily targets stereo
before folding back to mono. This frame limit does not bound total process memory. The shared
mono renderer’s -3 dB center-pan gain followed by `SQRT_2` compensation produces approximately
+0.0103 dB gain; the signal fixture accounts for that existing processing behavior.
Session track metadata retains the exact layout. Generic source WAV saving still infers its
channel mask from channel count. This change preserves the shared renderer, the fixed tail
and the existing commit options.
Implementation: `crates/engine/src/io.rs::commit_track` and
`crates/mix/src/lib.rs::render_track_pre_fader`. Regression fixtures:
`crates/engine/tests/commit_multichannel.rs`.
## Revision history
| Date | Change |
|---|---|
| 2026-10-13 | Refresh against current main; document source-supported format checks, existing renderer limits and regression fixtures. |
| 2026-10-12 | Preserve exact committed formats and reject incomplete or unsupported-width renders; add channel-signal, routing, persistence/export and rollback coverage. |
+9 -3
View File
@@ -83,9 +83,15 @@ script events such as `{"system_theme": "dark"}` to change it on subsequent fram
Appearance defaults to Dark. `ui.theme {"mode":"system"}` follows the appearance supplied by
the native or web integration at launch and on later frames, while the saved choice stays System.
When the integration cannot report an appearance, System uses Dark. Manual Light and Dark stay
fixed when the OS appearance changes. An offscreen render without `--system-theme` supplies no
OS appearance and therefore resolves System to Dark.
When the integration cannot report an appearance, as on Linux, System reads the desktop's
settings portal (`org.freedesktop.appearance` `color-scheme`) and follows its changes; without
a portal answer it uses Dark. Manual Light and Dark stay fixed when the OS appearance changes.
An offscreen render without `--system-theme` supplies no OS appearance and therefore resolves
System to Dark, or on Linux to the desktop's portal setting when one is available.
`view.scroll_into_view {"track":"Bass"}` (a track id or name; without it, the first selected
track) brings the track to the top of the Edit window and the left of the Mix window, the same as
Scroll Into View in the track name menu. A window not on screen catches up when it is next shown.
## Panel docking
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (WAV: empty data chunk with trailing metadata (#466); previously minor: continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (Float32 file headroom, #791) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types
its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its
@@ -33,10 +33,10 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
| Format | Pro Tools | Read | Write | Tests / notes |
|---|---|---|---|---|
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
| WAV / BWF (PCM 16/24/32, float 32), `bext` | native | ✅ | ✅ | Native reader/writer, TPDF dither option; Float32 retains finite headroom (#791); also 8-bit and float 64 read. An empty data chunk followed by metadata reads as no audio; an unfinalised recording's audio is still read (#466). |
| RF64 / BW64 (> 4 GiB) | read/write | ✅ | ✅ | `encode_wav_rf64` |
| WAV `WAVE_FORMAT_EXTENSIBLE` multichannel with channel mask | read/write | ✅ | ✅ | `encode_with_channel_mask`; default 5.1 import creates a stereo track (#89, PR #128) |
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native |
| AIFF / AIFF-C | native | ✅ | ✅ (`fl32`) | Native; `fl32` retains finite headroom (#791) |
| FLAC | import | ✅ | ✅ | Own encoder |
| MP3 | import, bounce | ✅ (symphonia) | ❌ | G-21 |
| AAC / M4A / MP4 audio | import, bounce | ✅ (symphonia) | ❌ | G-21 |
@@ -80,6 +80,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | trivial | WAV and AIFF-C Float32 encoders retain finite headroom (#791); whole-file, streaming and bounce regression tests |
| 2026-10-13 | trivial | WAV: empty data chunk with trailing metadata (#466) |
| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged |
| 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options |
+13 -3
View File
@@ -1,6 +1,6 @@
# Where SoundCraft falls short of Pro Tools
> **Last reviewed:** 2026-10-18 · **Last updated:** 2026-10-18 · **Change:** trivial (G-04: mono device playback fold-down) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-19 · **Last updated:** 2026-10-19 · **Change:** trivial (G-04: Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Every known shortfall, one entry each, ranked by how much it stops a Pro Tools user from doing
real work in SoundCraft. This is the work list: pick from the top. Rows marked **alpha blocker**
@@ -80,6 +80,8 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
### G-04. Correctness bugs in "done" features
**Fixed (2026-10-19, #791):** Float32 WAV and AIFF-C encoding retain finite samples above full scale. Whole-file and streaming tests cover both formats, integer clipping, and the existing NaN/infinity behavior. A bounce regression checks preserved headroom and file/report agreement with normalization off, then checks normalized output.
**Fixed (2026-10-18):** Mono device playback averages the stereo channels before output clipping,
**Fixed (2026-10-16):** Master fader volume automation uses the first and last samples of each
**Fixed (2026-10-16):** Aux, folder and master latency calculations now skip inserts already
@@ -94,6 +96,9 @@ regression tests verify rendered impulse timing, retained healthy latency and re
instead of discarding the right side. Surround mixes average both sides of the ITU stereo
fold-down. Regression tests cover right-only and left-only audio, equal and opposite-polarity
channels, clipping order, centered mono tracks, each 5.1 channel and 1-, 37- and 512-frame callbacks.
**Fixed (2026-10-16):** Split into Mono now gives each output track its own source channel,
including surround channels, instead of repeating channel zero. Clip edits, source timestamps,
Undo/Redo and save/reopen are covered by regression tests; missing or empty media fails atomically.
Open on 2026-10-10 (several have PRs open):
editing #3, #5, #140, #144; clip/session #86 (PR #123), #88 (PR #130), #91 (PR #124), #92 (PR #132);
@@ -178,8 +183,10 @@ varispeed, high-quality offline) plus Elastic Pitch and warp-marker editing dept
### G-16. Session management
No session templates, track presets, workspace/sound browser with previews, batch rename (new in
2026.4), disk-usage and relink depth. ≈ 25–40 h.
Session templates, track-preset recall UI and browser, workspace/sound browser with previews,
batch rename (new in 2026.4), disk-usage and relink depth remain. Track-preset saves capture the
current hosted-plugin state before writing (#661); recall UI, partial recall and preset browsing remain
tracked in #639. ≈ 25–40 h.
### G-17. Video and post
@@ -214,9 +221,12 @@ missing. Out of scope unless the owner decides otherwise.
| Date | Change | Summary |
|---|---|---|
| 2026-10-19 | trivial | G-04: Float32 encoding retains finite headroom (#791); integer clipping and non-finite handling retained |
| 2026-10-18 | trivial | G-04: mono device stereo/surround fold-down fixed and regression-tested; estimates unchanged |
| 2026-10-16 | trivial | G-04: master volume automation block boundaries fixed and regression-tested; estimates unchanged |
| 2026-10-16 | trivial | G-04: failed aux/folder/master inserts excluded from latency compensation after containment; estimates unchanged |
| 2026-10-16 | trivial | G-04: Split into Mono channel preservation fixed and regression-tested; estimates unchanged |
| 2026-10-16 | minor | G-16: capture current hosted-plugin state before saving a track preset (#661); broader preset work remains (#639) |
| 2026-10-12 | minor | G-01 shrunk: audio device, rate and buffer choice; opt-in ASIO |
| 2026-10-10 | minor | Marked the alpha blockers (G-01, G-02, G-03; parts of G-05 and G-06) from the core-workflow gate |
| 2026-10-10 | major | Created from the full re-measure against Pro Tools Ultimate 2026.4.1: 22 ranked gaps with evidence and estimates |
+16 -9
View File
@@ -21,6 +21,8 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
```sh
soundcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
soundcraft-cli mcp --automation-read-root /work/project --automation-read-root /work/shared-samples \
--automation-write-root /work/project/out --automation-write-root /tmp/scratch
```
```json
@@ -31,20 +33,24 @@ soundcraft-cli mcp --automation-read-root /work/project --automation-write-root
The two flags (VectorCraft and the other Craft apps take the same ones, so one client config
works across the suite) keep the agent to the folders you give it:
- Every file a command reads must lie inside the **read root**: `open_session`, `session.open`,
- Every file a command reads must lie inside a **read root**: `open_session`, `session.open`,
`import_audio` and `file.import_audio`, `file.import_midi`, `file.import_video`,
`file.import_clip_groups`, `file.import_session_data`, `track.load_preset` and
`mix.sampler_load`. The media a session file names is read too, so an opened session whose
audio paths lead outside the read root loads without that audio and lists it under `missing`.
- Every file a command writes must lie inside the **write root**: `save_session`, `session.save`,
audio paths lead outside the read roots loads without that audio and lists it under `missing`.
- Every file a command writes must lie inside a **write root**: `save_session`, `session.save`,
`session.save_as`, `session.save_copy`, `session.save_template`, `bounce_mix` and
`file.bounce_mix`, `file.bounce_stems`, `file.export_clips`, `file.export_midi`,
`file.export_session_text`, `file.export_selected_tracks_as_session`, `file.export_sibelius`,
`file.send_to_sibelius`, `file.print_score`, `file.export_clip_groups` and
`track.save_preset`, together with what a session save writes beside the session file
(`Audio Files/`, `Backups/`) and its temporary file. A score command given no `path` writes to
the temp folder, which is outside the write root unless you made it the root, so it is refused.
- Read and write access are separate. Give the same folder twice for both; a root left out grants
the temp folder, which is outside the write roots unless you made it one, so it is refused.
- Each flag may be given more than once (#874): a path inside any of the read roots may be read,
one inside any of the write roots written, so one session can read a project folder and a shared
samples folder, or write to an output folder and a scratch folder, without widening a root to
their common parent. A folder given twice counts once.
- Read and write access are separate. Give the same folder to both flags for both; a kind left out grants
none of its access (with only `--automation-read-root`, nothing can be written to disk). Without
either flag nothing changes.
- A path is made absolute against the server's working directory, then its links are followed: a
@@ -55,12 +61,13 @@ works across the suite) keep the agent to the folders you give it:
inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like
drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused.
- A refused path is an ordinary tool error the agent can read and correct, for example
`automation path rejected: outside the write root /work/project/out: /etc/x.wav` or
`automation path rejected: outside the write root /work/project/out: /etc/x.wav` (with several,
`outside the write roots /work/project/out, /tmp/scratch: ...`) or
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.wav`.
A refused save path is reported as refused, never as "already exists".
- The roots must be existing folders; a missing one stops the server with an error. Each flag is
given once (`--flag <dir>` or `--flag=<dir>`), and a misspelled `--automation-...` flag is an
error rather than an unconfined server.
- The roots must be existing folders; a missing one stops the server with an error. Each flag takes
one folder per use (`--flag <dir>` or `--flag=<dir>`) and may be used again for more, and a
misspelled `--automation-...` flag is an error rather than an unconfined server.
The flags confine the in-process server, so `--connect` together with them is an error: the server
would otherwise talk to an app that isn't confined. SoundCraft's desktop app has no equivalent flags
-2
View File
@@ -1,7 +1,5 @@
# Parity report
> **Generated:** 2026-10-06 (last run of `cargo xtask parity`; the catalog and aliases are unchanged since) · **Target:** Avid Pro Tools Ultimate 2026.4.1 menu tree
Generated by `soundcraft-cli parity --write docs/parity-checklist.md` (also `cargo xtask parity`).
It compares the incumbent's menu tree (feature names observed black-box, `crates/engine/catalog/menus.txt`) with SoundCraft's command registry.
UI-layer commands (windows, AudioSuite) are counted by the app's own report (`engine.parity` over the control channel).
+5 -3
View File
@@ -1,6 +1,6 @@
# Plugin parity: built-in processors and plugin hosting
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1
Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins
cover the rest. This doc tracks both. Our plugins have our own names and are written from
@@ -17,12 +17,12 @@ scratch (clean-room rule): Avid plugin names are not used here, only categories.
| Dynamics | compressor/limiter, expander/gate, de-esser, maximizer, vintage compressors, multiband | `compressor`, `expander_gate`, `de_esser`, `maximizer` | Vintage/opto models, multiband, sidechain input (no key input anywhere) |
| Reverb | algorithmic room/plate/hall; convolution (paid tier) | `room_reverb`, `plate_reverb` | Hall/chamber, convolution, early-reflection editor |
| Delay | modulated delay, tape/analog delays | `mod_delay` | Tape, ping-pong, multi-tap, tempo-sync depth |
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser` | Tremolo, auto-pan, ring modulation |
| Modulation | chorus, flanger, phaser, tremolo, ring/frequency shift | `chorus`, `flanger`, `phaser`, `ring_mod` (ring, frequency modulation, positive and negative resonators; LFO, envelope or sample-and-hold modulation) | Tremolo, auto-pan, frequency shift |
| Harmonic | lo-fi, rectifier, amp simulation, saturation, tape | `lofi`, `rectifier`, `saturator` | Amp and cabinet models, tape |
| Pitch and time | pitch shift, varispeed effects, time shift | `pitch_shifter`, `time_shift` | Formant control, varispeed effect, pitch correction |
| Utility | trim, gain, invert, DC offset, signal generator, dither, time adjuster | `trim`, `gain`, `invert`, `dc_offset_removal`, `signal_generator`, `dither` | Time adjuster, meter/analyzer plugins, noise shaping options |
| Instruments | several virtual instruments (multi-sampler, piano, drum machine, organ, synths, sample player; Massive X Player added in 2026.4) | `subtractive_synth`, `drum_synth`; `mix.instrument` takes the instrument's parameters in `params` and refuses unknown fields (#265). The sampler's ping-pong loop keeps its phase when a step is wider than the loop (#463). | Sampler (PR #108 open), piano, organ, wavetable, content library |
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **26 processors + 2 instruments** | |
| **Total** | **≈ 80 processors and instruments** (estimate from Avid's bundle documentation) | **25 processors + 3 instruments** | |
AudioSuite: every catalog AudioSuite leaf maps to one of our processes (`AUDIOSUITE` in
`crates/ui-egui/src/menus.rs`), so the AudioSuite menu is 100 % present. Depth: no preview,
@@ -58,6 +58,8 @@ User report #152: a saved instrument level does not reach the bounce.
| Date | Change | Summary |
|---|---|---|
| 2026-10-23 | trivial | Modulation: Ring Modulator (`ring_mod`), also used by the AudioSuite Modulation menu |
| 2026-10-13 | minor | VST3 host: IMessage and IAttributeList for plugins whose halves talk through messages (Line 6 Helix Stadium Native verified) |
| 2026-10-13 | trivial | Sampler ping-pong loop phase (#463) |
| 2026-10-13 | trivial | mix.instrument sets built-in instrument parameters in params (#265) |
| 2026-10-12 | minor | Editors: VST3 and embedded CLAP editors on Windows |
+9
View File
@@ -0,0 +1,9 @@
# Ordinary strip mute automation
Audio, instrument, Aux and Folder strips evaluate their existing mute lane at each sample represented by a render block. Values at or above 0.5 silence both the processed output and its pre-fader send copy. A mute and unmute inside one block therefore take effect without splitting that block into smaller processor calls.
The first mute lane, existing interpolation, and existing automation-mode policy remain authoritative. An empty lane or Off mode leaves ordinary audio unchanged. Static mute, solo suppression and static VCA mute remain dominant. Fader ramps, pan scheduling and hosted processor call sizes remain unchanged.
Mute reads use the current render-position clock. Signal alignment, Master/VCA automation and send-control policies retain their existing behavior.
Synthetic regressions cover interior and boundary transitions, different block partitions, pre/post-fader sends, Aux/Folder input paths, mode/empty-lane fallbacks, static/solo/VCA suppression, unchanged fader/pan scheduling, and an explicitly adopted mock hosted processor's call cadence. No installed hosted plugin is required.
+3 -2
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (drag across checkboxes, #399; waveforms preview fades during drag; a plugin opens when inserted, #486) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-16 · **Last updated:** 2026-10-16 · **Change:** minor (Scroll Into View in the track name menu) · **Target:** Avid Pro Tools Ultimate 2026.4.1
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -35,7 +35,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Modifier gestures (Alt for all tracks, Shift-Alt for selected, Cmd-click fine faders, Ctrl-click defaults) | 🟡 | Main ones on faders and pan; not across every control |
| Scroll/zoom: wheel, trackpad pinch | 🟡 | Anchored trackpad zoom in open PRs #112, #137 |
| Drag and drop: Clip List → tracks, files → tracks | ✅ macOS/Windows; 🟡 Wayland (PR #150) | |
| Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4) |
| Context menus (clip, track name, ruler, Clip List) | 🟡 | Clip and track menus exist; depth lower than Pro Tools'; Clip List delete (#4); track name ▸ Scroll Into View (Edit top, Mix left) |
| Drag across checkboxes to check or uncheck several | ✅ | #399; every checkbox, as in the other Craft apps |
## Windows and panels
@@ -69,6 +69,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
| 2026-10-13 | minor | Drag across checkboxes (#399) |
| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged |
+37 -6
View File
@@ -29,11 +29,28 @@ while [[ $# -gt 0 ]]; do
done
# FreeBSD calls x86_64 "amd64"; release names use the Rust/Linux spelling.
case "$(uname -m)" in
amd64 | x86_64) ARCH=x86_64 ;;
arm64 | aarch64) ARCH=aarch64 ;;
*) echo "unsupported architecture $(uname -m)" >&2; exit 2 ;;
release_arch() {
local arch="$1"
case "$arch" in
amd64 | x86_64) echo x86_64 ;;
arm64 | aarch64) echo aarch64 ;;
*) echo "unsupported architecture $arch" >&2; return 2 ;;
esac
return
}
# The release workflow cross-compiles aarch64 in an x86_64 VM with CARGO_BUILD_TARGET set to
# aarch64-unknown-freebsd (craft-common-actions#39): name the archive after the target.
case "${CARGO_BUILD_TARGET:-}" in
"") ARCH="$(release_arch "$(uname -m)")" ;;
aarch64-unknown-freebsd | x86_64-unknown-freebsd) ARCH="$(release_arch "${CARGO_BUILD_TARGET%%-*}")" ;;
*) echo "CARGO_BUILD_TARGET=$CARGO_BUILD_TARGET is not a supported FreeBSD target" >&2; exit 2 ;;
esac
# Only binaries for this machine run here (the --version check at the end); a FreeBSD target built
# on another OS never does.
HOST_ARCH="$(release_arch "$(uname -m)" 2>/dev/null)" || HOST_ARCH="$(uname -m)"
if [[ -n "${CARGO_BUILD_TARGET:-}" ]] && [[ "$(uname -s)" != FreeBSD ]]; then
HOST_ARCH="$(uname -s) $HOST_ARCH"
fi
BASENAME="soundcraft-$VERSION-freebsd-$ARCH"
echo "==> SoundCraft $VERSION for FreeBSD $ARCH"
@@ -41,7 +58,8 @@ echo "==> SoundCraft $VERSION for FreeBSD $ARCH"
if [[ "$SKIP_BUILD" = 0 ]]; then
(cd "$ROOT" && cargo build --release --locked -p soundcraft -p soundcraft-cli)
fi
BIN="$CARGO_TARGET_DIR/release"
# Cargo puts a cross build under target/<triple>/release.
BIN="$CARGO_TARGET_DIR/${CARGO_BUILD_TARGET:+$CARGO_BUILD_TARGET/}release"
WORK="$CARGO_TARGET_DIR/freebsd-package"
STAGE="$WORK/$BASENAME"
rm -rf "$WORK"
@@ -63,6 +81,19 @@ copy_docs "$STAGE/share/doc/soundcraft"
tar -C "$WORK" -czf "$DIST/$BASENAME.tar.gz" "$BASENAME"
echo "wrote $DIST/$BASENAME.tar.gz"
"$STAGE/bin/soundcraft-cli" --version
if [[ "$ARCH" = "$HOST_ARCH" ]]; then
"$STAGE/bin/soundcraft-cli" --version
else
# They cannot run here, so check what they are instead: executables, not libraries or objects.
# An older file(1) calls a PIE a shared object; its interpreter still marks it as a program.
want="$ARCH"; [[ "$ARCH" != x86_64 ]] || want=x86-64
for b in "$STAGE"/bin/*; do
case "$(file -b "$b")" in
ELF*" executable, "*"$want"*FreeBSD* | ELF*"$want"*FreeBSD*interpreter*) ;;
*) echo "error: $b is not a FreeBSD $ARCH executable: $(file -b "$b")" >&2; exit 1 ;;
esac
done
echo "skipping --version: an $ARCH binary does not run on this $HOST_ARCH machine"
fi
echo "==> done"
ls -lh "$DIST"