Compare commits

..
Author SHA1 Message Date
Dylan M. TaylorandClaude Sonnet 5.5 e103e2e45f fix: clippy lints in extracted helpers
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:44:54 +00:00
Dylan M. TaylorandClaude Sonnet 5.5 353acea88c refactor: reduce cognitive complexity in audio-io, midi, dsp, engine, playback, plugin hosts (S3776)
Extract helpers; behavior-preserving.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-24 08:36:56 +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
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
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
Brandon ThomasandClaude Opus 5.5 dfb3815eb3 Move the #264 toolbar scroll test into the shared tests/it binary
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 00:50:07 -04:00
Brandon Thomas bf9fa3cc60 Merge remote-tracking branch 'origin/main' into m/946 2026-10-24 00:50:02 -04:00
Dylan M. Taylor 64669e9a7c chore: resolve SonarCloud maintainability issues (#1003)
* chore: resolve SonarCloud maintainability issues

* fix(packaging): extract verify_sha256 helper in package.sh

* style: apply rustfmt formatting

* fix(video): ensure prefix is i32 in cabac_mb
2026-10-23 22:33:59 -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
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
Dylan M. Taylor 6b84a4f046 ci(sonar): don't cancel superseded runs on main, so coverage is published (#992) 2026-10-23 17:00:27 -04:00
aiongg 67865b23e8 Build: use craft-media for bitstream and isobmff (#776)
* Build: use craft-media for bitstream and isobmff

soundcraft-video re-exports craft_media::{bitstream, isobmff} from
craft-libs instead of carrying a copy of FilmCraft's crates. The module
paths stay, so crate::bitstream and crate::isobmff resolve as before.
The shared copy is newer: it adds APV, .mp2 as MPEG audio, AAC channel
configurations 11-14, SampleEntry::av1/flac, Mp4Writer::set_sample_entry
and the se(v) fix for i32::MIN.

* Build: pin craft-media to craft-libs f0ad6c1e
2026-10-23 16:41:42 -04:00
Dylan M. TaylorandDylan Taylor fe4bb3fe0c style: replace flagged glob imports with explicit uses (SonarCloud rust:S2208); move release workflow's write permission to the job (S8233) (#988)
* security: move the release workflow's write permission to the job level (SonarCloud githubactions:S8233)

* style: replace wildcard imports with explicit uses (SonarCloud rust:S2208)

Rewrites every glob `use` Sonar flags into explicit imports, so what each
command module consumes is visible at the top of the file.

* release: the git-sources gate moves into craft-common-actions (PR #78)

The check is shared at release.yml@main now; keep only the reusable call.

---------

Co-authored-by: Dylan Taylor <dylanmtaylor@users.noreply.github.com>
2026-10-23 14:53:00 -04:00
aiongg a4d3d8541f CI: run the gate through craft-common-actions' shared gate.yml; FreeBSD nightly and on release (#955)
ci.yml is new: it calls the shared workflow (cached apt packages, nextest, main-only cache saves, Cargo.lock check). FreeBSD runs nightly and on pushes to release instead of on pull requests. Depends on storytold/craft-common-actions#69.
2026-10-23 14:17:28 -04:00
aiongg e8abd44712 CI: one test binary per crate, nextest, line tables only (#985)
* Build: line tables only for workspace crates, no debug info for dependencies

Full debug info made most of the dev and test target size and link time. Line tables keep file and line numbers in backtraces; dependency frames lose them.

* Tests: build each crate's integration tests as one binary

Each tests/*.rs file was its own test binary: 98 binaries, each linking the crate and its dependencies. The files are now modules of tests/it/main.rs, one binary per crate, which cuts link time and target size. Filter by module: `cargo test -p <crate> --test it <module>::`.

Binaries with their own main (harness = false) in clap-host and vst3-host stay separate, as do the ui-egui tests that switch the process-wide Light/Dark palette and recovery.rs, whose autosave locks leak into child processes other tests start.

* Xtask: run tests with nextest, doctests apart, and no examples in the test build

cargo xtask ci runs the tests with cargo nextest when it is installed, else cargo test with a notice, and doctests with cargo test --doc. The test build leaves out examples (--lib --bins --tests); clippy --all-targets still compiles them. Test binaries with harness = false run under cargo test: their own main cannot answer nextest's --list. clippy and the wasm check keep going past a failing crate. cargo xtask test runs the test step alone.
2026-10-23 14:00:38 -04: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
aiongg ab1849cbf8 Audio: check the WAVE behind a skipped RIFF form; hold egui at 0.36 in Dependabot (#978)
* Audio: check the WAVE behind a RIFF form the probe skips

The probe skips a RIFF form other than WAVE and reads a WAVE behind it, so
the marker search skips it too and the INFO check runs on the WAVE the
probe reads. The mismatched-container test moves to an input that still
mismatches: a hinted FLAC behind a WAVE.

* Dependabot: hold egui, eframe and egui_kittest at 0.36

craft-ui stays on egui 0.36 until storytold/craft-libs#199 lands; the daily
group would otherwise propose 0.37 again.
2026-10-23 13:37:39 -04:00
aiongg 265645a8fc Sessions: resolve stored source paths with craft-docpath's resolve_absolute (#826)
* Sessions: use resolve_absolute for stored source paths

* CI: check that git dependencies come from storytold

* CI: release only with git dependencies from storytold

* Parse Cargo.lock as TOML when checking git sources

* Use the shared git-sources check in CI

* CI: parse cargo config, including the legacy .cargo/config, in the git-sources check
2026-10-23 13:37:13 -04:00
Gert Pellin - GPE 9b68982738 autosave: give every session its own folder; keep the tests apart from coarse mtimes (#579) (#594)
With /tmp on tmpfs, four autosave and recovery tests failed:

- Autosave folders were named by millisecond and process id, and created
  with create_dir_all, so two sessions started in the same millisecond
  shared one folder and the second overwrote the first's unsaved work
  (recovering_keeps_the_unsaved_work_it_replaces,
  closing_the_session_or_quitting_drops_its_autosave). Autosave::create
  now creates the folder exclusively and adds a -2, -3... suffix when
  the name is taken.
- leftovers() offers an autosave only when it is strictly newer than its
  session file. A save and the autosave right after it can share one
  coarse mtime, so the tests that save and then autosave now move the
  saved file's mtime a second back
  (unsaved_work_is_offered_once_its_session_closes,
  an_exit_without_quit_offers_unsaved_changes_at_the_next_launch).
2026-10-23 13:36:57 -04:00
aiongg ef8a63f8c3 Packaging: pin the signing client and appimagetool, and check their hashes (#827)
* Packaging: pin the Windows signing client and check its hash

* Packaging: pin appimagetool and check its hash

* Reject AppImage download for architectures without a pinned appimagetool

* Pin the AppImage runtime
2026-10-23 13:35:58 -04: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
Superior f0d5acb800 Deps: fix the workspace build after the dependency bump (#954)
* Audio: migrate file decoding to symphonia 0.6

Upstream bumped symphonia to 0.6 without adapting crates/audio-io, so
`soundcraft-audio-io` no longer built: the probe module, codec parameters
and decoded-buffer APIs all moved.

- Probe with `default::get_probe().probe(&Hint, …)`. The 0.5 prober's
  detection-only path is gone, so scan for the container marker ourselves
  before instantiating a reader, and keep running the existing size checks
  (CAF/MP4/FLAC/WAV) on the first marker — the damaged-file regression
  tests depend on those checks firing before any allocation.
- Skip leading ID3v2 tags with `ReadBytes::ignore_bytes` as before.
- Read `Track::codec_params` (now an `Option`), decode through
  `make_audio_decoder` and `GenericAudioBufferRef::copy_to_vecs_planar`,
  and read the new public `Packet` fields.
- Keep the per-packet frame limit and the sample sanitisation.

* Playback: migrate the audio device layer to cpal 0.18

- Sample rates are plain `u32` now: drop `.0` and the `cpal::SampleRate`
  constructor.
- One `cpal::Error` replaces the build/stream/play/pause error types;
  detect `DeviceNotAvailable` through `error.kind()`.
- Stream builders take `StreamConfig` by value, and device names come
  from `Device::description()` (the deprecated `name()` is gone).
- The test `Fake` implements the new `StreamTrait::{buffer_size, now}`.

* Deps: pin egui back to 0.36 until craft-ui supports 0.37

The dependabot group bump (#767) moved the workspace to egui, eframe and
egui_kittest 0.37, but the pinned `craft-ui` revision still builds against
egui 0.36, so the graph carries two incompatible egui versions and
`soundcraft-ui-egui` does not compile. craft-libs#199 updates craft-ui to
0.37 and is still open; pin 0.36 and refresh the lockfile meanwhile. The
other version bumps from #767 stay.

Re-land egui 0.37 together with the craft-ui update.

* Audio: refuse a size-checked container the probe did not validate

The dependency-migration review showed the preflight size checks can
validate one container marker while symphonia's probe selects a different
container, so a misleading marker could skip the FLAC/MP4/CAF/WAVE checks.
Probe 0.6 reports the selected format only through the reader it returns,
so `open()` now records which container's checks ran (including the
RF64/BW64 RIFF forms) and refuses the stream when a size-checked reader
was chosen without its checks, before any packet is decoded. Markers found
in audio data of non-container streams (MP3, Ogg, AIFF) do not reject
valid files.

Adds a regression test with a fake RIFF prefix followed by a valid FLAC
stream, and doc comments for the functions touched by the dependency
migration (the review's docstring-coverage warning).
2026-10-23 12:15:56 -04:00
MarekSpodBiedry 7c690b19e1 Follow the desktop light/dark setting for the System theme on Linux (#951) 2026-10-23 17:44:09 +02:00
Dylan M. Taylor d47abe484d Use chrono instead of hand-rolled UTC conversion (#844)
* Use chrono for UTC/calendar conversion instead of hand-rolled copies

Replaces the civil_from_days copy behind the desktop app's log timestamp with
chrono as a workspace dependency. The output format is unchanged and pinned by
the existing test. AGENTS.md now says time goes through chrono.

Follow-up to storytold/cadcraft#1785

* Never panic logging on a clock outside chrono's range

chrono's From<SystemTime> unwraps internally, so a clock past year 262143
panicked inside the logger. timestamp now reads the duration since the epoch
and goes through the fallible DateTime::from_timestamp, reading an
out-of-range clock as the epoch, exactly as the pre-epoch clamp already did.
The year is written with {:04} rather than %Y, which would prefix a '+' past
year 9999. Tests cover the far-future and year-10000 cases.

* Don't build an unrepresentable SystemTime in the far-future test

UNIX_EPOCH + Duration::from_secs(u64::MAX / 2) overflows Windows'
SystemTime and panicked before timestamp() ran, failing the Windows
test job. Build it with checked_add and only assert where it exists.

* time: preserve old out-of-range log timestamp (no epoch fallback)

`DateTime::from_timestamp(...).unwrap_or_default()` silently reset any clock
past chrono's year range to 1970-01-01, changing output the old hand-rolled
`civil_from_days` converted exactly. Restore the old conversion domain with a
cycle-based helper that routes the calendar through chrono (the calendar
repeats every 400 years = 146_097 days, so month/day/time come from chrono and
only the year is carried in i128, keeping every i64 exact). Keep the pre-1970
epoch clamp; u64 seconds past i64::MAX saturate rather than falling back.

Pinned old outputs in the test (replacing the u64::MAX/2 -> epoch assertion):
  u64::MAX/2 s -> 292277026596-12-04T15:30:07.000Z
  10_000_000_000_000 s -> 318857-05-20T17:46:40.000Z

* Don't build a year-318857 SystemTime unchecked in the logging test

Windows' SystemTime only reaches about year 30828, so
UNIX_EPOCH + Duration::from_secs(10_000_000_000_000) panicked before
timestamp() ran and failed the Windows test job. Build it with
checked_add and assert where the platform can hold it. The year-10000
case fits everywhere and stays unconditional.

* Name the 400-year range extension as the one exception to the time rule

The rule said never to hand-roll calendar math, but the range fix
(shifting by whole 400-year Gregorian cycles so dates past chrono's year
range keep their old output) is cycle arithmetic, so the rule and the code
disagreed. The rule now names that exception: the library still computes
month, day and time, and only whole cycles are carried in the year.

* Say the library computes month and day; time-of-day is plain arithmetic

Narrows the range-extension wording: the date library computes the
calendar fields, and splitting seconds into hours, minutes and seconds
is ordinary arithmetic, which is what the code does.
2026-10-23 11:35:06 -04:00
Dylan M. Taylor f431791c26 style: use [[ ]], named locals and explicit returns in packaging scripts (SonarCloud S7688/S7679/S7682) (#950)
Bash scripts under packaging/ now use [[ ]] for tests, copy positional
parameters into named locals inside functions, and end env.sh helpers with
an explicit return (bare, so each keeps its exit status). shellcheck clean.
2026-10-23 11:17:44 -04:00
Dylan M. Taylor 53a18ec037 Merge pull request #962 from dylanmtaylor/sonar/security-reliability
style: normalize the prores DCT basis literals (SonarCloud rust:S7454)
2026-10-23 09:37:38 -04:00
Dylan M. Taylor 44067c8779 style: normalize the prores DCT basis literals (SonarCloud rust:S7454)
0.490_392_64 and 0.49039264 are the same f32 value; the underscored form
reads as a numeric suffix to the analyzer.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-10-23 09:29:32 -04: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
dependabot[bot]anddependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> 52471b37e9 chore(deps): bump the cargo-dependencies group across 1 directory with 11 updates (#767)
Bumps the cargo-dependencies group with 11 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [serde_json](https://github.com/serde-rs/json) | `1.0.151` | `1.0.154` |
| [proptest](https://github.com/proptest-rs/proptest) | `1.11.0` | `1.12.0` |
| [symphonia](https://github.com/pdeljanov/Symphonia) | `0.5.5` | `0.6.1` |
| [zip](https://github.com/zip-rs/zip2) | `9.0.1` | `9.0.5` |
| [cpal](https://github.com/RustAudio/cpal) | `0.16.0` | `0.18.2` |
| [egui](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [eframe](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [egui_kittest](https://github.com/emilk/egui) | `0.36.2` | `0.37.0` |
| [libloading](https://github.com/nagisa/rust_libloading) | `0.8.9` | `0.9.0` |
| [muda](https://github.com/tauri-apps/muda) | `0.19.3` | `0.21.2` |
| [winresource](https://github.com/BenjaminRi/winresource) | `0.1.31` | `0.1.34` |



Updates `serde_json` from 1.0.151 to 1.0.154
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.151...v1.0.154)

Updates `proptest` from 1.11.0 to 1.12.0
- [Release notes](https://github.com/proptest-rs/proptest/releases)
- [Changelog](https://github.com/proptest-rs/proptest/blob/main/CHANGELOG.md)
- [Commits](https://github.com/proptest-rs/proptest/compare/v1.11.0...v1.12.0)

Updates `symphonia` from 0.5.5 to 0.6.1
- [Release notes](https://github.com/pdeljanov/Symphonia/releases)
- [Commits](https://github.com/pdeljanov/Symphonia/compare/v0.5.5...v0.6.1)

Updates `zip` from 9.0.1 to 9.0.5
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v9.0.1...v9.0.5)

Updates `cpal` from 0.16.0 to 0.18.2
- [Release notes](https://github.com/RustAudio/cpal/releases)
- [Changelog](https://github.com/RustAudio/cpal/blob/master/CHANGELOG.md)
- [Commits](https://github.com/RustAudio/cpal/compare/v0.16.0...v0.18.2)

Updates `egui` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `eframe` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `egui_kittest` from 0.36.2 to 0.37.0
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/main/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.36.2...0.37.0)

Updates `libloading` from 0.8.9 to 0.9.0
- [Commits](https://github.com/nagisa/rust_libloading/compare/0.8.9...0.9.0)

Updates `muda` from 0.19.3 to 0.21.2
- [Release notes](https://github.com/tauri-apps/muda/releases)
- [Changelog](https://github.com/tauri-apps/muda/blob/dev/CHANGELOG.md)
- [Commits](https://github.com/tauri-apps/muda/compare/muda-v0.19.3...muda-v0.21.2)

Updates `winresource` from 0.1.31 to 0.1.34
- [Commits](https://github.com/BenjaminRi/winresource/commits)

---
updated-dependencies:
- dependency-name: cpal
  dependency-version: 0.18.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: eframe
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: egui_kittest
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: libloading
  dependency-version: 0.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: muda
  dependency-version: 0.21.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: proptest
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: serde_json
  dependency-version: 1.0.154
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: symphonia
  dependency-version: 0.6.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: cargo-dependencies
- dependency-name: winresource
  dependency-version: 0.1.34
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
- dependency-name: zip
  dependency-version: 9.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: cargo-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-22 23:36:57 -04:00
skylar taylor-barrick 44663d9601 #264: require the whole tempo field visible and unclipped
The test only checked the tempo text's left edge, so a field cut off on either side would still pass. It now checks that the whole text lies inside the window and inside the clip rect it is painted with. With the toolbar's scrolling turned off it still fails.
2026-10-22 19:13:26 -06:00
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
skylar taylor-barrick a36228f0ec #264: test that the Edit toolbar scrolls horizontally in a narrow window
The Edit toolbar laid its tool groups out in a bare row, so in a narrow window the groups on the right (the tempo among them) were clipped and unreachable. The test renders a 700 pt wide window, scrolls the toolbar sideways with the wheel and checks the tempo field is then painted inside the window. Without the horizontal ScrollArea the tempo is never painted, so the assertion fails.
2026-10-22 18:02:15 -06: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
Brandon ThomasandClaude Opus 5.5 e85a4801e9 Release 0.12.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:17 -04:00
Brandon ThomasandClaude Opus 5.5 239c78e3a0 macOS packaging: retry stapler when Apple's CloudKit drops the connection
stapler staple/validate fetch the notarization ticket from CloudKit, which
sometimes fails with "The network connection was lost" (Error 68). That
failed this release's macOS job twice in a row. Both steps are idempotent,
so retry up to 5 times with backoff, like hdiutil_retry.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:16:16 -04: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
Brandon ThomasandClaude Opus 5.5 2cfc56d515 Release 0.12.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:38:08 -04: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
skylar taylor-barrick 06db0bd373 Merge pull request #526 from saadkhantareen/fix/length-prefixed-nals-zero-width
fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469)
2026-10-22 07:49:15 -06:00
skylar taylor-barrick 6c596fca13 Merge pull request #702 from Silvertree2010/fix/697-random-edit-test-pad-playlist-no
Keep playlist sorted after delete_fades trims overlapping clips
2026-10-22 07:49:07 -06:00
skylar taylor-barrick 5cdc72a0ed Merge pull request #786 from MichaelMuClaude/fix/plus-key-nudge-734
fix(ui): bare "=" key nudges the selected clip right, matching "-"
2026-10-22 07:49:00 -06:00
skylar taylor-barrick 589608a5d3 Merge pull request #812 from Silvertree2010/fix/h840-automationlane-write-range-overw
Keep existing breakpoint at end + 1 in AutomationLane::write_range
2026-10-22 07:48:53 -06:00
skylar taylor-barrick 55d9e8f86d Merge pull request #820 from Silvertree2010/fix/h907-dsp-offline-normalize-turns-near
Fix normalize producing inf samples for subnormal peaks
2026-10-22 07:48:47 -06:00
skylar taylor-barrick 84495f1c9b Merge pull request #829 from Silvertree2010/fix/816-mix-send-to-an-unknown-bus-name
Reject mix.send to an unknown bus name instead of creating a bus
2026-10-22 07:48:40 -06:00
skylar taylor-barrick 095b483ce1 Merge pull request #828 from MMexicann/fix/timecode-frame-count-overflow
Fix overflow in extreme timecode frame counts
2026-10-22 07:48:33 -06:00
skylar taylor-barrick 816a6efb77 Merge pull request #830 from MMexicann/fix/midi-invalid-smpte-division
Reject undefined SMPTE frame rates in MIDI headers
2026-10-22 07:48:26 -06:00
skylar taylor-barrick 1b3e87c9c0 Merge pull request #831 from Silvertree2010/fix/h1126-tempomap-bar-beat-at-tick-panics
Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN
2026-10-22 07:48:19 -06:00
skylar taylor-barrick 2a5d308c55 Merge pull request #834 from Silvertree2010/fix/h1179-autoparam-label-panics-with-inte
Fix integer overflow in AutoParam::label for Pan(255)
2026-10-22 07:48:13 -06:00
BFlat b1466e451c Merge pull request #934 from storytold/readme/app-icon
README: the app icon above the title
2026-10-22 18:02:28 +07:00
bflatastic 24543e1486 README: the app icon above the title 2026-10-22 17:58:22 +07: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
Dylan M. Taylor 9b8283b908 security: enforce HTTPS on curl downloads (SonarCloud S6506) (#860)
Add --proto '=https' to curl invocations so redirects cannot downgrade to
plaintext HTTP. Resolves SonarCloud S6506 (shell/docker/githubactions).
2026-10-21 20:13:52 -04: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 01c8c85ebc Fix integer overflow in AutoParam::label for Pan(255)
Found while testing crates/model/src/automation.rs:73

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 07:12:50 +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 4ef6458e4e Fix integer overflow in TempoMap::bar_beat_at_tick for i64::MIN
Found while testing crates/time/src/tempo.rs:271

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 01:12:42 +02:00
Mex 152c2c45e6 Reject undefined SMPTE frame rates in MIDI headers
Accept only the four defined SMPTE frame-rate codes. Add exhaustive invalid-code rejection and valid-rate timing and zero-tick regressions.
2026-10-20 23:51:29 +02:00
NetterSilverandClaude Haiku 5.5 96130d22c7 Reject mix.send to an unknown bus name instead of creating a bus
Fixes #816

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 20:30:04 +00:00
Mex 988d678eec Fix overflow in extreme timecode frame counts
Widen the magnitude and drop-frame label arithmetic to i128 so i64::MIN stays exact and extreme drop-frame counts cannot overflow. Add round-trip regressions for all frame rates and both drop-frame variants.
2026-10-20 21:50:38 +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
NetterSilverandClaude Haiku 5.5 5703db0b80 Fix normalize producing inf samples for subnormal peaks
Found while testing crates/dsp/src/offline.rs:62-79

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 17:24:47 +02:00
NetterSilverandClaude Haiku 5.5 1816f2dc92 Keep existing breakpoint at end + 1 in AutomationLane::write_range
Found while testing crates/model/src/automation.rs:190-203

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 14:30:22 +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
MichaelandClaude Sonnet 5 9ac264184e fix(ui): bare "=" key nudges the selected clip right, matching "-"
`edit.nudge`'s "Plus/Minus" shortcut only matched `Key::Plus`, which a
real keyboard without a dedicated numpad `+` can never send: typing
"+" needs Shift, and Shift+=/- is already a separate, intentional
shortcut (Grid value). So nudge-right was unreachable on most
keyboards, while nudge-left worked fine ("-" needs no shift). Fixes
#736.

Add `Key::Equals` (the bare, unshifted key) to the nudge match arm,
the same way it already works for `Key::Minus`. Also exclude `m.alt`
from the arm, since `Alt+=` is the separate, already-registered
`window.event_list` shortcut that would otherwise be shadowed.

Verified live against a running demo session over the control channel
before writing the fix (plain "-" moved a clip; plain "=" and
Shift+"=" both did nothing), and again after. New regression test
covers bare Equals/Plus/Minus nudging, Shift+Equals still stepping the
Grid value instead, and Alt+Equals still opening the MIDI Event List
instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-19 20:01:40 +08: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 0a9d29f227 Keep playlist sorted after delete_fades trims overlapping clips
Fixes #697

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:21:32 +00:00
Marco Arnulfo 5f03a5991e Plugin-only editor changes mark the session modified (fixes #455) 2026-10-16 03:20:53 +02: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
Saad Abdullah 08b0704b0c fix(video): reject zero/invalid NAL length widths in length_prefixed_nals (#469) 2026-10-13 16:37:51 +00:00
237 changed files with 4528 additions and 6440 deletions
+8
View File
@@ -12,6 +12,14 @@ updates:
cargo-dependencies: cargo-dependencies:
patterns: patterns:
- "*" - "*"
# craft-ui is on egui 0.36 until storytold/craft-libs#199 lands.
ignore:
- dependency-name: "egui"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "eframe"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
- dependency-name: "egui_kittest"
update-types: ["version-update:semver-major", "version-update:semver-minor"]
commit-message: commit-message:
prefix: "chore(deps)" prefix: "chore(deps)"
+27
View File
@@ -0,0 +1,27 @@
name: CI
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
# suite's shared gate workflow in craft-common-actions.
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions:
contents: read
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
concurrency:
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
gate:
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
libxrandr-dev libxi-dev libfontconfig1-dev libfreetype-dev mesa-vulkan-drivers
libgl1-mesa-dev libasound2-dev
+5 -6
View File
@@ -3,20 +3,19 @@
# player falls back to its silent clock when the VM has no sound device. # player falls back to its silent clock when the VM has no sound device.
name: FreeBSD name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push # The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
# or dependency change: a drifted dependency still shows up within a day, and always before a # request: a drifted dependency still shows up within a day, and always before a release ships.
# release ships. PRs that change the FreeBSD packaging itself still run it. # PRs that change the FreeBSD workflow or packaging itself still run it.
on: on:
schedule:
- cron: "47 3 * * *"
push: push:
branches: [release] branches: [release]
pull_request: pull_request:
paths: paths:
- "crates/playback/**"
- .github/workflows/freebsd.yml - .github/workflows/freebsd.yml
- packaging/freebsd/** - packaging/freebsd/**
- packaging/env.sh - packaging/env.sh
schedule:
- cron: "47 3 * * *"
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
+38
View File
@@ -21,3 +21,41 @@ jobs:
nix: nix:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/nix.yml@main uses: storytold/craft-common-actions/.github/workflows/nix.yml@main
git-sources:
name: Git dependencies come from storytold
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Git dependencies come from storytold
run: |
set -euo pipefail
test -f Cargo.lock || { echo "Cargo.lock is missing"; exit 1; }
# Source replacement, patches and path overrides in cargo config never show up
# in Cargo.lock. Parsed rather than grepped: TOML has many spellings of one key.
# Cargo still reads the legacy .cargo/config.
python3 - <<'PY'
import os, sys, tomllib
for path in (".cargo/config", ".cargo/config.toml"):
if not os.path.exists(path):
continue
config = tomllib.load(open(path, "rb"))
replaced = [name for name, source in config.get("source", {}).items()
if isinstance(source, dict) and "replace-with" in source]
if replaced or "patch" in config or "paths" in config:
print(f"{path} replaces, patches or overrides a source")
sys.exit(1)
PY
# Fails when Cargo.lock is out of date with the manifests.
cargo metadata --locked --format-version 1 > /dev/null
python3 - <<'PY'
import sys, tomllib
lock = tomllib.load(open("Cargo.lock", "rb"))
bad = sorted({p["source"] for p in lock.get("package", [])
if p.get("source", "").startswith("git+")
and not p["source"].startswith("git+https://github.com/storytold/")})
if bad:
print("Git dependencies outside github.com/storytold:", *bad, sep="\n")
sys.exit(1)
PY
-3
View File
@@ -10,9 +10,6 @@ on:
type: string type: string
default: "" default: ""
permissions:
contents: write
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
+2 -1
View File
@@ -11,7 +11,8 @@ permissions:
concurrency: concurrency:
group: sonarcloud-${{ github.ref }} group: sonarcloud-${{ github.ref }}
cancel-in-progress: true # A push to main is the run that publishes coverage; superseded runs there would never finish.
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs: jobs:
sonarcloud: sonarcloud:
+1
View File
@@ -32,6 +32,7 @@ People trust SoundCraft with their recordings; a crash loses takes. **This outra
- The audio callback never blocks, allocates in steady state, or panics. - The audio callback never blocks, allocates in steady state, or panics.
- Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`. - Every production crate root carries `#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
- `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test. - `Engine::execute` catches escaped panics and restores the document. Every crash fix lands with a regression test.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
## Architecture (layers enforced by `cargo xtask layers`) ## Architecture (layers enforced by `cargo xtask layers`)
Generated
+261 -318
View File
File diff suppressed because it is too large Load Diff
+11 -3
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package] [workspace.package]
version = "0.11.0" version = "0.12.1"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.90" rust-version = "1.90"
@@ -32,21 +32,27 @@ thiserror = "2"
serde = { version = "1", features = ["derive", "rc"] } serde = { version = "1", features = ["derive", "rc"] }
serde_json = { version = "1", features = ["float_roundtrip"] } serde_json = { version = "1", features = ["float_roundtrip"] }
log = "0.4" log = "0.4"
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1" rayon = "1"
proptest = "1" proptest = "1"
# audio # audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] } symphonia = { version = "0.6", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6" rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only. # Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false } zip = { version = "9", default-features = false }
cpal = "0.16" cpal = "0.18"
# Which file paths stored in a session may be opened. # Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4" }
# ui # ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
# egui 0.37 needs craft-libs#199 (craft-ui) first; pin 0.36 until it lands.
egui = "0.36" egui = "0.36"
eframe = { version = "0.36", default-features = false } eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36" egui_kittest = "0.36"
@@ -64,9 +70,11 @@ type_complexity = "allow"
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 2 opt-level = 2
debug = false
[profile.dev] [profile.dev]
opt-level = 1 opt-level = 1
debug = "line-tables-only"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
+4
View File
@@ -7,6 +7,10 @@
</a> </a>
</p> </p>
<p align="center">
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.soundcraft.png" width="128" height="128" alt="SoundCraft icon: a singing bird on a blue tile">
</p>
<h1 align="center">SoundCraft</h1> <h1 align="center">SoundCraft</h1>
<p align="center"> <p align="center">
+25 -31
View File
@@ -3,30 +3,22 @@
use std::path::PathBuf; use std::path::PathBuf;
#[derive(Default)]
pub struct AutomationArgs { pub struct AutomationArgs {
pub read_root: Option<PathBuf>, pub read_roots: Vec<PathBuf>,
pub write_root: Option<PathBuf>, pub write_roots: Vec<PathBuf>,
read_given: bool,
write_given: bool,
} }
impl AutomationArgs { impl AutomationArgs {
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self { /// Returns whether this argument is a root flag. Each flag may be given more than once (#874),
Self { read_root, write_root, read_given: false, write_given: false } /// in either spelling (`--flag DIR`, `--flag=DIR`): every use adds a folder.
}
/// 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.
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> { 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 (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
let (root, given) = match flag { let roots = match flag {
"--automation-read-root" => (&mut self.read_root, &mut self.read_given), "--automation-read-root" => &mut self.read_roots,
"--automation-write-root" => (&mut self.write_root, &mut self.write_given), "--automation-write-root" => &mut self.write_roots,
_ => return Ok(false), _ => return Ok(false),
}; };
if *given {
return Err(format!("{flag} given twice"));
}
let value = match inline { let value = match inline {
Some(value) => value.to_owned(), Some(value) => value.to_owned(),
None => { None => {
@@ -40,8 +32,7 @@ impl AutomationArgs {
if value.is_empty() { if value.is_empty() {
return Err(format!("{flag} needs a non-empty folder")); return Err(format!("{flag} needs a non-empty folder"));
} }
*root = Some(value.into()); roots.push(value.into());
*given = true;
Ok(true) Ok(true)
} }
} }
@@ -51,40 +42,43 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn roots_are_taken_once_and_other_arguments_are_left() { fn roots_add_up_and_other_arguments_are_left() {
let mut roots = AutomationArgs::new(None, None); let mut roots = AutomationArgs::default();
let mut args = ["a folder".to_owned(), "song.scraft".to_owned()].into_iter(); 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-read-root", &mut args).unwrap());
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap()); assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
assert!(!roots.consume("--demo", &mut args).unwrap()); assert!(!roots.consume("--demo", &mut args).unwrap());
assert_eq!(roots.read_root, Some("a folder".into())); assert_eq!(roots.read_roots, [PathBuf::from("a folder")]);
assert_eq!(roots.write_root, Some("out".into())); assert_eq!(roots.write_roots, [PathBuf::from("out")]);
assert_eq!(args.next().as_deref(), Some("song.scraft")); assert_eq!(args.next().as_deref(), Some("song.scraft"));
assert!(roots.consume("--automation-read-root=other", &mut args).is_err()); // #874: a flag given again adds a folder, in either spelling.
assert_eq!(roots.read_root, Some("a folder".into())); 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] #[test]
fn invalid_operands_leave_roots_unchanged() { fn invalid_operands_leave_roots_unchanged() {
for flag in ["--automation-read-root", "--automation-write-root"] { for flag in ["--automation-read-root", "--automation-write-root"] {
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] { 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(); let mut args = operand.map(str::to_owned).into_iter();
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}"); assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
assert_eq!(roots.read_root, Some("read".into())); assert_eq!(roots.read_roots, [PathBuf::from("read")]);
assert_eq!(roots.write_root, Some("write".into())); 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()); assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
} }
} }
#[test] #[test]
fn explicit_dash_folder_and_relative_dash_folder_are_valid() { 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-read-root=-folder", &mut std::iter::empty()).unwrap());
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).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.read_roots, [PathBuf::from("-folder")]);
assert_eq!(roots.write_root, Some("./-folder".into())); assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
} }
} }
+14 -11
View File
@@ -8,12 +8,14 @@
//! soundcraft-cli commands [FILTER] list commands //! soundcraft-cli commands [FILTER] list commands
//! soundcraft-cli describe ID one command's details //! soundcraft-cli describe ID one command's details
//! soundcraft-cli app [--port P] METHOD [JSON] call a running app's control channel //! 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] //! 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 //! MCP server on stdio. The roots confine every file the
//! agent's commands read (open, import, presets) and write //! agent's commands read (open, import, presets) and write
//! (save, bounce, export) to those folders, links followed; //! (save, bounce, export) to those folders, links followed;
//! a root left out grants none of its access. They run the //! each flag may be given more than once, a path inside any
//! in-process server, so they conflict with --connect. //! 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 parity [--write PATH] parity report (markdown)
//! soundcraft-cli plugins list built-in plugins //! soundcraft-cli plugins list built-in plugins
//! ``` //! ```
@@ -110,7 +112,7 @@ fn info(args: &[String]) -> ExitCode {
Ok(b) => b, Ok(b) => b,
Err(e) => return fail(format!("{path}: {e}")), Err(e) => return fail(format!("{path}: {e}")),
}; };
let ext = std::path::Path::new(path).extension().and_then(|x| x.to_str()); let ext = std::path::Path::new(path).extension().and_then(std::ffi::OsStr::to_str);
match soundcraft_audio_io::probe(&bytes, ext) { match soundcraft_audio_io::probe(&bytes, ext) {
Ok(i) => { Ok(i) => {
outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default()); outln!("{}", serde_json::to_string_pretty(&json!({"format": i.format, "sample_format": i.sample_format, "sample_rate": i.sample_rate, "channels": i.channels, "frames": i.frames, "seconds": i.duration_secs(), "bwf": i.bwf})).unwrap_or_default());
@@ -134,7 +136,7 @@ fn convert(args: &[String]) -> ExitCode {
Ok(b) => b, Ok(b) => b,
Err(e) => return fail(format!("{input}: {e}")), Err(e) => return fail(format!("{input}: {e}")),
}; };
let ext = std::path::Path::new(input).extension().and_then(|x| x.to_str()); 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 (_, mut buf) = match soundcraft_audio_io::decode(&bytes, ext) {
Ok(x) => x, Ok(x) => x,
Err(e) => return fail(format!("{input}: {e}")), Err(e) => return fail(format!("{input}: {e}")),
@@ -146,7 +148,7 @@ fn convert(args: &[String]) -> ExitCode {
if args.iter().any(|a| a == "--normalize") { if args.iter().any(|a| a == "--normalize") {
soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false); soundcraft_dsp::offline::normalize(&mut buf.channels, -0.1, false);
} }
let out_ext = std::path::Path::new(output).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); 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 { 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)); return fail(format!("{output}: cannot write `.{out_ext}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS));
}; };
@@ -398,7 +400,7 @@ struct McpArgs {
fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> { fn parse_mcp_args(args: &[String]) -> Result<McpArgs, String> {
let mut connect = None; let mut connect = None;
let mut demo = false; 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(); let mut it = args.iter().cloned();
while let Some(a) = it.next() { while let Some(a) = it.next() {
if roots_args.consume(&a, &mut it)? { if roots_args.consume(&a, &mut it)? {
@@ -411,7 +413,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() { 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, \ 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)" so drop --connect (or use --connect without the roots)"
@@ -437,8 +441,7 @@ fn mcp(args: &[String]) -> ExitCode {
if let Some(roots) = roots { if let Some(roots) = roots {
// Every thread of this process, not only the server's: nothing reaches past the roots. // Every thread of this process, not only the server's: nothing reaches past the roots.
file_access::confine_process(roots.clone()); 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: {}", roots.describe());
eprintln!("soundcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
h = h.with_automation_roots(Some(roots)); h = h.with_automation_roots(Some(roots));
} }
let mut s = Server::new(h); let mut s = Server::new(h);
@@ -1,9 +1,8 @@
//! `soundcraft-cli mcp` with the automation root flags, run as the real binary. //! `soundcraft-cli mcp` with the automation root flags, run as the real binary.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). // 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::io::Write; use std::io::Write;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio}; use std::process::{Command, Output, Stdio};
use serde_json::Value; use serde_json::Value;
@@ -28,7 +27,16 @@ fn tmpdir(tag: &str) -> PathBuf {
/// Send `calls` (tool name, arguments) to `soundcraft-cli mcp <args>`; returns whether it exited /// 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. /// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) { 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(); let stdin = child.stdin.as_mut().unwrap();
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap(); writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
@@ -102,6 +110,74 @@ fn mcp_automation_roots_confine_the_server() {
let _ = std::fs::remove_dir_all(base); 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] #[test]
fn mcp_automation_root_flags_are_checked_up_front() { fn mcp_automation_root_flags_are_checked_up_front() {
let base = tmpdir("roots-args"); let base = tmpdir("roots-args");
@@ -118,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. // The flags confine the in-process server, so they cannot be combined with --connect.
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect"); fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect"); fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice"); // #874: a flag may be given again, but every folder it names must exist.
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice"); 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-read-root"], "needs a folder");
fails(&["--automation-write-root", "--demo"], "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"); fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
+5
View File
@@ -0,0 +1,5 @@
//! Integration tests, built as one test binary: each file is a module.
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod cli;
mod closed_stdout;
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>SoundCraft</title> <title>SoundCraft</title>
<link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" /> <link data-trunk rel="icon" type="image/png" href="../../assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" />
<link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs /> <link data-trunk rel="rust" data-bin="soundcraft-web" data-wasm-opt="z" data-weak-refs />
+2 -1
View File
@@ -22,6 +22,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
egui.workspace = true egui.workspace = true
serde_json.workspace = true serde_json.workspace = true
log.workspace = true log.workspace = true
chrono.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true rfd.workspace = true
@@ -31,6 +32,6 @@ rfd.workspace = true
winresource = "0.1" winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false } muda = { version = "0.21.2", default-features = false }
objc2 = "0.6" objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] } objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
+40 -18
View File
@@ -112,28 +112,36 @@ fn matches(name: &str, target: &str) -> bool {
} }
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch. /// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
/// A clock far past chrono's year range still converts (the calendar repeats every 400 years), so
/// logging never panics and never silently resets to the epoch for a convertible instant.
pub fn timestamp(t: SystemTime) -> String { pub fn timestamp(t: SystemTime) -> String {
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default(); let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
let secs = since.as_secs(); // Pre-1970 is clamped to the epoch by `unwrap_or_default` above. `as_secs` is `u64`; a value
let (days, rem) = (secs / 86_400, secs % 86_400); // past `i64::MAX` seconds saturates rather than falling back to the epoch.
let (y, m, d) = civil_from_days(days); let secs = i64::try_from(since.as_secs()).unwrap_or(i64::MAX);
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis()) let (y, t) = utc_parts(secs, since.subsec_nanos());
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
format!("{y:04}-{}", t.format("%m-%dT%H:%M:%S%.3fZ"))
} }
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's /// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd /// chrono's range by whole 400-year cycles). The calendar repeats every 400 years (146_097 days),
/// clock can't overflow). /// so month, day and time of day come from chrono and only the year is carried, which keeps every
fn civil_from_days(days: u64) -> (u64, u64, u64) { /// `i64` exact. Replaces the old hand-rolled `civil_from_days`; chrono is the only calendar logic.
let z = days.saturating_add(719_468); fn utc_parts(secs: i64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
let era = z / 146_097; use chrono::Datelike;
let doe = z % 146_097; let (days, sod) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
let mp = (5 * doy + 2) / 153; let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
let d = doy - (153 * mp + 2) / 5 + 1; let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
let m = if mp < 10 { mp + 3 } else { mp - 9 }; match (date, time) {
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2)); (Some(d), Some(t)) => {
(y, m, d) let y = i128::from(d.year()) + i128::from(cycles) * 400;
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.and_time(t))
}
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
}
} }
fn numbered(dir: &Path, n: usize) -> PathBuf { fn numbered(dir: &Path, n: usize) -> PathBuf {
@@ -533,6 +541,20 @@ mod tests {
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
// Before the epoch (a clock set wrong) clamps instead of panicking. // Before the epoch (a clock set wrong) clamps instead of panicking.
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z"); assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
// Past chrono's year range still converts via the 400-year cycle (the old code did too,
// through `civil_from_days`): never a silent reset to the epoch. Platform-safe: Windows'
// SystemTime can't hold this instant, so it is only checked where it exists.
// `u64::MAX / 2` seconds == `i64::MAX` seconds → year 292277026596 (old output).
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
}
// Years past 9999 keep the plain zero-padded year (no `+`).
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
// 10_000_000_000_000 s is far beyond chrono's range; old output is year 318857.
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
}
} }
#[test] #[test]
+1 -1
View File
@@ -182,7 +182,7 @@ fn services() -> Services {
if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) { if let Some(dir) = path.parent().filter(|dir| dir.is_dir()) {
d = d.set_directory(dir); d = d.set_directory(dir);
} }
if let Some(n) = path.file_name().and_then(|n| n.to_str()) { if let Some(n) = path.file_name().and_then(std::ffi::OsStr::to_str) {
d = d.set_file_name(n); d = d.set_file_name(n);
} }
d.save_file().map(|p| p.to_string_lossy().into_owned()) d.save_file().map(|p| p.to_string_lossy().into_owned())
+2 -2
View File
@@ -192,7 +192,7 @@ pub enum AuError {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod api { mod api {
use super::*; use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec<AuDescriptor> { pub fn scan() -> Vec<AuDescriptor> {
registry::scan() registry::scan()
@@ -210,7 +210,7 @@ mod api {
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
mod api { mod api {
use super::*; use super::{AuDescriptor, AuError, Plugin, PluginInfo};
pub fn scan() -> Vec<AuDescriptor> { pub fn scan() -> Vec<AuDescriptor> {
Vec::new() Vec::new()
+4
View File
@@ -0,0 +1,4 @@
//! Integration tests, built as one test binary: each file is a module.
#[cfg(target_os = "macos")]
mod system_units;
@@ -1,6 +1,5 @@
//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params → //! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing. //! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
#![cfg(target_os = "macos")]
use soundcraft_dsp::{Category, Plugin}; use soundcraft_dsp::{Category, Plugin};
+204 -98
View File
@@ -3,18 +3,18 @@
use std::io::Cursor; use std::io::Cursor;
use std::panic::{AssertUnwindSafe, catch_unwind}; use std::panic::{AssertUnwindSafe, catch_unwind};
use symphonia::core::audio::SampleBuffer; use symphonia::core::codecs::CodecParameters;
use symphonia::core::codecs::{ use symphonia::core::codecs::audio::well_known::{
CODEC_TYPE_AAC, CODEC_TYPE_ADPCM_G722, CODEC_TYPE_ADPCM_G726, CODEC_TYPE_ADPCM_G726LE, CODEC_TYPE_ADPCM_IMA_QT, CODEC_TYPE_ADPCM_IMA_WAV, CODEC_ID_AAC, CODEC_ID_ADPCM_G722, CODEC_ID_ADPCM_G726, CODEC_ID_ADPCM_G726LE, CODEC_ID_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_WAV, CODEC_ID_ADPCM_MS,
CODEC_TYPE_ADPCM_MS, CODEC_TYPE_ALAC, CODEC_TYPE_FLAC, CODEC_TYPE_MP1, CODEC_TYPE_MP2, CODEC_TYPE_MP3, CODEC_TYPE_NULL, CODEC_TYPE_OPUS, CODEC_ID_ALAC, CODEC_ID_FLAC, CODEC_ID_MP1, CODEC_ID_MP2, CODEC_ID_MP3, CODEC_ID_OPUS, CODEC_ID_PCM_ALAW, CODEC_ID_PCM_F32BE, CODEC_ID_PCM_F32LE,
CODEC_TYPE_PCM_ALAW, CODEC_TYPE_PCM_F32BE, CODEC_TYPE_PCM_F32LE, CODEC_TYPE_PCM_F64BE, CODEC_TYPE_PCM_F64LE, CODEC_TYPE_PCM_MULAW, CODEC_ID_PCM_F64BE, CODEC_ID_PCM_F64LE, CODEC_ID_PCM_MULAW, CODEC_ID_VORBIS,
CODEC_TYPE_VORBIS, CodecParameters, CodecType, DecoderOptions,
}; };
use symphonia::core::codecs::audio::{AudioCodecId, AudioCodecParameters, AudioDecoderOptions, CODEC_ID_NULL_AUDIO};
use symphonia::core::errors::Error as SymError; use symphonia::core::errors::Error as SymError;
use symphonia::core::formats::probe::Hint;
use symphonia::core::formats::{FormatOptions, FormatReader}; use symphonia::core::formats::{FormatOptions, FormatReader};
use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes}; use symphonia::core::io::{MediaSourceStream, MediaSourceStreamOptions, ReadBytes};
use symphonia::core::meta::MetadataOptions; use symphonia::core::meta::MetadataOptions;
use symphonia::core::probe::Instantiate;
use crate::pcm::{MAX_DECODE_SAMPLES, be_u32}; use crate::pcm::{MAX_DECODE_SAMPLES, be_u32};
use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat}; use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat};
@@ -23,6 +23,8 @@ use crate::{AudioBuffer, AudioError, AudioInfo, FileFormat, Result, SampleFormat
/// takes its frame length from the file (encoders use 4096). /// takes its frame length from the file (encoders use 4096).
const MAX_PACKET_FRAMES: u64 = 1 << 20; const MAX_PACKET_FRAMES: u64 = 1 << 20;
/// Turn a symphonia error into the audio layer's error, keeping "unsupported" and "too large"
/// distinct from a malformed file.
fn map_err(e: SymError) -> AudioError { fn map_err(e: SymError) -> AudioError {
match e { match e {
SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()), SymError::Unsupported(m) => AudioError::Unsupported(m.to_string()),
@@ -42,80 +44,164 @@ enum Opened {
Empty(AudioInfo), Empty(AudioInfo),
} }
/// Symphonia's probe loop (`Probe::format`, whose hint 0.5 ignores), checking the container it /// Skip a leading ID3v2 tag before probing.
/// finds before its reader allocates whatever the file claims. ///
fn open(bytes: &[u8]) -> Result<Opened> { /// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims, so the tag is skipped
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default()); /// with [`ReadBytes::ignore_bytes`] (which allocates nothing) instead of being handed to the
let fmt_opts = FormatOptions { enable_gapless: true, ..FormatOptions::default() }; /// metadata reader. Returns the stream position the container starts at.
fn skip_leading_id3(mss: &mut MediaSourceStream<'_>, bytes: &[u8]) -> Result<usize> {
loop { loop {
let found = symphonia::default::get_probe().next(&mut mss).map_err(map_err)?;
let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX); let at = usize::try_from(mss.pos()).unwrap_or(usize::MAX);
let head = bytes.get(at..).unwrap_or_default(); let head = bytes.get(at..).unwrap_or_default();
match (found, head.get(..10)) { if head.len() < 10 || head.get(..3) != Some(b"ID3") {
(Instantiate::Format(reader), _) => { return Ok(at);
match head.get(..4) {
Some(b"caff") => crate::caf::check(bytes, at)?,
Some(b"ftyp") => crate::mp4::check(bytes, at.saturating_sub(4))?,
Some(b"fLaC") => {
crate::flac::check_metadata(bytes, at)?;
if let Some(info) = crate::flac::empty_stream_info(bytes, at) {
return Ok(Opened::Empty(info));
}
}
Some(b"RIFF") => crate::wav::check_info(bytes, at)?,
_ => {}
}
return reader(mss, &fmt_opts).map(Opened::Reader).map_err(map_err);
}
// Tags aren't used, and symphonia allocates whatever an ID3v2 frame claims: skip it.
(Instantiate::Metadata(_), Some([b'I', b'D', b'3', _, _, _, size @ ..])) => {
let size = size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f));
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
}
(Instantiate::Metadata(tag), _) => {
tag(&MetadataOptions::default()).read_all(&mut mss).map_err(map_err)?;
}
} }
let size = head.get(6..10).map(|size| size.iter().fold(0u64, |v, &x| (v << 7) | u64::from(x & 0x7f))).unwrap_or(0);
mss.ignore_bytes(10 + size).map_err(|e| AudioError::Malformed(e.to_string()))?;
} }
} }
fn format_from_codec(codec: CodecType, detected: FileFormat) -> FileFormat { /// How far symphonia's probe scans for a format marker before giving up.
const MAX_PROBE_DEPTH: usize = 1024 * 1024;
/// The container marker each detected format starts with, if its reader needs size checks first.
fn container_markers(detected: FileFormat) -> &'static [&'static [u8; 4]] {
match detected {
// RF64 and BW64 are the >4 GiB RIFF forms.
FileFormat::Wav => &[b"RIFF", b"RF64", b"BW64"],
FileFormat::Flac => &[b"fLaC"],
FileFormat::Caf => &[b"caff"],
// MP4/M4A put `ftyp` after the first atom's size field.
FileFormat::Aac | FileFormat::Alac => &[b"ftyp"],
// Unknown files may carry junk before the container; symphonia's probe scans for markers.
FileFormat::Other => &[b"caff", b"ftyp", b"fLaC", b"RIFF"],
FileFormat::Aiff | FileFormat::Mp3 | FileFormat::Ogg => &[],
}
}
/// The probe short names a container marker stands for.
fn expected_probe_format(marker: &[u8; 4]) -> &'static [&'static str] {
match marker {
b"caff" => &["caf"],
// The isomp4 reader reads the `ftyp` atom.
b"ftyp" => &["isomp4"],
b"fLaC" => &["flac"],
b"RIFF" | b"RF64" | b"BW64" => &["wave"],
_ => &[],
}
}
/// Probe short names whose demuxer allocates from sizes in the file, so the matching container
/// check has to run before the stream is decoded.
const SIZE_CHECKED_PROBE_FORMATS: &[&str] = &["caf", "flac", "isomp4", "wave"];
/// Find the first container marker at or after `at`, within symphonia's probe depth. A RIFF form
/// other than WAVE is skipped, as the probe skips it and reads a WAVE behind it.
fn find_marker<'a>(bytes: &[u8], at: usize, markers: &'a [&'static [u8; 4]]) -> Option<(usize, &'a [u8; 4])> {
let end = bytes.len().min(at.saturating_add(MAX_PROBE_DEPTH));
for pos in at..end.saturating_sub(3) {
let window = bytes.get(pos..pos + 4)?;
if let Some(marker) = markers.iter().find(|marker| window == marker.as_slice()) {
if matches!(*marker, b"RIFF" | b"RF64" | b"BW64") && bytes.get(pos + 8..pos + 12) != Some(&b"WAVE"[..]) {
continue;
}
return Some((pos, *marker));
}
}
None
}
/// Probe for a container, checking the container it finds before its reader allocates whatever the
/// file claims.
///
/// The check has to run on the container the probe actually selects, but the probe reports that
/// container only through the reader it returns. The check therefore runs on the first marker the
/// detected format allows, and the reader is refused if its format is not the checked one: a
/// misleading marker must not let a later container slip through unchecked.
fn open(bytes: &[u8], detected: FileFormat) -> Result<Opened> {
let mut mss = MediaSourceStream::new(Box::new(Cursor::new(bytes.to_vec())), MediaSourceStreamOptions::default());
let at = skip_leading_id3(&mut mss, bytes)?;
let mut checked: Option<&'static [&'static str]> = None;
if let Some((found, marker)) = find_marker(bytes, at, container_markers(detected)) {
match marker {
b"caff" => {
crate::caf::check(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
b"ftyp" => {
crate::mp4::check(bytes, found.saturating_sub(4))?;
checked = Some(expected_probe_format(marker));
}
b"fLaC" => {
crate::flac::check_metadata(bytes, found)?;
if let Some(info) = crate::flac::empty_stream_info(bytes, found) {
return Ok(Opened::Empty(info));
}
checked = Some(expected_probe_format(marker));
}
b"RIFF" | b"RF64" | b"BW64" => {
crate::wav::check_info(bytes, found)?;
checked = Some(expected_probe_format(marker));
}
_ => {}
}
}
let reader = symphonia::default::get_probe().probe(&Hint::new(), mss, FormatOptions::default(), MetadataOptions::default()).map_err(map_err)?;
let probed = reader.format_info().short_name;
// Only containers whose readers allocate from sizes in the file need the check. A stream the
// probe reads with another reader (MP3, Ogg, AIFF, ...) has no such size fields, so a marker
// found in its audio data must not reject it.
if let Some(names) = checked.filter(|_| SIZE_CHECKED_PROBE_FORMATS.contains(&probed)) {
if !names.contains(&probed) {
return Err(AudioError::Malformed(format!("the probed container ({probed}) is not the one whose size checks ran")));
}
} else if checked.is_none() && SIZE_CHECKED_PROBE_FORMATS.contains(&probed) {
return Err(AudioError::Malformed(format!("the probed container ({probed}) was not size-checked")));
}
Ok(Opened::Reader(reader))
}
/// The container `detected` stands for: the detection wins when it knows the file, otherwise the
/// decoded codec names the family.
fn format_from_codec(codec: AudioCodecId, detected: FileFormat) -> FileFormat {
if detected != FileFormat::Other { if detected != FileFormat::Other {
return detected; return detected;
} }
match codec { match codec {
c if c == CODEC_TYPE_FLAC => FileFormat::Flac, c if c == CODEC_ID_FLAC => FileFormat::Flac,
c if c == CODEC_TYPE_MP3 || c == CODEC_TYPE_MP2 || c == CODEC_TYPE_MP1 => FileFormat::Mp3, c if c == CODEC_ID_MP3 || c == CODEC_ID_MP2 || c == CODEC_ID_MP1 => FileFormat::Mp3,
c if c == CODEC_TYPE_AAC => FileFormat::Aac, c if c == CODEC_ID_AAC => FileFormat::Aac,
c if c == CODEC_TYPE_ALAC => FileFormat::Alac, c if c == CODEC_ID_ALAC => FileFormat::Alac,
c if c == CODEC_TYPE_VORBIS => FileFormat::Ogg, c if c == CODEC_ID_VORBIS => FileFormat::Ogg,
_ => FileFormat::Other, _ => FileFormat::Other,
} }
} }
fn sample_format_of(p: &CodecParameters) -> SampleFormat { /// The stored sample format, using the codec and its declared bit depth. Lossy and unusual
/// codecs decode to `Compressed`.
fn sample_format_of(p: &AudioCodecParameters) -> SampleFormat {
let c = p.codec; let c = p.codec;
if c == CODEC_TYPE_PCM_F32LE || c == CODEC_TYPE_PCM_F32BE { if c == CODEC_ID_PCM_F32LE || c == CODEC_ID_PCM_F32BE {
return SampleFormat::Float32; return SampleFormat::Float32;
} }
if c == CODEC_TYPE_PCM_F64LE || c == CODEC_TYPE_PCM_F64BE { if c == CODEC_ID_PCM_F64LE || c == CODEC_ID_PCM_F64BE {
return SampleFormat::Float64; return SampleFormat::Float64;
} }
let lossy = [ let lossy = [
CODEC_TYPE_MP1, CODEC_ID_MP1,
CODEC_TYPE_MP2, CODEC_ID_MP2,
CODEC_TYPE_MP3, CODEC_ID_MP3,
CODEC_TYPE_AAC, CODEC_ID_AAC,
CODEC_TYPE_VORBIS, CODEC_ID_VORBIS,
CODEC_TYPE_OPUS, CODEC_ID_OPUS,
CODEC_TYPE_ADPCM_MS, CODEC_ID_ADPCM_MS,
CODEC_TYPE_ADPCM_IMA_WAV, CODEC_ID_ADPCM_IMA_WAV,
CODEC_TYPE_ADPCM_IMA_QT, CODEC_ID_ADPCM_IMA_QT,
CODEC_TYPE_ADPCM_G722, CODEC_ID_ADPCM_G722,
CODEC_TYPE_ADPCM_G726, CODEC_ID_ADPCM_G726,
CODEC_TYPE_ADPCM_G726LE, CODEC_ID_ADPCM_G726LE,
CODEC_TYPE_PCM_ALAW, CODEC_ID_PCM_ALAW,
CODEC_TYPE_PCM_MULAW, CODEC_ID_PCM_MULAW,
]; ];
let lossless = !lossy.contains(&c); let lossless = !lossy.contains(&c);
match (lossless, p.bits_per_sample) { match (lossless, p.bits_per_sample) {
@@ -127,15 +213,18 @@ fn sample_format_of(p: &CodecParameters) -> SampleFormat {
} }
} }
/// The first track with a decodable audio codec, with the stream information the UI shows.
fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> { fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, AudioInfo)> {
let track = reader let (track, p) = reader
.tracks() .tracks()
.iter() .iter()
.find(|t| t.codec_params.codec != CODEC_TYPE_NULL) .find_map(|t| match t.codec_params.as_ref().and_then(CodecParameters::audio) {
Some(p) if p.codec != CODEC_ID_NULL_AUDIO => Some((t, p)),
_ => None,
})
.ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?; .ok_or_else(|| AudioError::Unsupported("no decodable audio track".into()))?;
let p = &track.codec_params;
let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?; let sample_rate = p.sample_rate.filter(|&r| r > 0).ok_or_else(|| AudioError::Malformed("unknown sample rate".into()))?;
let channels = p.channels.map(|c| c.count()).unwrap_or(0); let channels = p.channels.as_ref().map(|c| c.count()).unwrap_or(0);
let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?; let channels = u16::try_from(channels).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok(( Ok((
track.id, track.id,
@@ -144,15 +233,46 @@ fn info_of(reader: &dyn FormatReader, detected: FileFormat) -> Result<(u32, Audi
sample_format: sample_format_of(p), sample_format: sample_format_of(p),
sample_rate, sample_rate,
channels, channels,
frames: p.n_frames.unwrap_or(0), frames: track.num_frames.unwrap_or(0),
bwf: None, bwf: None,
}, },
)) ))
} }
/// A demux error ends the decode; it is only an error when nothing was decoded yet.
fn demux_failure(frames: u64, e: SymError) -> Result<()> {
if frames == 0 {
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
Ok(())
}
/// Note a corrupt packet (the `errors`-th); a stream that never decodes is malformed.
fn corrupt_packet(errors: usize, frames: u64, msg: &str) -> Result<()> {
log::debug!("skipping corrupt packet: {msg}");
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {msg}")));
}
Ok(())
}
/// The decoded stream is authoritative for the channel count: record it and allocate the planes.
fn init_planar(info: &mut AudioInfo, ch: usize) -> Result<Vec<Vec<f32>>> {
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
Ok((0..ch).map(|_| Vec::new()).collect())
}
/// Append one decoded block to the planes, replacing non-finite samples with silence.
fn append_planar(planar: &mut [Vec<f32>], scratch: &[Vec<f32>]) {
for (dst, src) in planar.iter_mut().zip(scratch) {
dst.extend(src.iter().map(|&s| if s.is_finite() { s } else { 0.0 }));
}
}
/// Decode the whole first audio track. When `count_only`, samples are counted but not stored. /// Decode the whole first audio track. When `count_only`, samples are counted but not stored.
fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> { fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInfo, AudioBuffer)> {
let mut reader = match open(bytes)? { let mut reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader, Opened::Reader(reader) => reader,
Opened::Empty(info) => { Opened::Empty(info) => {
let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0); let buf = AudioBuffer::new(info.sample_rate, usize::from(info.channels), 0);
@@ -160,7 +280,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
} }
}; };
let (track_id, mut info) = info_of(reader.as_ref(), detected)?; let (track_id, mut info) = info_of(reader.as_ref(), detected)?;
if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.codec_params.n_frames) { if let Some(n) = reader.tracks().iter().find(|t| t.id == track_id).and_then(|t| t.num_frames) {
let total = n.saturating_mul(u64::from(info.channels.max(1))); let total = n.saturating_mul(u64::from(info.channels.max(1)));
if !count_only && total > MAX_DECODE_SAMPLES { if !count_only && total > MAX_DECODE_SAMPLES {
return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit"))); return Err(AudioError::TooLarge(format!("{total} samples exceeds the decode limit")));
@@ -170,56 +290,51 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
.tracks() .tracks()
.iter() .iter()
.find(|t| t.id == track_id) .find(|t| t.id == track_id)
.map(|t| t.codec_params.clone()) .and_then(|t| t.codec_params.as_ref())
.and_then(CodecParameters::audio)
.cloned()
.ok_or_else(|| AudioError::Malformed("track vanished".into()))?; .ok_or_else(|| AudioError::Malformed("track vanished".into()))?;
let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_TYPE_ALAC).and_then(|d| be_u32(d, 0)); let alac = params.extra_data.as_deref().filter(|_| params.codec == CODEC_ID_ALAC).and_then(|d| be_u32(d, 0));
if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) { if params.max_frames_per_packet.into_iter().chain(alac.map(u64::from)).any(|n| n > MAX_PACKET_FRAMES) {
return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into())); return Err(AudioError::Malformed("the file declares packets far longer than any codec uses".into()));
} }
let mut decoder = symphonia::default::get_codecs().make(&params, &DecoderOptions::default()).map_err(map_err)?; let mut decoder = symphonia::default::get_codecs().make_audio_decoder(&params, &AudioDecoderOptions::default()).map_err(map_err)?;
let mut planar: Vec<Vec<f32>> = Vec::new(); let mut planar: Vec<Vec<f32>> = Vec::new();
let mut scratch: Vec<Vec<f32>> = Vec::new();
let mut frames: u64 = 0; let mut frames: u64 = 0;
let mut sbuf: Option<SampleBuffer<f32>> = None;
let mut errors = 0usize; let mut errors = 0usize;
loop { loop {
let packet = match reader.next_packet() { let packet = match reader.next_packet() {
Ok(p) => p, Ok(Some(packet)) => packet,
Ok(None) => break,
Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break, Err(SymError::IoError(e)) if e.kind() == std::io::ErrorKind::UnexpectedEof => break,
Err(SymError::ResetRequired) => break, Err(SymError::ResetRequired) => break,
Err(e) => { Err(e) => {
if frames == 0 { demux_failure(frames, e)?;
return Err(map_err(e));
}
log::warn!("stopping decode after demux error: {e}");
break; break;
} }
}; };
if packet.track_id() != track_id { if packet.track_id != track_id {
continue; continue;
} }
let decoded = match decoder.decode(&packet) { let decoded = match decoder.decode(&packet) {
Ok(d) => d, Ok(d) => d,
Err(SymError::DecodeError(m)) => { Err(SymError::DecodeError(m)) => {
errors += 1; errors += 1;
log::debug!("skipping corrupt packet: {m}"); corrupt_packet(errors, frames, m)?;
if errors > 1000 && frames == 0 {
return Err(AudioError::Malformed(format!("no decodable packets: {m}")));
}
continue; continue;
} }
Err(SymError::IoError(_)) => break, Err(SymError::IoError(_)) => break,
Err(e) => return Err(map_err(e)), Err(e) => return Err(map_err(e)),
}; };
let spec = *decoded.spec(); let spec = decoded.spec();
let ch = spec.channels.count(); let ch = spec.channels().count();
if ch == 0 { if ch == 0 {
continue; continue;
} }
if planar.is_empty() { if planar.is_empty() {
// The decoded stream is authoritative for the channel count. planar = init_planar(&mut info, ch)?;
info.channels = u16::try_from(ch).map_err(|_| AudioError::Unsupported("too many channels".into()))?;
planar = (0..ch).map(|_| Vec::new()).collect();
} }
let n = decoded.frames() as u64; let n = decoded.frames() as u64;
frames = frames.saturating_add(n); frames = frames.saturating_add(n);
@@ -229,17 +344,8 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
if count_only { if count_only {
continue; continue;
} }
let needed = decoded.capacity() as u64; decoded.copy_to_vecs_planar::<f32>(&mut scratch);
let sb = match &mut sbuf { append_planar(&mut planar, &scratch);
Some(sb) if sb.capacity() >= decoded.capacity() * ch => sb,
_ => sbuf.insert(SampleBuffer::<f32>::new(needed, spec)),
};
sb.copy_interleaved_ref(decoded);
for frame in sb.samples().chunks_exact(ch) {
for (dst, &s) in planar.iter_mut().zip(frame) {
dst.push(if s.is_finite() { s } else { 0.0 });
}
}
} }
if info.channels == 0 { if info.channels == 0 {
return Err(AudioError::Malformed("stream contains no audio".into())); return Err(AudioError::Malformed("stream contains no audio".into()));
@@ -254,7 +360,7 @@ fn run(bytes: &[u8], detected: FileFormat, count_only: bool) -> Result<(AudioInf
pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> { pub(crate) fn probe(bytes: &[u8], detected: FileFormat) -> Result<AudioInfo> {
guarded(|| { guarded(|| {
let reader = match open(bytes)? { let reader = match open(bytes, detected)? {
Opened::Reader(reader) => reader, Opened::Reader(reader) => reader,
Opened::Empty(info) => return Ok(info), Opened::Empty(info) => return Ok(info),
}; };
+41 -21
View File
@@ -164,6 +164,45 @@ fn only_chunks_follow(b: &[u8], from: usize) -> bool {
false false
} }
/// A `data` chunk header as found in the file, for [`data_chunk_len`].
struct DataChunk {
body: usize,
size32: u32,
is_rf64: bool,
remaining: usize,
declared: usize,
}
/// Bytes of audio a `data` chunk holds: leniently, whatever is present when the size is wrong or
/// the recording was never finalised.
fn data_chunk_len(b: &[u8], c: DataChunk) -> usize {
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64;
let empty = c.size32 == 0 && !c.is_rf64 && finished && only_chunks_follow(b, c.body);
if c.size32 == 0 && !c.is_rf64 && !empty { c.remaining } else { c.declared.min(c.remaining) }
}
/// The sample layout of a `fmt` chunk, `None` for a format we do not read natively.
fn pcm_layout(fmt: &Fmt) -> Result<Option<(PcmKind, SampleFormat)>> {
Ok(Some(match (fmt.tag, fmt.bits) {
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(None),
}))
}
pub(crate) fn parse(b: &[u8]) -> Result<WavParse> { pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
if !is_wav(b) { if !is_wav(b) {
return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into())); return Err(AudioError::Malformed("not a RIFF/RF64 WAVE file".into()));
@@ -195,9 +234,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
// A size of 0 in a file whose RIFF size is right and that has only chunks after it is an // A size of 0 in a file whose RIFF size is right and that has only chunks after it is an
// empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording // empty data chunk followed by metadata (LIST, bext, …); otherwise it is a recording
// that was never finalised, and what follows is audio. // that was never finalised, and what follows is audio.
let finished = u64::from(le_u32(b, 4).unwrap_or(0)) + 8 == b.len() as u64; let len = data_chunk_len(b, DataChunk { body, size32, is_rf64, remaining, declared });
let empty = size32 == 0 && !is_rf64 && finished && only_chunks_follow(b, body);
let len = if size32 == 0 && !is_rf64 && !empty { remaining } else { declared.min(remaining) };
if data.is_none() { if data.is_none() {
data = Some((body, len)); data = Some((body, len));
} }
@@ -232,24 +269,7 @@ pub(crate) fn parse(b: &[u8]) -> Result<WavParse> {
return Err(AudioError::Malformed("WAV declares a zero sample rate".into())); return Err(AudioError::Malformed("WAV declares a zero sample rate".into()));
} }
let channels = usize::from(fmt.channels); let channels = usize::from(fmt.channels);
let (kind, sample_format) = match (fmt.tag, fmt.bits) { let Some((kind, sample_format)) = pcm_layout(&fmt)? else { return Ok(WavParse::Foreign { bwf }) };
(WAVE_FORMAT_PCM, bits @ 1..=32) => {
let bytes = usize::from(bits.div_ceil(8));
let sf = match fmt.valid_bits.min(bits) {
0..=8 => SampleFormat::Int8,
9..=16 => SampleFormat::Int16,
17..=24 => SampleFormat::Int24,
_ => SampleFormat::Int32,
};
(PcmKind::Int { bytes, big_endian: false, unsigned: bytes == 1 }, sf)
}
(WAVE_FORMAT_IEEE_FLOAT, 32) => (PcmKind::Float { bytes: 4, big_endian: false }, SampleFormat::Float32),
(WAVE_FORMAT_IEEE_FLOAT, 64) => (PcmKind::Float { bytes: 8, big_endian: false }, SampleFormat::Float64),
(WAVE_FORMAT_PCM | WAVE_FORMAT_IEEE_FLOAT, bits) => {
return Err(AudioError::Unsupported(format!("WAV format {} with {bits} bits per sample", fmt.tag)));
}
_ => return Ok(WavParse::Foreign { bwf }),
};
let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?; let block = kind.bytes().checked_mul(channels).ok_or_else(|| AudioError::Malformed("block size overflow".into()))?;
if usize::from(fmt.block_align) != block { if usize::from(fmt.block_align) != block {
log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align); log::debug!("WAV block_align {} disagrees with computed {block}; using computed", fmt.block_align);
+7
View File
@@ -0,0 +1,7 @@
//! Integration tests, built as one test binary: each file is a module.
mod encode_format;
mod malformed;
mod roundtrip;
mod stream;
mod wav_empty_data;
@@ -328,6 +328,18 @@ fn wav_info_entries_beyond_the_file_are_refused() {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file"))); assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
} }
#[test]
fn a_wave_behind_another_riff_form_is_checked() {
let info = [&b"INFO"[..], b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
let mut wav = riff(&[(b"fmt ", [fmt(6, 1, 8_000, 8), vec![0, 0]].concat()), (b"LIST", info), (b"data", vec![0xD5; 800])]);
wav[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let list = wav.windows(4).position(|w| w == b"LIST").unwrap();
wav[list + 4..list + 8].copy_from_slice(&0x7fff_fffcu32.to_le_bytes());
// The probe skips the AVI form and reads the WAVE behind it.
let f = [&b"RIFF"[..], &4u32.to_le_bytes(), b"AVI ", &wav].concat();
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than the file")));
}
#[test] #[test]
fn wav_info_entries_are_found_where_symphonia_reads_them() { fn wav_info_entries_are_found_where_symphonia_reads_them() {
let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat(); let list = [&b"LIST"[..], &0x7fff_fffcu32.to_le_bytes(), b"INFO", b"INAM", &0x7fff_fff0u32.to_le_bytes(), b"hi"].concat();
@@ -451,6 +463,20 @@ fn garbage_in_compressed_containers_errors() {
assert!(decode(&[], None).is_err()); assert!(decode(&[], None).is_err());
} }
/// A marker that passes its container check must not let the probe select a different container
/// without its own checks: the reader is refused when the probed format disagrees.
#[test]
fn a_probed_container_that_disagrees_with_the_checked_marker_is_refused() {
// The junk byte hides both magics, so the hint picks the marker: the FLAC is checked, but the
// probe reads the WAVE before it.
let bytes = [vec![0], small(FileFormat::Wav, BitDepth::Int24, 2), small(FileFormat::Flac, BitDepth::Int24, 2)].concat();
match decode(&bytes, Some("flac")) {
Err(AudioError::Malformed(message)) => assert!(message.contains("probed container"), "unexpected message: {message}"),
Err(other) => panic!("unexpected error kind: {other:?}"),
Ok(_) => panic!("the mismatched container must be refused"),
}
}
proptest! { proptest! {
#![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })] #![proptest_config(ProptestConfig { cases: 256, ..ProptestConfig::default() })]
+22 -4
View File
@@ -13,7 +13,7 @@ use serde_json::{Value, json};
use soundcraft_engine::Engine; use soundcraft_engine::Engine;
use soundcraft_engine::file_access::{self, AutomationRoots}; use soundcraft_engine::file_access::{self, AutomationRoots};
use std::io::{BufRead, BufReader, Write}; use std::io::{BufRead, BufReader, Write};
use std::net::TcpStream; use std::net::{TcpStream, ToSocketAddrs};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -97,9 +97,19 @@ impl Remote {
if self.conn.is_some() { if self.conn.is_some() {
return Ok(()); return Ok(());
} }
let sock = self.addr.parse().map_err(|e| format!("{}: {e}", self.addr))?; let socks: Vec<_> = self.addr.to_socket_addrs().map_err(|e| format!("{}: {e}", self.addr))?.collect();
let s = let mut last = "no addresses found".to_string();
TcpStream::connect_timeout(&sock, Duration::from_millis(800)).map_err(|e| format!("cannot reach SoundCraft at {}: {e}", self.addr))?; 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())?; 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())?; let r = s.try_clone().map_err(|e| e.to_string())?;
self.conn = Some((s, BufReader::new(r))); self.conn = Some((s, BufReader::new(r)));
@@ -179,6 +189,14 @@ mod tests {
assert!(r.call("session.inspect", json!({})).is_err()); 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, /// 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`). /// then either drops the connection (`false`) or answers ok (`true`).
fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) { fn fake_app(replies: Vec<bool>) -> (String, Arc<std::sync::atomic::AtomicUsize>) {
@@ -1,7 +1,6 @@
//! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads //! `Headless::with_automation_roots` through the real MCP protocol: every file a command reads
//! stays inside the read root, every file it writes inside the write root. //! stays inside the read root, every file it writes inside the write root.
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md). // 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 serde_json::{Value, json}; use serde_json::{Value, json};
use soundcraft_automation::{Headless, mcp::Server}; use soundcraft_automation::{Headless, mcp::Server};
@@ -58,7 +57,7 @@ fn fixture(name: &str) -> Fixture {
} }
fn server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> { 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)) Server::new(Headless::new(Engine::default()).with_automation_roots(roots))
} }
@@ -187,7 +186,7 @@ fn writes(dir: &Path) -> Vec<(&'static str, Value)> {
} }
fn demo_server(read: Option<&Path>, write: Option<&Path>) -> Server<Headless> { 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)) Server::new(Headless::new(soundcraft_engine::demo::demo_engine()).with_automation_roots(roots))
} }
+5
View File
@@ -0,0 +1,5 @@
//! Integration tests, built as one test binary: each file is a module.
mod agent_tasks;
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod automation_roots;
+1 -1
View File
@@ -23,7 +23,7 @@ harness = false
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
clap-sys = "0.5" clap-sys = "0.5"
libloading = "0.8" libloading = "0.9"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace # The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the # `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
+2 -2
View File
@@ -88,7 +88,7 @@ pub enum ClapError {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
mod api { mod api {
use super::*; use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo, registry};
pub fn cached() -> Option<Vec<ClapDescriptor>> { pub fn cached() -> Option<Vec<ClapDescriptor>> {
registry::cached() registry::cached()
@@ -118,7 +118,7 @@ mod api {
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod api { mod api {
use super::*; use super::{ClapDescriptor, ClapError, Path, PathBuf, Plugin, PluginInfo};
pub fn cached() -> Option<Vec<ClapDescriptor>> { pub fn cached() -> Option<Vec<ClapDescriptor>> {
Some(Vec::new()) Some(Vec::new())
+35 -20
View File
@@ -102,6 +102,35 @@ fn note_mode(p: Option<NotePort>) -> NoteMode {
} }
} }
/// Copy `n` frames from `start` of the host buffers into the plugin's input `port` (mono sources
/// fill every port channel).
fn feed_input(inputs: &mut PortBuffers, port: usize, io: &[Vec<f32>], ch: usize, (start, n): (usize, usize)) {
for c in 0..inputs.channels(port) {
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, inputs.channel_mut(port, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
}
/// Copy `n` frames of the plugin's output `port` into the host buffers from `start`; a mono host
/// buffer takes the average of a stereo port.
fn read_output(outputs: &PortBuffers, port: usize, io: &mut [Vec<f32>], ch: usize, (start, n): (usize, usize)) {
let oc = outputs.channels(port);
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (outputs.channel(port, 0), outputs.channel(port, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = outputs.channel(port, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
}
impl Plugin for ClapPlugin { impl Plugin for ClapPlugin {
fn info(&self) -> &'static PluginInfo { fn info(&self) -> &'static PluginInfo {
self.info self.info
@@ -197,12 +226,7 @@ impl Plugin for ClapPlugin {
let end = u32::try_from(start + n).unwrap_or(u32::MAX); let end = u32::try_from(start + n).unwrap_or(u32::MAX);
// Feed the main input port (mono sources fill every port channel). // Feed the main input port (mono sources fill every port channel).
if let Some(p) = self.in_main { if let Some(p) = self.in_main {
for c in 0..self.inputs.channels(p) { feed_input(&mut self.inputs, p, io, ch, (start, n));
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
dst.copy_from_slice(src);
}
}
} }
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len()); let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
let offset = u32::try_from(start).unwrap_or(u32::MAX); let offset = u32::try_from(start).unwrap_or(u32::MAX);
@@ -212,19 +236,7 @@ impl Plugin for ClapPlugin {
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0)); self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
match (r, self.out_main) { match (r, self.out_main) {
(Ok(_), Some(p)) => { (Ok(_), Some(p)) => {
let oc = self.outputs.channels(p); read_output(&self.outputs, p, io, ch, (start, n));
for (c, dst) in io.iter_mut().take(ch).enumerate() {
let Some(dst) = dst.get_mut(start..start + n) else { continue };
if ch == 1 && oc >= 2 {
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
*d = 0.5 * (a + b);
}
}
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
dst.copy_from_slice(src);
}
}
} }
(Err(e), _) => log::debug!("{}: {e}", self.info.id), (Err(e), _) => log::debug!("{}: {e}", self.info.id),
_ => {} _ => {}
@@ -340,7 +352,6 @@ impl PluginEditor for ClapEditor {
fn idle(&mut self) -> Vec<(String, f32)> { fn idle(&mut self) -> Vec<(String, f32)> {
let raw = self.link.idle(); let raw = self.link.idle();
let _ = self.link.host().dirty.swap(false, Ordering::Relaxed);
let mut out: Vec<(String, f32)> = Vec::new(); let mut out: Vec<(String, f32)> = Vec::new();
for (cid, v) in raw { for (cid, v) in raw {
let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue }; let Some(i) = self.ids.iter().position(|&x| x == cid) else { continue };
@@ -354,4 +365,8 @@ impl PluginEditor for ClapEditor {
} }
out out
} }
fn state_changed(&mut self) -> bool {
self.link.host().dirty.swap(false, Ordering::Relaxed)
}
} }
+1 -1
View File
@@ -51,7 +51,7 @@ pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathB
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive). /// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
pub fn is_clap_path(p: &Path) -> bool { pub fn is_clap_path(p: &Path) -> bool {
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap")) p.extension().and_then(std::ffi::OsStr::to_str).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
} }
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped). /// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
+57 -47
View File
@@ -189,6 +189,61 @@ fn apply(f: &mut Fx, h: &clap_event_header) {
} }
} }
/// Fill every output channel with 1.0 when all ports have the wide layout, else -1.0.
unsafe fn fill_wide(pr: &clap_process, ports: u32, channels: u32, n: usize) {
let fits = |bufs: *const clap_audio_buffer, n: u32| {
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
}
/// Push the host-visible parameter edit and an ignorable note event onto the output queue.
unsafe fn push_gui_edit(pr: &clap_process) {
let e = clap_event_param_value {
header: clap_event_header {
size: std::mem::size_of::<clap_event_param_value>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_PARAM_VALUE,
flags: 0,
},
param_id: 7,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value: 0.25,
};
let out = unsafe { &*pr.out_events };
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
// Other event types are accepted and ignored by the host.
let note = clap_event_note {
header: clap_event_header {
size: std::mem::size_of::<clap_event_note>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_NOTE_END,
flags: 0,
},
note_id: -1,
port_index: 0,
channel: 0,
key: 60,
velocity: 0.0,
};
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &note.header) });
}
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status { unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
let f = unsafe { fx(p) }; let f = unsafe { fx(p) };
let pr = unsafe { &*pr }; let pr = unsafe { &*pr };
@@ -197,19 +252,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
let ev = unsafe { &*pr.in_events }; let ev = unsafe { &*pr.in_events };
let count = unsafe { (ev.size.unwrap())(pr.in_events) }; let count = unsafe { (ev.size.unwrap())(pr.in_events) };
if let Some((ports, channels)) = f.wide { if let Some((ports, channels)) = f.wide {
let fits = |bufs: *const clap_audio_buffer, n: u32| { unsafe { fill_wide(pr, ports, channels, n) };
n == ports && (0..n as usize).all(|i| unsafe { &*bufs.add(i) }.channel_count == channels)
};
let ok = fits(pr.audio_inputs, pr.audio_inputs_count) && fits(pr.audio_outputs, pr.audio_outputs_count);
if pr.audio_outputs_count > 0 {
let out = unsafe { &*pr.audio_outputs };
for c in 0..out.channel_count as usize {
let o = unsafe { *out.data32.add(c) };
for i in 0..n {
unsafe { *o.add(i) = if ok { 1.0 } else { -1.0 } };
}
}
}
return CLAP_PROCESS_CONTINUE; return CLAP_PROCESS_CONTINUE;
} }
if pr.audio_outputs_count != 1 { if pr.audio_outputs_count != 1 {
@@ -230,40 +273,7 @@ unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) ->
if f.gui_edit_pending { if f.gui_edit_pending {
f.gui_edit_pending = false; f.gui_edit_pending = false;
f.gain = 0.25; f.gain = 0.25;
let e = clap_event_param_value { unsafe { push_gui_edit(pr) };
header: clap_event_header {
size: std::mem::size_of::<clap_event_param_value>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_PARAM_VALUE,
flags: 0,
},
param_id: 7,
cookie: std::ptr::null_mut(),
note_id: -1,
port_index: -1,
channel: -1,
key: -1,
value: 0.25,
};
let out = unsafe { &*pr.out_events };
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &e.header) });
// Other event types are accepted and ignored by the host.
let note = clap_event_note {
header: clap_event_header {
size: std::mem::size_of::<clap_event_note>() as u32,
time: 0,
space_id: CLAP_CORE_EVENT_SPACE_ID,
type_: CLAP_EVENT_NOTE_END,
flags: 0,
},
note_id: -1,
port_index: 0,
channel: 0,
key: 60,
velocity: 0.0,
};
assert!(unsafe { (out.try_push.unwrap())(pr.out_events, &note.header) });
} }
let mut next = 0u32; let mut next = 0u32;
let mut last_time = 0u32; let mut last_time = 0u32;
@@ -3,8 +3,7 @@
use soundcraft_dsp::{Category, Unit}; use soundcraft_dsp::{Category, Unit};
mod common; use crate::common::fixture_dir;
use common::fixture_dir;
const GAIN: &str = "clap:org.soundcraft.test.gain"; const GAIN: &str = "clap:org.soundcraft.test.gain";
const SYNTH: &str = "clap:org.soundcraft.test.synth"; const SYNTH: &str = "clap:org.soundcraft.test.synth";
+6
View File
@@ -0,0 +1,6 @@
//! Integration tests, built as one test binary: each file is a module.
#[path = "../common/mod.rs"]
mod common;
mod fixture_e2e;
+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 /// user closed, and returns parameter edits made in the editor as `(param id, value)` in
/// SoundCraft units (ids and ranges of [`PluginInfo::params`]). /// SoundCraft units (ids and ranges of [`PluginInfo::params`]).
fn idle(&mut self) -> Vec<(String, f32)>; 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 /// Tells the editor that a parameter changed elsewhere (generic editor, automation), so the
/// GUI shows it. Values are in SoundCraft units. /// GUI shows it. Values are in SoundCraft units.
fn set_param(&mut self, _id: &str, _value: f32) {} fn set_param(&mut self, _id: &str, _value: f32) {}
+88 -67
View File
@@ -65,19 +65,29 @@ pub fn normalize(ch: &mut [Vec<f32>], target_db: f32, per_channel: bool) {
for c in ch.iter_mut() { for c in ch.iter_mut() {
let p = peak(std::slice::from_ref(c)); let p = peak(std::slice::from_ref(c));
if p > 0.0 { if p > 0.0 {
let g = target / p; scale_to(c.iter_mut(), target, p);
c.iter_mut().for_each(|s| *s *= g);
} }
} }
} else { } else {
let p = peak(ch); let p = peak(ch);
if p > 0.0 { if p > 0.0 {
let g = target / p; scale_to(ch.iter_mut().flatten(), target, p);
ch.iter_mut().flatten().for_each(|s| *s *= g);
} }
} }
} }
/// Scales samples whose peak is `p` to `target`. A subnormal `p` makes the f32 gain overflow to
/// infinity, so that case scales in f64.
fn scale_to<'a>(samples: impl Iterator<Item = &'a mut f32>, target: f32, p: f32) {
let g = target / p;
if g.is_finite() {
samples.for_each(|s| *s *= g);
} else {
let g = f64::from(target) / f64::from(p);
samples.for_each(|s| *s = (f64::from(*s) * g) as f32);
}
}
/// Applies a gain change in dB. /// Applies a gain change in dB.
pub fn gain(ch: &mut [Vec<f32>], db: f32) { pub fn gain(ch: &mut [Vec<f32>], db: f32) {
let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) }); let g = db_to_gain(if db.is_nan() { 0.0 } else { db.min(96.0) });
@@ -233,6 +243,77 @@ pub fn resample(ch: &[Vec<f32>], from: u32, to: u32) -> Vec<Vec<f32>> {
// --------------------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------------------
// Time stretch (WSOLA) and pitch shift // Time stretch (WSOLA) and pitch shift
/// Start position in the guide signal whose `hop` samples best match the natural continuation at
/// `natural`, searched within `tol` of `nominal` (coarse stride 4, then refined +-3).
fn best_start(g: &impl Fn(i64) -> f32, nominal: i64, natural: i64, tol: i64, hop: usize) -> i64 {
// Similarity of candidate start `p` with the natural continuation (stride 2).
let score = |p: i64| -> f32 {
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
let mut i = 0i64;
while i < hop as i64 {
let a = g(natural + i);
let b = g(p + i);
xy += a * b;
yy += b * b;
i += 2;
}
xy / yy.sqrt()
};
let lo = (nominal - tol).max(0);
let hi = nominal + tol;
let mut best = nominal.max(0);
let mut best_s = f32::NEG_INFINITY;
let mut p = lo;
while p <= hi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
p += 4;
}
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
for p in rlo..=rhi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
}
best
}
/// Add one windowed frame of every channel, read from `at.0`, into `out` at `at.1`, and the window
/// into `wsum`.
fn overlap_add(ch: &[Vec<f32>], out: &mut [Vec<f32>], wsum: &mut [f32], window: &[f32], at: (usize, usize)) {
let (in_pos, out_pos) = at;
for (c, o) in ch.iter().zip(out.iter_mut()) {
for (i, w) in window.iter().enumerate() {
let v = c.get(in_pos + i).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(out_pos + i) {
*d += v * w;
}
}
}
for (i, w) in window.iter().enumerate() {
if let Some(d) = wsum.get_mut(out_pos + i) {
*d += w;
}
}
}
/// Divide out the accumulated window weights and cut each channel to `out_len`.
fn normalise(out: &mut [Vec<f32>], wsum: &[f32], out_len: usize) {
for o in out.iter_mut() {
for (s, w) in o.iter_mut().zip(wsum) {
if *w > 1e-3 {
*s /= w;
}
}
o.truncate(out_len);
}
}
/// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add). /// Pitch-preserving time stretch by WSOLA (waveform-similarity overlap-add).
/// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same /// `ratio` = output length / input length, clamped to 0.1 ..= 10. All channels share the same
/// segment choices so the stereo image stays coherent. /// segment choices so the stereo image stays coherent.
@@ -265,72 +346,12 @@ pub fn time_stretch(ch: &[Vec<f32>], ratio: f64, sample_rate: f32) -> Vec<Vec<f3
if out_pos >= out_len { if out_pos >= out_len {
break; break;
} }
let in_pos = if k == 0 { let in_pos = if k == 0 { 0 } else { best_start(&g, (out_pos as f64 / ratio).round() as i64, prev_in + hop as i64, tol, hop) };
0 overlap_add(ch, &mut out, &mut wsum, &window, (in_pos as usize, out_pos));
} else {
let nominal = (out_pos as f64 / ratio).round() as i64;
let natural = prev_in + hop as i64;
// Similarity of candidate start `p` with the natural continuation (stride 2).
let score = |p: i64| -> f32 {
let (mut xy, mut yy) = (0.0f32, 1e-9f32);
let mut i = 0i64;
while i < hop as i64 {
let a = g(natural + i);
let b = g(p + i);
xy += a * b;
yy += b * b;
i += 2;
}
xy / yy.sqrt()
};
let lo = (nominal - tol).max(0);
let hi = nominal + tol;
let mut best = nominal.max(0);
let mut best_s = f32::NEG_INFINITY;
let mut p = lo;
while p <= hi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
p += 4;
}
let (rlo, rhi) = ((best - 3).max(lo), (best + 3).min(hi));
for p in rlo..=rhi {
let s = score(p);
if s > best_s {
best_s = s;
best = p;
}
}
best
};
for (c, o) in ch.iter().zip(out.iter_mut()) {
for (i, w) in window.iter().enumerate() {
let src = in_pos as usize + i;
let v = c.get(src).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(out_pos + i) {
*d += v * w;
}
}
}
for (i, w) in window.iter().enumerate() {
if let Some(d) = wsum.get_mut(out_pos + i) {
*d += w;
}
}
prev_in = in_pos; prev_in = in_pos;
k += 1; k += 1;
} }
for o in out.iter_mut() { normalise(&mut out, &wsum, out_len);
for (s, w) in o.iter_mut().zip(&wsum) {
if *w > 1e-3 {
*s /= w;
}
}
o.truncate(out_len);
}
out out
} }
+60 -55
View File
@@ -135,6 +135,65 @@ impl Default for DrumSynth {
} }
} }
/// One output sample of a sounding voice of `kind`, advancing its envelopes.
fn voice_sample(v: &mut DrumVoice, kind: Kind, sr: f32, clap_gap: usize) -> f32 {
match kind {
Kind::Kick | Kind::Tom(_) => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp;
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + click
}
Kind::Snare => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + n
}
Kind::Clap => {
// Three quick bursts then a diffuse tail.
let burst = if v.t < clap_gap * 3 {
if v.t.is_multiple_of(clap_gap) {
v.amp = 1.0;
}
v.amp
} else {
0.0
};
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
v.amp *= v.amp_coef;
if v.t >= clap_gap * 2 {
v.noise_amp *= v.noise_coef;
}
y
}
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
let mut metal = 0.0;
for (m, f) in v.metal.iter_mut().zip(METAL) {
*m += f * scale / sr;
*m -= m.floor();
metal += if *m < 0.5 { 1.0 } else { -1.0 };
}
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
v.amp *= v.amp_coef;
y
}
}
}
impl DrumSynth { impl DrumSynth {
pub fn new() -> Self { pub fn new() -> Self {
let mut voices = [DrumVoice::new(1); VOICES]; let mut voices = [DrumVoice::new(1); VOICES];
@@ -246,61 +305,7 @@ impl DrumSynth {
let mut out = 0.0; let mut out = 0.0;
for v in &mut self.voices { for v in &mut self.voices {
let Some(kind) = v.kind else { continue }; let Some(kind) = v.kind else { continue };
let y = match kind { let y = voice_sample(v, kind, sr, clap_gap);
Kind::Kick | Kind::Tom(_) => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp;
let click = v.filt.process_sample(v.rng.bipolar()) * v.noise_amp;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + click
}
Kind::Snare => {
v.pitch_env *= v.pitch_coef;
let f = v.f_end + (v.f_start - v.f_end) * v.pitch_env;
v.phase += f / sr;
v.phase -= v.phase.floor();
let tone = (v.phase * TAU).sin() * v.amp * 0.6;
let n = v.filt2.process_sample(v.filt.process_sample(v.rng.bipolar())) * v.noise_amp * 0.7;
v.amp *= v.amp_coef;
v.noise_amp *= v.noise_coef;
tone + n
}
Kind::Clap => {
// Three quick bursts then a diffuse tail.
let burst = if v.t < clap_gap * 3 {
if v.t % clap_gap == 0 {
v.amp = 1.0;
}
v.amp
} else {
0.0
};
let env = burst.max(if v.t >= clap_gap * 2 { v.noise_amp * 0.6 } else { 0.0 });
let y = v.filt.process_sample(v.rng.bipolar()) * env * 2.0;
v.amp *= v.amp_coef;
if v.t >= clap_gap * 2 {
v.noise_amp *= v.noise_coef;
}
y
}
Kind::ClosedHat | Kind::OpenHat | Kind::Crash | Kind::Ride => {
let scale = if matches!(kind, Kind::Crash | Kind::Ride) { 1.0 } else { 1.9 };
let mut metal = 0.0;
for (m, f) in v.metal.iter_mut().zip(METAL) {
*m += f * scale / sr;
*m -= m.floor();
metal += if *m < 0.5 { 1.0 } else { -1.0 };
}
let raw = metal / 6.0 * (1.0 - v.noise_amp * 0.5) + v.rng.bipolar() * v.noise_amp;
let y = v.filt2.process_sample(v.filt.process_sample(raw)) * v.amp;
v.amp *= v.amp_coef;
y
}
};
v.t += 1; v.t += 1;
let alive = match kind { let alive = match kind {
Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF, Kind::Clap => v.t < clap_gap * 3 || v.noise_amp > OFF,
+27 -15
View File
@@ -201,6 +201,31 @@ impl Reverb {
} }
} }
/// Input sample `n` of the left and right channels (a mono input feeds both); non-finite reads as 0.
fn input_pair(io: &[Vec<f32>], ch: usize, n: usize) -> (f32, f32) {
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0);
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
(xl, xr)
}
/// Blend the wet signal `[mid, left, right]` into sample `n` of the first `ch` channels at mix `m`.
fn mix_out(io: &mut [Vec<f32>], ch: usize, n: usize, wet: [f32; 3], m: f32) {
let [mid, wl, wr] = wet;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let wet = if ch == 1 {
mid
} else if c % 2 == 0 {
wl
} else {
wr
};
if let Some(x) = b.get_mut(n) {
let dry = if x.is_finite() { *x } else { 0.0 };
*x = dry * (1.0 - m) + wet * m;
}
}
}
impl Plugin for Reverb { impl Plugin for Reverb {
fn info(&self) -> &'static PluginInfo { fn info(&self) -> &'static PluginInfo {
self.info self.info
@@ -279,8 +304,7 @@ impl Plugin for Reverb {
let size = self.size.value(); let size = self.size.value();
let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr)); let base: [f32; LINES] = self.cfg.delays_ms.map(|ms| ms_to_samples(ms * size, sr));
for n in 0..frames { for n in 0..frames {
let xl = io.first().and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0); let (xl, xr) = input_pair(io, ch, n);
let xr = if ch > 1 { io.get(1).and_then(|b| b.get(n)).copied().filter(|v| v.is_finite()).unwrap_or(0.0) } else { xl };
let pre = ms_to_samples(self.pre_ms.next(), sr); let pre = ms_to_samples(self.pre_ms.next(), sr);
let mut ins = [xl, xr]; let mut ins = [xl, xr];
for (side, x) in ins.iter_mut().enumerate() { for (side, x) in ins.iter_mut().enumerate() {
@@ -319,19 +343,7 @@ impl Plugin for Reverb {
let side = 0.5 * (wl - wr) * w; let side = 0.5 * (wl - wr) * w;
let (wl, wr) = (mid + side, mid - side); let (wl, wr) = (mid + side, mid - side);
let m = self.mix.next(); let m = self.mix.next();
for (c, b) in io.iter_mut().take(ch).enumerate() { mix_out(io, ch, n, [mid, wl, wr], m);
let wet = if ch == 1 {
mid
} else if c % 2 == 0 {
wl
} else {
wr
};
if let Some(x) = b.get_mut(n) {
let dry = if x.is_finite() { *x } else { 0.0 };
*x = dry * (1.0 - m) + wet * m;
}
}
} }
} }
+7
View File
@@ -0,0 +1,7 @@
//! Integration tests, built as one test binary: each file is a module.
mod fuzz;
mod offline_meters;
mod offline_normalize;
mod plugins;
mod transients;
+19
View File
@@ -0,0 +1,19 @@
use soundcraft_dsp::offline::{normalize, peak};
#[test]
fn normalize_subnormal_peak_stays_finite() {
for per_channel in [false, true] {
let mut c = vec![vec![1e-44f32, -1e-44]];
normalize(&mut c, 0.0, per_channel);
assert!(c[0].iter().all(|s| s.is_finite()), "{c:?}");
assert!((peak(&c) - 1.0).abs() < 1e-3, "{c:?}");
}
}
#[test]
fn normalize_silent_and_normal_channels() {
let mut c = vec![vec![0.0f32; 4], vec![0.25, -0.5]];
normalize(&mut c, 0.0, true);
assert_eq!(c[0], vec![0.0; 4]);
assert!((peak(&c[1..]) - 1.0).abs() < 1e-5);
}
+103 -6
View File
@@ -176,11 +176,29 @@ struct Autosave {
} }
impl Autosave { impl Autosave {
/// A new folder under `root` for the session called `name`. /// A new folder under `root` for the session called `name`. Two sessions started within the
/// same millisecond get folders of their own, never a shared one.
fn create(root: &Path, name: &str) -> Result<Autosave> { fn create(root: &Path, name: &str) -> Result<Autosave> {
let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis(); let stamp = SystemTime::now().duration_since(SystemTime::UNIX_EPOCH).unwrap_or_default().as_millis();
let dir = root.join(format!("{} {stamp}-{}", sanitize_name(name), std::process::id())); let base = format!("{} {stamp}-{}", sanitize_name(name), std::process::id());
let lock = std::fs::create_dir_all(&dir).and_then(|()| std::fs::File::create(dir.join(LOCK))).map_err(|err| io_error(&dir, err))?; create_folder(root).map_err(|err| io_error(root, err))?;
let mut dir = root.join(&base);
let mut n = 1;
loop {
// 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;
dir = root.join(format!("{base}-{n}"));
}
Err(err) => return Err(io_error(&dir, err)),
}
}
let lock = std::fs::File::create(dir.join(LOCK)).map_err(|err| io_error(&dir, err))?;
lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?; lock.try_lock().map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false }) Ok(Autosave { dir, lock: Some(lock), copies: BTreeMap::new(), next: 1, recording: false })
} }
@@ -210,9 +228,7 @@ impl Autosave {
self.next += 1; self.next += 1;
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Float32, dither: false, bwf: None }; 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()))?; let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::create_dir_all(&audio_dir) create_folder(&audio_dir).and_then(|()| write_synced(&audio_dir.join(&name), &bytes)).map_err(|err| io_error(&audio_dir, err))?;
.and_then(|()| write_synced(&audio_dir.join(&name), &bytes))
.map_err(|err| io_error(&audio_dir, err))?;
name name
} }
}; };
@@ -395,6 +411,18 @@ pub fn discard(left: &Leftover) {
let _ = std::fs::remove_dir_all(&left.dir); 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<()> { fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let mut f = std::fs::File::create(path)?; let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?; f.write_all(bytes)?;
@@ -427,6 +455,14 @@ mod tests {
dir dir
} }
/// Moves a saved session file's mtime a second into the past. Filesystems with coarse
/// timestamps (tmpfs) can give it and an autosave written right after it the same mtime,
/// and `leftovers` only offers autosaves strictly newer than the file they came from.
fn backdate(path: &Path) {
let then = SystemTime::now() - Duration::from_secs(1);
std::fs::File::options().write(true).open(path).unwrap().set_modified(then).unwrap();
}
fn record(e: &mut Engine, level: f32) { fn record(e: &mut Engine, level: f32) {
let before = e.session_arc(); let before = e.session_arc();
let s = e.session_mut(); let s = e.session_mut();
@@ -453,6 +489,7 @@ mod tests {
let mut e = Engine::default(); let mut e = Engine::default();
record(&mut e, 0.5); record(&mut e, 0.5);
crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap(); crate::io::save_session(&mut e, &path.to_string_lossy()).unwrap();
backdate(&path);
record(&mut e, -0.25); record(&mut e, -0.25);
let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap(); let mut a = Autosave::create(&dir.join("Autosave"), "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap(); a.write(e.session(), e.path.as_deref(), None).unwrap();
@@ -511,6 +548,50 @@ mod tests {
let _ = std::fs::remove_dir_all(&root); 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] #[test]
fn a_take_still_recording_at_a_crash_is_placed_on_recovery() { fn a_take_still_recording_at_a_crash_is_placed_on_recovery() {
use crate::io::Take; use crate::io::Take;
@@ -590,6 +671,21 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn sessions_started_together_get_folders_of_their_own() {
// #579: the folder name has millisecond resolution, so two sessions created back to back
// shared one folder and the second overwrote the first's unsaved work.
let dir = folder("autosave-unique");
let root = dir.join("Autosave");
let made: Vec<Autosave> = (0..20).map(|_| Autosave::create(&root, "Song").unwrap()).collect();
let mut dirs: Vec<&PathBuf> = made.iter().map(|a| &a.dir).collect();
dirs.sort();
dirs.dedup();
assert_eq!(dirs.len(), made.len());
drop(made);
let _ = std::fs::remove_dir_all(&dir);
}
#[test] #[test]
fn unsaved_work_is_offered_once_its_session_closes() { fn unsaved_work_is_offered_once_its_session_closes() {
let dir = folder("autosave-leftovers"); let dir = folder("autosave-leftovers");
@@ -598,6 +694,7 @@ mod tests {
std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(&dir).unwrap();
let mut e = Engine::default(); let mut e = Engine::default();
crate::io::save_session(&mut e, &path).unwrap(); crate::io::save_session(&mut e, &path).unwrap();
backdate(Path::new(&path));
record(&mut e, 0.5); record(&mut e, 0.5);
let mut a = Autosave::create(&root, "Song").unwrap(); let mut a = Autosave::create(&root, "Song").unwrap();
a.write(e.session(), e.path.as_deref(), None).unwrap(); a.write(e.session(), e.path.as_deref(), None).unwrap();
+30 -20
View File
@@ -50,7 +50,7 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
}; };
let n = clips.len(); let n = clips.len();
lanes.sort_by_key(|(i, _)| *i); lanes.sort_by_key(|(i, _)| *i);
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("Clip Group").to_string(); let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("Clip Group").to_string();
let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem)); let audio_dir_name = format!("{} Audio", crate::io::sanitize_name(&stem));
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default(); let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let mut audio = BTreeMap::new(); let mut audio = BTreeMap::new();
@@ -80,26 +80,17 @@ pub fn export(e: &Engine, ids: &[ClipId], path: &str) -> Result<usize> {
Ok(n) Ok(n)
} }
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks fn decode_group_audio(
/// are made when the session runs out). Returns the ids of the placed clips. audio_entries: &BTreeMap<u64, String>,
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> { dir: &Path,
let id = "file.import_clip_groups"; rate: u32,
let text = crate::io::read_text(path)?; cmd_id: &str,
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?; ) -> Result<(Vec<(String, soundcraft_audio_io::AudioBuffer)>, BTreeMap<u64, usize>)> {
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new()); let (mut decoded, mut index, mut file_of) = (Vec::new(), BTreeMap::new(), BTreeMap::new());
let rate = e.session().sample_rate.hz(); for (old, rel) in audio_entries {
for (old, rel) in &file.audio {
let full = dir.join(rel); let full = dir.join(rel);
if !full.starts_with(&dir) || rel.contains("..") { if !full.starts_with(dir) || rel.contains("..") {
return Err(EngineError::BadParams(id.into(), format!("audio path escapes the group folder: {rel}"))); return Err(EngineError::BadParams(cmd_id.into(), format!("audio path escapes the group folder: {rel}")));
} }
let key = crate::io::file_key(&full); let key = crate::io::file_key(&full);
let n = match index.get(&key) { let n = match index.get(&key) {
@@ -116,6 +107,25 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
}; };
file_of.insert(*old, n); file_of.insert(*old, n);
} }
Ok((decoded, file_of))
}
/// Import a clip group file at `at`, one lane per track starting at `first` (new audio tracks
/// are made when the session runs out). Returns the ids of the placed clips.
pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -> Result<Vec<ClipId>> {
let id = "file.import_clip_groups";
let text = crate::io::read_text(path)?;
let file: GroupFile = serde_json::from_str(&text).map_err(|err| EngineError::BadParams(id.into(), format!("not a clip group file: {err}")))?;
if file.format != FORMAT {
return Err(EngineError::BadParams(id.into(), "not a SoundCraft clip group file".into()));
}
if file.lanes.iter().map(|l| l.len().max(1)).sum::<usize>() > MAX_CLIPS || file.audio.len() > MAX_CLIPS {
return Err(EngineError::BadParams(id.into(), "clip group file has too many clips".into()));
}
let dir = Path::new(path).parent().map(Path::to_path_buf).unwrap_or_default();
// Decode audio first so a bad file changes nothing, and each file once however many ids name it.
let rate = e.session().sample_rate.hz();
let (decoded, file_of) = decode_group_audio(&file.audio, &dir, rate, id)?;
let src_rate = f64::from(file.sample_rate.max(1)); let src_rate = f64::from(file.sample_rate.max(1));
let s = e.session_mut(); let s = e.session_mut();
let ratio = f64::from(s.sample_rate.hz()) / src_rate; let ratio = f64::from(s.sample_rate.hz()) / src_rate;
@@ -123,7 +133,7 @@ pub fn import(e: &mut Engine, path: &str, first: Option<TrackId>, at: Samples) -
let sources: Vec<SourceId> = decoded let sources: Vec<SourceId> = decoded
.into_iter() .into_iter()
.map(|(rel, buf)| { .map(|(rel, buf)| {
let name = Path::new(&rel).file_name().and_then(|x| x.to_str()).unwrap_or("clip group").to_string(); let name = Path::new(&rel).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("clip group").to_string();
crate::io::add_source(s, &name, buf, None, FileFormat::Wav) crate::io::add_source(s, &name, buf, None, FileFormat::Wav)
}) })
.collect(); .collect();
+3 -3
View File
@@ -1,9 +1,9 @@
//! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the //! Edit › Copy Special › Audio as MIDI: pitch-track the selected audio into MIDI notes on the
//! clipboard (paste them onto a MIDI or instrument track). //! clipboard (paste them onto a MIDI or instrument track).
use super::{CommandSpec, bad, has_range, range_param, tracks_required};
use super::*; use crate::{Clipboard, Engine, Result, cmd};
use crate::{Clipboard, cmd}; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::{Note, Sequence}; use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipId}; use soundcraft_model::{Clip, ClipId};
+52 -4
View File
@@ -1,8 +1,9 @@
//! AudioSuite-style offline processing of selected clips. //! AudioSuite-style offline processing of selected clips.
use super::{CommandSpec, bad, clip_ids_param, has_selection, range_param, str_param, tracks_param};
use soundcraft_time::Range;
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat}; use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, TrackKind}; use soundcraft_model::{Clip, ClipContent, TrackKind};
@@ -139,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> { fn generate(e: &mut Engine, p: &Value, name: &str, params: &[(String, f32)], r: Range) -> Result<Value> {
let sr = e.session().sample_rate; let sr = e.session().sample_rate;
let tracks = tracks_param(e, "audiosuite.process", p)?; 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. // 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) { 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(); let mut clips = Vec::new();
for track in tracks { for track in tracks {
@@ -256,4 +265,43 @@ mod tests {
let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect(); let ranges: Vec<_> = e.session().tracks[0].clips().iter().map(|c| (c.start, c.length)).collect();
assert_eq!(ranges, vec![(0, 32), (32, 64)]); 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:?}");
}
} }
+2 -2
View File
@@ -1,9 +1,9 @@
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer //! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
//! commands. Hosting itself lives in `soundcraft-clap-host`. //! commands. Hosting itself lives in `soundcraft-clap-host`.
use super::{CommandSpec, always};
use super::*;
use crate::cmd; use crate::cmd;
use serde_json::json; use serde_json::{Value, json};
use soundcraft_dsp::PluginInfo; use soundcraft_dsp::PluginInfo;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+5 -3
View File
@@ -1,8 +1,10 @@
//! Clip menu commands. //! Clip menu commands.
use super::{CommandSpec, always, bad, clip_ids_param, f64_or, has_range, has_selection, i64_or, position_param, str_param, track_param};
use soundcraft_model::ClipId;
use soundcraft_time::Samples;
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+4 -4
View File
@@ -1,9 +1,9 @@
//! Clip List commands. //! Clip List commands.
use super::{CommandSpec, bad, bool_or};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{ClipId, SourceId};
use soundcraft_model::SourceId;
use std::collections::BTreeSet; use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear"; const CLEAR: &str = "clip.clear";
+3 -3
View File
@@ -1,10 +1,10 @@
//! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and //! More Clip menu commands: Regroup, Unloop, Clip Effects bypass/render, Conform to Tempo and
//! Remove Pitch Shift. //! Remove Pitch Shift.
use super::{CommandSpec, bad, clip_ids_param, has_selection, str_param};
use super::more_util::{render_stretched, replace_clip}; use super::more_util::{render_stretched, replace_clip};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId}; use soundcraft_model::{Clip, ClipContent, ClipId, Session, TrackId};
use soundcraft_time::Samples; use soundcraft_time::Samples;
+42 -29
View File
@@ -1,11 +1,15 @@
//! Edit menu commands. //! Edit menu commands.
use super::{
CommandSpec, always, bad, bool_or, can_redo, can_undo, clip_id, clip_ids_param, f32_or, f64_or, has_clipboard, has_range, has_selection,
has_tracks, i64_or, position_param, range_param, str_param, track_param, tracks_param, tracks_required,
};
use soundcraft_time::Samples;
use super::more_util::toggle_flag; use super::more_util::toggle_flag;
use super::*;
use crate::edit as ops; use crate::edit as ops;
use crate::{Clipboard, cmd}; use crate::{Clipboard, Engine, Result, cmd};
use serde_json::json; use serde_json::{Value, json};
use soundcraft_model::{EditMode, Fade, FadeShape, Session}; use soundcraft_model::{ClipId, EditMode, Fade, FadeShape, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
@@ -448,6 +452,38 @@ fn select_all(e: &mut Engine, _: &Value) -> Result<Value> {
Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end})) Ok(json!({"tracks": s.edit.selected_tracks.len(), "end": end}))
} }
/// Adds the members of every active edit group that touches `t`, then puts `t` in track order.
fn add_group_members(s: &Session, t: &mut Vec<TrackId>) {
let extra: Vec<TrackId> =
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
for m in extra {
if !t.contains(&m) {
t.push(m);
}
}
let order: Vec<TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
/// Selecting clips also selects their range and tracks (Grabber click).
fn select_clip_ids(s: &Session, ed: &mut soundcraft_model::EditState, c: Vec<ClipId>) {
let mut range: Option<soundcraft_time::Range> = None;
let mut trs = Vec::new();
for id in &c {
if let Some((tid, clip)) = s.find_clip(*id) {
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
if !trs.contains(&tid) {
trs.push(tid);
}
}
}
if let Some(r) = range {
ed.selection = r;
ed.selected_tracks = trs;
}
ed.selected_clips = c;
}
fn select(e: &mut Engine, p: &Value) -> Result<Value> { fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None }; let tracks = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "edit.select", p)?) } else { None };
let st = position_param(e, "edit.select", p, "start")?; let st = position_param(e, "edit.select", p, "start")?;
@@ -463,15 +499,7 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
if let Some(mut t) = tracks { if let Some(mut t) = tracks {
// Active edit groups select all their members together. // Active edit groups select all their members together.
if !exact { if !exact {
let extra: Vec<soundcraft_model::TrackId> = add_group_members(s, &mut t);
s.groups.iter().filter(|g| g.active && g.edit && g.members.iter().any(|m| t.contains(m))).flat_map(|g| g.members.clone()).collect();
for m in extra {
if !t.contains(&m) {
t.push(m);
}
}
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
} }
ed.selected_tracks = t; ed.selected_tracks = t;
} }
@@ -481,22 +509,7 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
_ => {} _ => {}
} }
if let Some(c) = clips { if let Some(c) = clips {
// Selecting clips also selects their range and tracks (Grabber click). select_clip_ids(s, &mut ed, c);
let mut range: Option<soundcraft_time::Range> = None;
let mut trs = Vec::new();
for id in &c {
if let Some((tid, clip)) = s.find_clip(*id) {
range = Some(range.map_or(clip.range(), |r| soundcraft_time::Range::new(r.start.min(clip.start), r.end.max(clip.end()))));
if !trs.contains(&tid) {
trs.push(tid);
}
}
}
if let Some(r) = range {
ed.selection = r;
ed.selected_tracks = trs;
}
ed.selected_clips = c;
} else if list_changes { } else if list_changes {
ed.selected_clips.clear(); ed.selected_clips.clear();
} }
+58 -46
View File
@@ -1,11 +1,13 @@
//! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers, //! More Edit menu commands: Cut/Copy/Clear Special variants, Paste Special, selection helpers,
//! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide. //! Space Clips, Snap to, Copy/Move Selection to playlists, TCE, and automation coalesce/trim/glide.
use super::{
CommandSpec, always, bad, clip_ids_param, has_clipboard, has_range, has_selection, position_param, range_param, str_param, tracks_required,
};
use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist}; use super::more_util::{clip_db, render_stretched, replace_clip, set_clip_env, target_playlist};
use super::*;
use crate::edit as ops; use crate::edit as ops;
use crate::{Clipboard, TransportRequest, cmd}; use crate::{Clipboard, Engine, Result, TransportRequest, cmd};
use serde_json::json; use serde_json::{Value, json};
use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session}; use soundcraft_model::{AutoParam, AutomationLane, Clip, ClipContent, ClipId, EditMode, Playlist, Session};
use soundcraft_time::{Range, Samples}; use soundcraft_time::{Range, Samples};
@@ -854,6 +856,57 @@ fn coalesce_clip_gain_to_volume(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"clips": n})) Ok(json!({"clips": n}))
} }
/// The automation parameters a trim/glide over a range touches on `tr`.
fn trim_params(tr: &soundcraft_model::Track, pan_only: bool) -> Vec<AutoParam> {
let mut params: Vec<AutoParam> = tr
.automation
.iter()
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
.map(|l| l.param.clone())
.collect();
if pan_only {
for i in 0..tr.mixer.pan.len().min(2) {
let pp = AutoParam::Pan(i as u8);
if !params.contains(&pp) {
params.push(pp);
}
}
}
params
}
/// Trim (shift the points inside `r` by the change at its end) or glide (ramp across `r`) one lane.
fn trim_lane(tr: &mut soundcraft_model::Track, param: &AutoParam, r: Range, glide: bool) {
let cur = super::mix::current_value(tr, param);
let (lo, hi, _) = param.range();
let l = tr.lane_mut(param);
let before = l.value_at(r.start - 1, cur);
let after = l.value_at(r.end, cur);
let from = l.value_at(r.start, cur);
if glide {
l.clear_range(r.start, r.end.saturating_add(2));
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end, cur);
l.set_point(r.end.saturating_add(1), after);
} else {
let delta = cur - from;
let last = l.value_at(r.end - 1, cur);
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end - 1, last);
l.set_point(r.end, after);
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
pt.value = (pt.value + delta).clamp(lo, hi);
}
}
}
fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result<Value> { fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str) -> Result<Value> {
let tracks = tracks_required(e, id, p)?; let tracks = tracks_required(e, id, p)?;
let r = nonempty_range(e, p, id)?; let r = nonempty_range(e, p, id)?;
@@ -861,49 +914,8 @@ fn trim_glide(e: &mut Engine, p: &Value, glide: bool, pan_only: bool, id: &str)
let mut lanes = 0; let mut lanes = 0;
for t in &tracks { for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue }; let Some(tr) = s.track_mut(*t) else { continue };
let mut params: Vec<AutoParam> = tr for param in trim_params(tr, pan_only) {
.automation trim_lane(tr, &param, r, glide);
.iter()
.filter(|l| l.enabled && !matches!(l.param, AutoParam::Mute | AutoParam::SendMute(_)))
.filter(|l| !pan_only || matches!(l.param, AutoParam::Pan(_)))
.map(|l| l.param.clone())
.collect();
if pan_only {
for i in 0..tr.mixer.pan.len().min(2) {
let pp = AutoParam::Pan(i as u8);
if !params.contains(&pp) {
params.push(pp);
}
}
}
for param in params {
let cur = super::mix::current_value(tr, &param);
let (lo, hi, _) = param.range();
let l = tr.lane_mut(&param);
let before = l.value_at(r.start - 1, cur);
let after = l.value_at(r.end, cur);
let from = l.value_at(r.start, cur);
if glide {
l.clear_range(r.start, r.end.saturating_add(2));
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end, cur);
l.set_point(r.end.saturating_add(1), after);
} else {
let delta = cur - from;
let last = l.value_at(r.end - 1, cur);
if r.start > 0 {
l.set_point(r.start - 1, before);
}
l.set_point(r.start, from);
l.set_point(r.end - 1, last);
l.set_point(r.end, after);
for pt in l.points.iter_mut().filter(|pt| pt.at >= r.start && pt.at < r.end) {
pt.value = (pt.value + delta).clamp(lo, hi);
}
}
lanes += 1; lanes += 1;
} }
} }
+3 -3
View File
@@ -1,8 +1,8 @@
//! Event menu: time/tempo operations, MIDI operations, markers. //! Event menu: time/tempo operations, MIDI operations, markers.
use super::{CommandSpec, always, bad, clip_ids_param, f32_or, f64_or, has_selection, has_tracks, i64_or, position_param, range_param, str_param};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp}; use soundcraft_midi::ops::{DurationOp, QuantizeOptions, VelocityOp};
use soundcraft_model::{ClipContent, MarkerKind}; use soundcraft_model::{ClipContent, MarkerKind};
use soundcraft_time::TICKS_PER_QUARTER; use soundcraft_time::TICKS_PER_QUARTER;
+42 -27
View File
@@ -2,15 +2,18 @@
//! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track //! operations (split notes, input quantize, step input, restore/flatten performance), MIDI track
//! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and //! offsets and real-time properties, chord extraction, Beat Detective, Identify Beat and
//! Retrospective Record. //! Retrospective Record.
use super::{
CommandSpec, always, bad, bool_or, clip_ids_param, f32_or, f64_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param,
str_param, track_param, tracks_param, tracks_required,
};
use super::more_util::{store_value, track_key, values_with_prefix}; use super::more_util::{store_value, track_key, values_with_prefix};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::Note; use soundcraft_midi::Note;
use soundcraft_midi::ops::QuantizeOptions; use soundcraft_midi::ops::QuantizeOptions;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind}; use soundcraft_model::{ChannelFormat, Clip, ClipContent, ClipId, MarkerKind, Session, TrackId, TrackKind};
use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER, TempoMap}; use soundcraft_time::{MAX_POSITION, Range, Samples, TICKS_PER_QUARTER, TempoMap};
/// Most tempo events one curve may write. /// Most tempo events one curve may write.
const MAX_TEMPO_EVENTS: i64 = 10_000; const MAX_TEMPO_EVENTS: i64 = 10_000;
@@ -561,18 +564,11 @@ pub fn chord_name(weights: &[f64; 12], bass: usize) -> Option<String> {
const MAX_CHORD_BARS: usize = 100_000; const MAX_CHORD_BARS: usize = 100_000;
const MAX_CHORD_NOTES: usize = 10_000_000; const MAX_CHORD_NOTES: usize = 10_000_000;
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> { /// Absolute `(start, end, pitch)` tick notes from every MIDI clip on `tracks`, cut to their clip
let id = "event.extract_chords"; /// and sorted by start.
let (_, t0, t1) = selection_ticks(e, p, id)?; fn midi_tick_notes(s: &Session, tracks: &[TrackId]) -> Vec<(i64, i64, u8)> {
let s = e.session();
let picked = tracks_param(e, id, p)?;
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
if tracks.is_empty() {
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
}
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
let mut notes: Vec<(i64, i64, u8)> = Vec::new(); let mut notes: Vec<(i64, i64, u8)> = Vec::new();
for t in &tracks { for t in tracks {
for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) { for c in s.track(*t).map(|tr| tr.clips()).unwrap_or(&[]) {
let ClipContent::Midi { sequence } = &c.content else { continue }; let ClipContent::Midi { sequence } = &c.content else { continue };
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate); let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
@@ -587,6 +583,36 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
} }
} }
notes.sort_unstable_by_key(|n| n.0); notes.sort_unstable_by_key(|n| n.0);
notes
}
/// Duration-weighted pitch classes of the notes sounding in `a..b`, and the lowest pitch among them.
fn pitch_class_weights(sounding: &[(i64, i64, u8)], a: i64, b: i64) -> ([f64; 12], Option<u8>) {
let mut w = [0.0f64; 12];
let mut bass: Option<u8> = None;
for (na, nb, pitch) in sounding {
let ov = (*nb).min(b) - (*na).max(a);
if ov > 0 {
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
*x += ov as f64;
}
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
}
}
(w, bass)
}
fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.extract_chords";
let (_, t0, t1) = selection_ticks(e, p, id)?;
let s = e.session();
let picked = tracks_param(e, id, p)?;
let mut tracks: Vec<TrackId> = picked.into_iter().filter(|t| s.track(*t).is_some_and(|x| x.kind.is_midi())).collect();
if tracks.is_empty() {
tracks = s.tracks.iter().filter(|t| t.kind.is_midi()).map(|t| t.id).collect();
}
// Absolute (tick) notes from every MIDI clip on those tracks, cut to their clip.
let notes = midi_tick_notes(s, &tracks);
let mut bar = s.tempo.bar_beat_at_tick(t0).bar; let mut bar = s.tempo.bar_beat_at_tick(t0).bar;
let mut sounding: Vec<(i64, i64, u8)> = Vec::new(); let mut sounding: Vec<(i64, i64, u8)> = Vec::new();
let (mut next, mut bars, mut heard) = (0, 0, 0); let (mut next, mut bars, mut heard) = (0, 0, 0);
@@ -613,17 +639,7 @@ fn extract_chords(e: &mut Engine, p: &Value) -> Result<Value> {
} }
bars += 1; bars += 1;
heard += sounding.len(); heard += sounding.len();
let mut w = [0.0f64; 12]; let (w, bass) = pitch_class_weights(&sounding, a, b);
let mut bass: Option<u8> = None;
for (na, nb, pitch) in &sounding {
let ov = (*nb).min(b) - (*na).max(a);
if ov > 0 {
if let Some(x) = w.get_mut(usize::from(*pitch) % 12) {
*x += ov as f64;
}
bass = Some(bass.map_or(*pitch, |bp| bp.min(*pitch)));
}
}
if let Some(name) = bass.and_then(|bp| chord_name(&w, usize::from(bp) % 12)) { if let Some(name) = bass.and_then(|bp| chord_name(&w, usize::from(bp) % 12)) {
let at = s.tempo.tick_to_samples(a, s.sample_rate); let at = s.tempo.tick_to_samples(a, s.sample_rate);
chords.push(json!({"bar": bar, "chord": name, "at": at})); chords.push(json!({"bar": bar, "chord": name, "at": at}));
@@ -782,7 +798,6 @@ fn retro_record(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
fn midi_engine() -> (Engine, TrackId) { fn midi_engine() -> (Engine, TrackId) {
let mut e = Engine::default(); let mut e = Engine::default();
e.execute("track.new", &json!({"kind": "midi"})).unwrap(); e.execute("track.new", &json!({"kind": "midi"})).unwrap();
+29 -7
View File
@@ -1,9 +1,12 @@
//! File menu commands. //! File menu commands.
use super::{
CommandSpec, always, bad, bool_or, clip_ids_param, has_selection, has_tracks, i64_or, position_param, range_param, str_param, track_param,
tracks_param, tracks_required,
};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{Session, TrackId, TrackKind};
use soundcraft_model::{Session, TrackKind};
use soundcraft_time::SampleRate; use soundcraft_time::SampleRate;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@@ -110,9 +113,10 @@ pub fn specs() -> Vec<CommandSpec> {
crate::io::write_file(&path, &bytes)?; crate::io::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()})) 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); let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") { 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())?; crate::io::write_file(path, text.as_bytes())?;
} }
Ok(json!({"text": text})) Ok(json!({"text": text}))
@@ -238,7 +242,7 @@ fn import_audio(e: &mut Engine, p: &Value) -> Result<Value> {
let mut out = Vec::new(); let mut out = Vec::new();
for path in &paths { for path in &paths {
let bytes = crate::io::read_file(path)?; let bytes = crate::io::read_file(path)?;
let name = std::path::Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("Audio").to_string(); 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)?; let r = crate::io::import_audio_bytes(e, &name, &bytes, Some(path.as_str()), target, at)?;
out.push(r); out.push(r);
} }
@@ -299,7 +303,7 @@ pub fn bounce_job(e: &Engine, p: &Value) -> Result<crate::io::Bounce> {
let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string(); let path = str_param(p, "path").ok_or_else(|| bad("file.bounce_mix", "`path` required"))?.to_string();
let r = range_param(e, "file.bounce_mix", p)?; let r = range_param(e, "file.bounce_mix", p)?;
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r }; let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
let ext = std::path::Path::new(&path).extension().and_then(|x| x.to_str()).unwrap_or("wav").to_ascii_lowercase(); let ext = std::path::Path::new(&path).extension().and_then(std::ffi::OsStr::to_str).unwrap_or("wav").to_ascii_lowercase();
let format_name = str_param(p, "format").unwrap_or(ext.as_str()); let format_name = str_param(p, "format").unwrap_or(ext.as_str());
let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| { let format = soundcraft_audio_io::encode_format_for(format_name).ok_or_else(|| {
bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS)) bad("file.bounce_mix", format!("cannot write `{format_name}` files (supported: {})", soundcraft_audio_io::ENCODE_EXTENSIONS))
@@ -350,6 +354,24 @@ mod tests {
path.to_string_lossy().into_owned() path.to_string_lossy().into_owned()
} }
#[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] #[test]
fn import_audio_honours_new_tracks() { fn import_audio_honours_new_tracks() {
let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("sc-import-new-tracks-{}", std::process::id()));
+56 -42
View File
@@ -1,8 +1,8 @@
//! Media availability checks without decoding files or changing the session. //! Media availability checks without decoding files or changing the session.
use super::{CommandSpec, always};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::ClipContent; use soundcraft_model::ClipContent;
use std::collections::BTreeMap; use std::collections::BTreeMap;
@@ -12,6 +12,55 @@ pub fn specs() -> Vec<CommandSpec> {
] ]
} }
struct ClipContext<'a> {
track: &'a soundcraft_model::Track,
playlist: &'a soundcraft_model::Playlist,
active: bool,
session_sample_rate: u32,
}
fn check_audio_clip(
ctx: &ClipContext<'_>,
clip: &soundcraft_model::Clip,
source: soundcraft_model::SourceId,
offset: i64,
metadata: Option<&soundcraft_model::Source>,
audio: Option<&soundcraft_model::SourceAudio>,
issues: &mut Vec<Value>,
) {
let mut add = |code: &str, message: &str| {
issues.push(json!({"code": code, "message": message, "track": ctx.track.name, "track_id": ctx.track.id.0,
"playlist": ctx.playlist.name, "active": ctx.active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add("unavailable_audio", "Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.");
return;
};
if audio.buffer.sample_rate != ctx.session_sample_rate {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
return;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add("uneven_channels", "Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.");
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
}
fn report(e: &mut Engine, _: &Value) -> Result<Value> { fn report(e: &mut Engine, _: &Value) -> Result<Value> {
let s = e.session(); let s = e.session();
let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect(); let sources: BTreeMap<_, _> = s.sources.iter().map(|source| (source.id, source)).collect();
@@ -24,51 +73,16 @@ fn report(e: &mut Engine, _: &Value) -> Result<Value> {
for track in &s.tracks { for track in &s.tracks {
for (index, playlist) in track.playlists.iter().enumerate() { for (index, playlist) in track.playlists.iter().enumerate() {
let active = index == track.active_playlist; let active = index == track.active_playlist;
let ctx = ClipContext { track, playlist, active, session_sample_rate: s.sample_rate.hz() };
for clip in &playlist.clips { for clip in &playlist.clips {
let ClipContent::Audio { source, offset } = clip.content else { continue }; let ClipContent::Audio { source, offset } = clip.content else { continue };
clips += 1; clips += 1;
if active { if active {
active_end = active_end.max(clip.end()); active_end = active_end.max(clip.end());
} }
let metadata = sources.get(&source); let metadata = sources.get(&source).copied();
let audio = s.pool.get(source); let audio = s.pool.get(source).map(|a| a.as_ref());
let mut add = |code: &str, message: &str| { check_audio_clip(&ctx, clip, source, offset, metadata, audio, &mut issues);
issues.push(json!({"code": code, "message": message, "track": track.name, "track_id": track.id.0,
"playlist": playlist.name, "active": active, "clip": clip.name, "clip_id": clip.id.0,
"source_id": source.0, "path": metadata.map(|m| m.path.as_str())}));
};
if metadata.is_none() {
add("missing_source", "Source metadata is missing. Reimport the audio and replace this clip.");
}
let Some(audio) = audio else {
add(
"unavailable_audio",
"Audio is not loaded; this clip will be silent. Restore the media and reopen the session, or reimport it.",
);
continue;
};
if audio.buffer.sample_rate != s.sample_rate.hz() {
add("sample_rate_mismatch", "Loaded audio differs from the session sample rate. Reimport it to convert to the session rate.");
}
if audio.buffer.channels.is_empty() || audio.frames() == 0 {
add("empty_audio", "Loaded audio has no playable frames. Reimport a valid audio file.");
continue;
}
if audio.buffer.channels.iter().any(|channel| channel.len() != audio.frames()) {
add(
"uneven_channels",
"Loaded audio channels have different lengths. Reimport a valid audio file; bounds below use the shortest channel.",
);
}
if !clip.stretch.is_finite() || clip.stretch <= 0.0 || clip.length <= 0 {
add("invalid_clip", "Clip length or stretch is invalid. Replace this clip.");
}
let stretch = if clip.stretch.is_finite() && clip.stretch > 0.0 { clip.stretch } else { 1.0 };
// Match the mixer's last requested source sample, including elastic playback.
let last = offset as f64 + clip.length.saturating_sub(1).max(0) as f64 / stretch;
if offset < 0 || last >= audio.frames() as f64 {
add("source_bounds", "Clip extends outside the loaded audio; part will be silent. Trim the clip or replace its source.");
}
} }
} }
} }
+3 -3
View File
@@ -1,10 +1,10 @@
//! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI //! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI
//! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`; //! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;
//! the instrument is otherwise only set when the track is created (`track.new`). //! the instrument is otherwise only set when the track is created (`track.new`).
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{Insert, TrackKind}; use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
+3 -3
View File
@@ -1,8 +1,8 @@
//! MIDI note editing (MIDI Editor, pencil on MIDI tracks). //! MIDI note editing (MIDI Editor, pencil on MIDI tracks).
use super::{CommandSpec, always, bad, clip_id, has_tracks, i64_or, range_param, tracks_required};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_midi::{Note, Sequence}; use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, ClipContent, ClipId}; use soundcraft_model::{Clip, ClipContent, ClipId};
use soundcraft_time::TICKS_PER_QUARTER; use soundcraft_time::TICKS_PER_QUARTER;
+94 -66
View File
@@ -1,8 +1,10 @@
//! Mixer, insert, send and automation commands. //! Mixer, insert, send and automation commands.
use super::{
CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,
};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId}; use soundcraft_model::{AutoParam, AutomationMode, INSERT_SLOTS, Insert, SEND_SLOTS, SendSlot, Session, TrackId};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@@ -444,7 +446,7 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?; let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?; let explicit = optional_slot(p, "mix.send", SEND_SLOTS)?;
let s = e.session_mut(); let s = e.session_mut();
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo); let bid = s.bus_by_name(&bus).map(|b| b.id).ok_or_else(|| bad("mix.send", format!("no bus named \"{bus}\"")))?;
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?; let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?; let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting. // Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
@@ -610,6 +612,76 @@ fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
prev.map_or(0.0, |(_, v)| v) prev.map_or(0.0, |(_, v)| v)
} }
/// Fold the volume `lane` into the gain envelope of clip `c` over the selection `r`.
fn bake_volume_into_clip(c: &mut soundcraft_model::Clip, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
let delta = |at: i64| lane.value_at(at, base) - base;
let sel0 = r.start.max(c.start);
let sel1 = r.end.min(c.end());
if sel1 <= sel0 {
return;
}
let rel = |at: i64| at - c.start;
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
env.push(p);
}
c.gain_env = env;
}
/// Clear the volume lane over `r` when the clips all sit inside it, else hold the fader there.
fn reset_volume_lane(tr: &mut soundcraft_model::Track, lane: &soundcraft_model::AutomationLane, base: f32, r: soundcraft_time::Range) {
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
// held edge values outside would fall back to the fader level.
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
let lane_m = tr.lane_mut(&AutoParam::Volume);
if covers_all {
lane_m.clear_range(r.start, r.end);
} else {
// Hold the fader across the selection and anchor the samples on either side, so the
// curve outside does not interpolate through the gap or keep the old level inside.
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
let after = lane.value_at(r.end, base);
lane_m.clear_range(r.start, r.end);
if let Some(v) = before {
lane_m.set_point(r.start - 1, v);
}
lane_m.set_point(r.start, base);
if r.end - 1 > r.start {
lane_m.set_point(r.end - 1, base);
}
lane_m.set_point(r.end, after);
}
}
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> { fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?; let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
let r = range_param(e, "automation.volume_to_clip_gain", p)?; let r = range_param(e, "automation.volume_to_clip_gain", p)?;
@@ -618,72 +690,12 @@ fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let Some(tr) = s.track_mut(*t) else { continue }; let Some(tr) = s.track_mut(*t) else { continue };
let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue }; let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
let base = tr.mixer.volume_db; let base = tr.mixer.volume_db;
let delta = |at: i64| lane.value_at(at, base) - base;
if let Some(pl) = tr.playlist_mut() { if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) { for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
let sel0 = r.start.max(c.start); bake_volume_into_clip(c, &lane, base, r);
let sel1 = r.end.min(c.end());
if sel1 <= sel0 {
continue;
}
let rel = |at: i64| at - c.start;
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
env.push(p);
}
c.gain_env = env;
} }
} }
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the reset_volume_lane(tr, &lane, base, r);
// held edge values outside would fall back to the fader level.
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
let lane_m = tr.lane_mut(&AutoParam::Volume);
if covers_all {
lane_m.clear_range(r.start, r.end);
} else {
// Hold the fader across the selection and anchor the samples on either side, so the
// curve outside does not interpolate through the gap or keep the old level inside.
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
let after = lane.value_at(r.end, base);
lane_m.clear_range(r.start, r.end);
if let Some(v) = before {
lane_m.set_point(r.start - 1, v);
}
lane_m.set_point(r.start, base);
if r.end - 1 > r.start {
lane_m.set_point(r.end - 1, base);
}
lane_m.set_point(r.end, after);
}
} }
Ok(json!({})) Ok(json!({}))
} }
@@ -930,8 +942,10 @@ mod tests {
fn retargeting_a_send_keeps_its_settings() { fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default(); let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap(); e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap(); e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Delay"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap(); e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap(); let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id; let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
@@ -985,10 +999,24 @@ mod tests {
assert_eq!(count(&e), 0, "absent param must still clear every lane"); assert_eq!(count(&e), 0, "absent param must still clear every lane");
} }
#[test]
fn send_rejects_unknown_bus() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
let err = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Nowhere"})).unwrap_err();
assert!(err.to_string().contains("no bus named \"Nowhere\""), "{err}");
assert_eq!(e.session().busses.len(), 1);
assert!(e.session().tracks[0].mixer.sends.iter().all(Option::is_none));
e.execute("mix.send", &json!({"track": "Gtr", "bus": "verb"})).unwrap();
assert_eq!(e.session().busses.len(), 1);
}
#[test] #[test]
fn insert_and_send_reject_invalid_slot() { fn insert_and_send_reject_invalid_slot() {
let mut e = Engine::default(); let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap(); e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.new_bus", &json!({"name": "Verb"})).unwrap();
for slot in [json!(12), json!("k"), json!("x1")] { for slot in [json!(12), json!("k"), json!("x1")] {
let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"})); let r = e.execute("mix.insert", &json!({"track": "Gtr", "slot": slot, "plugin": "compressor"}));
assert!(r.is_err(), "insert slot {slot} accepted: {r:?}"); assert!(r.is_err(), "insert slot {slot} accepted: {r:?}");
+31 -2
View File
@@ -38,6 +38,27 @@ mod view_more;
mod vst3; mod vst3;
mod zoom_toggle; 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 use file::bounce_job;
pub(crate) use edit::remember_selection; pub(crate) use edit::remember_selection;
@@ -81,7 +102,7 @@ impl CommandSpec {
CommandInfo { CommandInfo {
id: self.id.into(), id: self.id.into(),
label: self.label.into(), label: self.label.into(),
menu: self.menu.iter().map(|s| s.to_string()).collect(), menu: self.menu.iter().map(ToString::to_string).collect(),
shortcut: self.shortcut.map(str::to_string), shortcut: self.shortcut.map(str::to_string),
params: self.params.into(), params: self.params.into(),
enabled: en.is_ok(), enabled: en.is_ok(),
@@ -136,6 +157,8 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(sampler::specs()); v.extend(sampler::specs());
v.extend(video::specs()); v.extend(video::specs());
v.extend(zoom_toggle::specs()); v.extend(zoom_toggle::specs());
#[cfg(test)]
v.extend(panic_probe::specs());
v 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) { if let Some(arr) = p.get("tracks").and_then(Value::as_array) {
let s = e.session(); let s = e.session();
let mut out = Vec::new(); let mut out = Vec::new();
let mut seen = std::collections::HashSet::new();
for v in arr { for v in arr {
let id = match (v.as_u64(), v.as_str()) { let id = match (v.as_u64(), v.as_str()) {
(Some(n), _) => s.track(TrackId(n)).map(|t| t.id), (Some(n), _) => s.track(TrackId(n)).map(|t| t.id),
(_, Some(name)) => s.track_by_name(name).map(|t| t.id), (_, Some(name)) => s.track_by_name(name).map(|t| t.id),
_ => None, _ => 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); return Ok(out);
} }
+3 -3
View File
@@ -1,8 +1,9 @@
//! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`, //! Helpers shared by the newer command modules: settings kept in `EditState.flags`/`values`,
//! per-track settings, target playlists, clip-gain envelopes and stretch rendering. //! per-track settings, target playlists, clip-gain envelopes and stretch rendering.
use super::bad;
use crate::{Engine, Result};
use super::*; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, FileFormat}; use soundcraft_audio_io::{AudioBuffer, FileFormat};
use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId}; use soundcraft_model::{Clip, ClipContent, EditState, Session, SourceId, TrackId};
use soundcraft_time::Samples; use soundcraft_time::Samples;
@@ -187,7 +188,6 @@ pub fn replace_clip(s: &mut Session, t: TrackId, old: soundcraft_model::ClipId,
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn text_settings_round_trip() { fn text_settings_round_trip() {
let mut ed = EditState::default(); let mut ed = EditState::default();
+3 -3
View File
@@ -1,8 +1,8 @@
//! Options menu toggles and Setup › Session settings. //! Options menu toggles and Setup › Session settings.
use super::{CommandSpec, always, bad, bool_or, position_param, str_param};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{BitDepthSetting, EditState}; use soundcraft_model::{BitDepthSetting, EditState};
use soundcraft_time::FrameRate; use soundcraft_time::FrameRate;
+25 -3
View File
@@ -1,9 +1,10 @@
//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's //! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
//! state right before saving and stores it on its insert (base64), from where the mixer restores //! state right before saving and stores it on its insert (base64), from where the mixer restores
//! it into new instances. //! it into new instances.
use super::{CommandSpec, bad, has_tracks, track_param};
use crate::{Engine, Result};
use super::*; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{INSERT_SLOTS, b64}; use soundcraft_model::{INSERT_SLOTS, b64};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@@ -47,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(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")), 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 s = e.session_mut();
let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?; let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;
let ins = match slot { let ins = match slot {
@@ -54,7 +64,6 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {
None => tr.instrument.as_mut(), None => tr.instrument.as_mut(),
} }
.ok_or_else(|| bad(CMD, "empty slot"))?; .ok_or_else(|| bad(CMD, "empty slot"))?;
let n = state.as_ref().map_or(0, Vec::len);
match state { match state {
Some(bytes) => ins.set_state_bytes(&bytes), Some(bytes) => ins.set_state_bytes(&bytes),
None => ins.state = None, None => ins.state = None,
@@ -96,6 +105,19 @@ mod tests {
assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None); 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] #[test]
fn hostile_params_are_errors() { fn hostile_params_are_errors() {
let mut e = Engine::default(); let mut e = Engine::default();
+2 -2
View File
@@ -1,8 +1,8 @@
//! Read-only queries for agents. //! Read-only queries for agents.
use super::{CommandInfo, CommandSpec, always, bad, command_specs, has_tracks, position_param, str_param, track_param};
use super::*;
use crate::cmd; use crate::cmd;
use serde_json::json; use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+6 -6
View File
@@ -4,11 +4,11 @@
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that //! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set //! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here. //! with `mix.instrument_param`, or all at once with `params` here.
use super::{CommandSpec, bad, has_selection, str_param, tracks_required};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{Insert, TrackId, TrackKind};
use soundcraft_model::{Insert, TrackKind};
use std::path::Path; use std::path::Path;
const SAMPLER: &str = "sampler"; const SAMPLER: &str = "sampler";
@@ -56,7 +56,7 @@ fn load(e: &mut Engine, p: &Value) -> Result<Value> {
} }
} }
let bytes = crate::io::read_file(&path)?; let bytes = crate::io::read_file(&path)?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string(); let name = Path::new(&path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("Sample").to_string();
attach(e, t, &name, &bytes, Some(&path), values) attach(e, t, &name, &bytes, Some(&path), values)
} }
@@ -69,7 +69,7 @@ pub(crate) fn attach(e: &mut Engine, t: TrackId, name: &str, bytes: &[u8], path:
return Err(bad(id, "only instrument tracks have a sampler")); return Err(bad(id, "only instrument tracks have a sampler"));
} }
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?; let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); let ext = Path::new(name).extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?; let (format, buf) = soundcraft_audio_io::decode(bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 { if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio"))); return Err(EngineError::Io(format!("{name}: no audio")));
+21 -17
View File
@@ -1,10 +1,10 @@
//! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click, //! More Options toggles, the Setup menu (hardware, playback engine, I/O, timecode, click,
//! shortcuts, MIDI setup) and File › Import › Session Data. //! shortcuts, MIDI setup) and File › Import › Session Data.
use super::{CommandSpec, always, bad, command_specs, i64_or, str_param, track_param, tracks_param};
use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix}; use super::more_util::{set_text, store_value, text, toggle_flag, values_with_prefix};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind}; use soundcraft_model::{BusId, ChannelFormat, ClipContent, OutputPath, Route, Session, SourceId, TrackId, TrackKind};
use soundcraft_time::Timecode; use soundcraft_time::Timecode;
use std::path::Path; use std::path::Path;
@@ -221,6 +221,23 @@ fn io_state(s: &Session) -> Value {
json!({"busses": s.busses, "outputs": s.outputs}) json!({"busses": s.busses, "outputs": s.outputs})
} }
/// Disconnect every track input, output and send that used bus `bid`.
fn unroute_bus(s: &mut Session, bid: BusId) {
for t in &mut s.tracks {
if t.mixer.input == Route::Bus(bid) {
t.mixer.input = Route::None;
}
if t.mixer.output == Route::Bus(bid) {
t.mixer.output = Route::None;
}
for slot in &mut t.mixer.sends {
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
*slot = None;
}
}
}
}
fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> { fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "setup.io"; let id = "setup.io";
let action = str_param(p, "action").unwrap_or("list"); let action = str_param(p, "action").unwrap_or("list");
@@ -252,19 +269,7 @@ fn io_setup(e: &mut Engine, p: &Value) -> Result<Value> {
"delete_bus" => { "delete_bus" => {
let bid: BusId = s.bus_by_name(&name).map(|b| b.id).ok_or_else(|| bad(id, format!("no bus `{name}`")))?; let bid: BusId = s.bus_by_name(&name).map(|b| b.id).ok_or_else(|| bad(id, format!("no bus `{name}`")))?;
s.busses.retain(|b| b.id != bid); s.busses.retain(|b| b.id != bid);
for t in &mut s.tracks { unroute_bus(s, bid);
if t.mixer.input == Route::Bus(bid) {
t.mixer.input = Route::None;
}
if t.mixer.output == Route::Bus(bid) {
t.mixer.output = Route::None;
}
for slot in &mut t.mixer.sends {
if slot.as_ref().is_some_and(|x| x.target == Route::Bus(bid)) {
*slot = None;
}
}
}
} }
"create_output" => { "create_output" => {
if s.outputs.iter().any(|o| o.name.eq_ignore_ascii_case(&name)) { if s.outputs.iter().any(|o| o.name.eq_ignore_ascii_case(&name)) {
@@ -503,7 +508,6 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn option_flags_toggle() { fn option_flags_toggle() {
let mut e = Engine::default(); let mut e = Engine::default();
+4 -3
View File
@@ -1,8 +1,8 @@
//! Surround mixing: the main output format and the surround panners of tracks and sends. //! Surround mixing: the main output format and the surround panners of tracks and sends.
use super::{CommandSpec, always, bad, bool_or, f32_or, has_selection, str_param, tracks_required};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind}; use soundcraft_model::{ChannelFormat, Mixer, OutputPath, Route, SEND_SLOTS, Session, SurroundPan, TrackKind};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
@@ -184,6 +184,7 @@ mod tests {
assert!(e.session().track(t).unwrap().mixer.surround.is_none()); assert!(e.session().track(t).unwrap().mixer.surround.is_none());
// Sends. // Sends.
assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot"); assert!(e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0})).is_err(), "empty slot");
e.execute("mix.new_bus", &json!({"name": "FX"})).unwrap();
e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap(); e.execute("mix.send", &json!({"track": t.0, "slot": 0, "bus": "FX"})).unwrap();
e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap(); e.execute("mix.send_surround_pan", &json!({"track": t.0, "slot": "a", "x": 1.0, "y": -1.0})).unwrap();
let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap(); let snd = e.session().track(t).unwrap().mixer.sends[0].clone().unwrap();
+5 -5
View File
@@ -1,10 +1,10 @@
//! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note. //! Tab and Tab to Transient: the insertion point or selection end to the next or previous clip boundary, transient or note.
use super::{CommandSpec, always, bool_or};
use super::*; use crate::{Engine, Result, TransportRequest, cmd};
use crate::{TransportRequest, cmd}; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{Clip, ClipContent, Session, SourceAudio, TrackId};
use soundcraft_model::{Clip, ClipContent, Session, SourceAudio}; use soundcraft_time::{Range, SampleRate, Samples};
use soundcraft_time::SampleRate;
use std::sync::{Arc, Mutex, PoisonError, Weak}; use std::sync::{Arc, Mutex, PoisonError, Weak};
/// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and /// Transients are found in fixed blocks of each clip, so a long clip costs a block per press and
+38 -32
View File
@@ -1,9 +1,11 @@
//! Track menu commands. //! Track menu commands.
use super::{
CommandSpec, always, bad, bool_or, has_selection, has_tracks, i64_or, range_param, str_param, track_param, tracks_param, tracks_required,
};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
use soundcraft_model::{ChannelFormat, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackKind};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
@@ -633,6 +635,35 @@ fn change_width(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"format": f})) Ok(json!({"format": f}))
} }
/// The nearest ancestor folder of `folder` that is not itself being deleted (walks at most 64 levels).
fn surviving_parent(s: &Session, deleted: &[TrackId], folder: TrackId) -> Option<TrackId> {
let mut parent = s.track(folder).and_then(|folder| folder.folder);
for _ in 0..64 {
match parent {
Some(candidate) if deleted.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
parent
}
/// Clear inputs and sends that used the removed bus; outputs that used it move to `replacement`.
fn reroute_deleted_bus(s: &mut Session, bus: soundcraft_model::BusId, replacement: &Route) {
for track in &mut s.tracks {
if track.mixer.input == Route::Bus(bus) {
track.mixer.input = Route::None;
}
if track.mixer.output == Route::Bus(bus) {
track.mixer.output = replacement.clone();
}
for send in &mut track.mixer.sends {
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
*send = None;
}
}
}
}
fn delete(e: &mut Engine, p: &Value) -> Result<Value> { fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "track.delete", p)?; let tracks = tracks_required(e, "track.delete", p)?;
let s = e.session_mut(); let s = e.session_mut();
@@ -640,13 +671,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
.iter() .iter()
.filter_map(|folder| { .filter_map(|folder| {
let bus = owned_folder_bus(s, *folder)?; let bus = owned_folder_bus(s, *folder)?;
let mut parent = s.track(*folder).and_then(|folder| folder.folder); let parent = surviving_parent(s, &tracks, *folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main))) Some((bus, parent.and_then(|parent| folder_bus(s, parent)).unwrap_or(Route::Main)))
}) })
.collect(); .collect();
@@ -658,13 +683,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
continue; continue;
} }
let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue }; let Some(folder) = child.folder.filter(|folder| tracks.contains(folder)) else { continue };
let mut parent = s.track(folder).and_then(|folder| folder.folder); let parent = surviving_parent(s, &tracks, folder);
for _ in 0..64 {
match parent {
Some(candidate) if tracks.contains(&candidate) => parent = s.track(candidate).and_then(|folder| folder.folder),
_ => break,
}
}
promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent)))); promoted.push((child.id, folder_bus(s, folder), parent, parent.and_then(|parent| folder_bus(s, parent))));
} }
for (child, old_bus, parent, parent_bus) in promoted { for (child, old_bus, parent, parent_bus) in promoted {
@@ -682,19 +701,7 @@ fn delete(e: &mut Engine, p: &Value) -> Result<Value> {
} }
for (bus, replacement) in removed_busses { for (bus, replacement) in removed_busses {
s.busses.retain(|candidate| candidate.id != bus); s.busses.retain(|candidate| candidate.id != bus);
for track in &mut s.tracks { reroute_deleted_bus(s, bus, &replacement);
if track.mixer.input == Route::Bus(bus) {
track.mixer.input = Route::None;
}
if track.mixer.output == Route::Bus(bus) {
track.mixer.output = replacement.clone();
}
for send in &mut track.mixer.sends {
if send.as_ref().is_some_and(|send| send.target == Route::Bus(bus)) {
*send = None;
}
}
}
} }
Ok(json!({"deleted": tracks.len()})) Ok(json!({"deleted": tracks.len()}))
} }
@@ -944,7 +951,6 @@ fn route(e: &mut Engine, p: &Value, input: bool) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn comp_from_alternate_playlist() { fn comp_from_alternate_playlist() {
let mut e = crate::demo::demo_engine(); let mut e = crate::demo::demo_engine();
+3 -4
View File
@@ -1,10 +1,10 @@
//! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert / //! More Track menu commands: Convert Aux to Routing Folder, Extract MIDI, track presets, insert /
//! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists. //! send bypass subsets, MIDI real-time properties, trim/VCA coalescing and target playlists.
use super::{CommandSpec, bad, has_selection, has_tracks, range_param, str_param, tracks_param, tracks_required};
use super::more_util::{activate_playlist, target_playlist, track_key}; use super::more_util::{activate_playlist, target_playlist, track_key};
use super::*; use crate::{Engine, EngineError, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_dsp::Category; use soundcraft_dsp::Category;
use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind}; use soundcraft_model::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipContent, Insert, Route, Session, Track, TrackId, TrackKind};
use soundcraft_time::Samples; use soundcraft_time::Samples;
@@ -547,7 +547,6 @@ fn toggle_recent(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn bypass_by_category_and_range() { fn bypass_by_category_and_range() {
let mut e = crate::demo::demo_engine(); let mut e = crate::demo::demo_engine();
+3 -3
View File
@@ -1,9 +1,9 @@
//! Transport commands. They update the session's stopped playhead and queue requests for the //! Transport commands. They update the session's stopped playhead and queue requests for the
//! host's audio engine (which may be absent when headless). //! host's audio engine (which may be absent when headless).
use super::{CommandSpec, always, bad, f64_or, position_param};
use super::*; use crate::{Engine, Result, TransportRequest, cmd};
use crate::{TransportRequest, cmd}; use serde_json::{Value, json};
use serde_json::json;
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
+4 -3
View File
@@ -2,10 +2,11 @@
//! //!
//! Movies are imported with `file.import_video`; the session only references them //! Movies are imported with `file.import_video`; the session only references them
//! (`Session::videos`). Decoding happens in the UI (`soundcraft-video`), never in the engine. //! (`Session::videos`). Decoding happens in the UI (`soundcraft-video`), never in the engine.
use super::{CommandSpec, always, bad, position_param, track_param};
use soundcraft_time::MAX_POSITION;
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
use soundcraft_model::{ClipContent, TrackKind}; use soundcraft_model::{ClipContent, TrackKind};
/// `edit.flags` key set while the Video track is offline (online is the default). /// `edit.flags` key set while the Video track is offline (online is the default).
+6 -5
View File
@@ -1,9 +1,10 @@
//! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge). //! View menu commands that change saved view state (zoom, rulers, counters, grid/nudge).
use super::{CommandSpec, always, bad, bool_or, f32_or, f64_or, i64_or, position_param, str_param};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json; use soundcraft_time::TimeFormat;
use soundcraft_time::{GridValue, NoteValue, TimeFormat}; use soundcraft_time::{GridValue, MAX_POSITION, NoteValue};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
@@ -160,7 +161,7 @@ pub fn grid_param(p: &Value, key: &str) -> Option<GridValue> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use soundcraft_time::Samples;
#[test] #[test]
fn grid_values_parse() { fn grid_values_parse() {
assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false })); assert_eq!(grid_param(&json!({"v": "1/16"}), "v"), Some(GridValue::Note { value: NoteValue::Sixteenth, dotted: false, triplet: false }));
+3 -4
View File
@@ -1,11 +1,11 @@
//! More View menu commands: Edit-window column views, extra rulers, marker displays, clip //! More View menu commands: Edit-window column views, extra rulers, marker displays, clip
//! display options, waveform styles, expanded sends, transport views and other view toggles. //! display options, waveform styles, expanded sends, transport views and other view toggles.
//! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers). //! Every setting is saved with the session in `edit.flags` (or `edit.rulers` for rulers).
use super::{CommandSpec, always, str_param};
use super::more_util::{exclusive_flag, set_flags, toggle_flag}; use super::more_util::{exclusive_flag, set_flags, toggle_flag};
use super::*; use crate::{Engine, Result, cmd};
use crate::cmd; use serde_json::{Value, json};
use serde_json::json;
/// A view toggle: `{value?: bool}` sets it, otherwise it flips. /// A view toggle: `{value?: bool}` sets it, otherwise it flips.
macro_rules! flag { macro_rules! flag {
@@ -219,7 +219,6 @@ fn toggle_ruler(e: &mut Engine, p: &Value, id: &str) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn flags_toggle_and_persist() { fn flags_toggle_and_persist() {
let mut e = Engine::default(); let mut e = Engine::default();
+2 -2
View File
@@ -1,10 +1,10 @@
//! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in //! Third-party VST3 plugins: discovery (`engine.vst3_plugins`). Hosting itself lives in
//! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in //! `soundcraft-vst3-host`; id lookup for the mixer commands is shared with CLAP in
//! `cmd::clap::plugin_info`. //! `cmd::clap::plugin_info`.
use super::{CommandSpec, always};
use super::*;
use crate::cmd; use crate::cmd;
use serde_json::json; use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![cmd!( vec![cmd!(
+8 -5
View File
@@ -1,9 +1,10 @@
//! Zoom Toggle: zoom in on the edit selection, then back out to the view before. //! Zoom Toggle: zoom in on the edit selection, then back out to the view before.
use super::{CommandSpec, always, f64_or};
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{TrackHeight, ZoomToggle}; use soundcraft_model::{TrackHeight, ZoomToggle};
use soundcraft_time::MAX_POSITION;
use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)] vec![cmd!(noundo "view.zoom_toggle", "Zoom Toggle", [], None, "{width_px?: 1200, height_px?: 600}", always, toggle)]
@@ -37,8 +38,10 @@ fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::TrackHeight::{Fit, Large, Medium};
use super::*; use super::*;
use TrackHeight::{Fit, Large, Medium}; use soundcraft_model::TrackId;
use soundcraft_time::Samples;
fn engine(select: Value) -> Engine { fn engine(select: Value) -> Engine {
let mut e = Engine::default(); let mut e = Engine::default();
+71 -4
View File
@@ -149,9 +149,11 @@ pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
let old = part.start; let old = part.start;
part.trim_start_to(isect.start); part.trim_start_to(isect.start);
rebase_midi_start(&mut part, old, t); rebase_midi_start(&mut part, old, t);
part.fade_in = Fade::default();
} }
if isect.end < part.end() { if isect.end < part.end() {
part.trim_end_to(isect.end); part.trim_end_to(isect.end);
part.fade_out = Fade::default();
} }
part.start -= range.start; part.start -= range.start;
out.push(part); out.push(part);
@@ -405,7 +407,8 @@ pub fn trim_end_to(s: &mut Session, track: TrackId, at: Samples) -> usize {
n n
} }
/// Heal separations inside `range`: merge neighbouring audio clips that continue the same file. /// 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 { 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 }; let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };
pl.sort(); pl.sort();
@@ -413,13 +416,16 @@ pub fn heal(s: &mut Session, track: TrackId, range: Range) -> usize {
let mut healed = 0; let mut healed = 0;
for c in pl.clips.drain(..) { for c in pl.clips.drain(..) {
if let Some(prev) = out.last_mut() { 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) { 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(prev.length).saturating_add(gap) == *ob
}
_ => false, _ => false,
}; };
if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 { if contiguous && same && (prev.gain_db - c.gain_db).abs() < 1e-6 {
prev.length += c.length; prev.length = c.end().saturating_sub(prev.start);
prev.fade_out = c.fade_out; prev.fade_out = c.fade_out;
healed += 1; healed += 1;
continue; continue;
@@ -835,6 +841,8 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {
} }
} }
} }
// Trimming can move a start past a later clip's start when one clip overlapped several.
pl.sort();
n n
} }
@@ -868,6 +876,20 @@ mod tests {
s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect() 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] #[test]
fn separate_splits_and_heal_rejoins() { fn separate_splits_and_heal_rejoins() {
let (mut s, t) = session_with_clip(); let (mut s, t) = session_with_clip();
@@ -878,6 +900,37 @@ mod tests {
assert_eq!(clips(&s, t), vec![(1000, 4000, 1000)]); 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] #[test]
fn clear_slip_and_shuffle() { fn clear_slip_and_shuffle() {
let (mut s, t) = session_with_clip(); let (mut s, t) = session_with_clip();
@@ -1041,4 +1094,18 @@ mod tests {
s.find_clip_mut(id).unwrap().time_locked = true; s.find_clip_mut(id).unwrap().time_locked = true;
assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0); assert_eq!(move_clips(&mut s, &[id], 500, &[]), 0);
} }
#[test]
fn delete_fades_keeps_playlist_sorted_when_a_clip_overlaps_several() {
let (mut s, t) = session_with_clip();
let pl = s.track_mut(t).and_then(|t| t.playlist_mut()).expect("track has a playlist");
pl.clips.clear();
pl.clips.push(Clip::audio(ClipId(901), "a", SourceId(500), 0, 0, 1000));
pl.clips.push(Clip::audio(ClipId(902), "b", SourceId(500), 100, 100, 100));
pl.clips.push(Clip::audio(ClipId(903), "c", SourceId(500), 150, 150, 750));
delete_fades(&mut s, t, Range::new(0, 10_000));
let got = clips(&s, t);
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:?}");
}
} }
+109 -40
View File
@@ -1,15 +1,16 @@
//! The folders automation may read and write. //! The folders automation may read and write.
//! //!
//! `soundcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the //! `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 //! files an agent's commands reach; each flag may be given more than once (#874). [`AutomationRoots`]
//! force, every path a command reads goes through [`check_read`] and every path it writes through [`check_write`]: //! 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 //! - 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 //! 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 //! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
//! is a link that leads nowhere). //! is a link that leads nowhere).
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside //! - The result must lie inside one of the roots of its kind, compared by whole components
//! `/work`), without regard to case on Windows. //! (`/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. //! - 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 //! Roots are in force on a thread inside [`confine`], and in the whole process after
@@ -32,11 +33,11 @@ pub enum Access {
Write, 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)] #[derive(Debug)]
pub struct AutomationRoots { pub struct AutomationRoots {
read: Option<Root>, read: Vec<Root>,
write: Option<Root>, write: Vec<Root>,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -62,48 +63,61 @@ impl Root {
} }
impl AutomationRoots { impl AutomationRoots {
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given /// The roots `read` and `write`, which must be existing folders (one given twice counts once);
/// (nothing is confined then). /// `None` when neither kind has any (nothing is confined then).
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> { pub fn new(read: &[&Path], write: &[&Path]) -> Result<Option<Arc<Self>>, String> {
if read.is_none() && write.is_none() { if read.is_empty() && write.is_empty() {
return Ok(None); return Ok(None);
} }
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?; let open = |paths: &[&Path], access: Access| -> Result<Vec<Root>, String> {
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?; let mut roots: Vec<Root> = Vec::with_capacity(paths.len());
Ok(Some(Arc::new(Self { read, write }))) 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. /// The roots for people: `read A, B; write C` (`none` for a kind without any).
pub fn read_root(&self) -> Option<&Path> { pub fn describe(&self) -> String {
self.read.as_ref().map(|r| r.shown.as_path()) 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. /// May the file or folder at `path` be read (or written)? It must lie inside one of the roots
pub fn write_root(&self) -> Option<&Path> { /// of that kind. `Err` says why not.
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.
pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> { pub fn check(&self, path: impl AsRef<Path>, access: Access) -> Result<(), String> {
let path = path.as_ref(); let path = path.as_ref();
let shown_path = path.display(); let shown_path = path.display();
let root = match access { let roots = match access {
Access::Read => self.read.as_ref(), Access::Read => &self.read,
Access::Write => self.write.as_ref(), Access::Write => &self.write,
}; };
let Some(Root { resolved: folder, shown }) = root else { if roots.is_empty() {
return match access { return match access {
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {shown_path}")), 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}")), 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(()); return Ok(());
} }
let root = shown.display(); let shown: Vec<String> = roots.iter().map(|r| r.shown.display().to_string()).collect();
match access { match (shown.as_slice(), access) {
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {shown_path}")), ([root], 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}")), ([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("in/a.scraft"), "x").unwrap();
std::fs::write(base.join("outside/secret.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) (base, roots)
} }
@@ -340,18 +354,18 @@ mod tests {
#[test] #[test]
fn a_missing_root_is_an_error_and_none_is_no_confinement() { fn a_missing_root_is_an_error_and_none_is_no_confinement() {
let (base, _) = folders("missing"); 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}"); 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!( assert!(
e.starts_with("cannot open the automation write root") e.starts_with("cannot open the automation write root")
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()), && e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
"{e}" "{e}"
); );
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``")); assert!(AutomationRoots::new(&[Path::new("")], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
assert!(AutomationRoots::new(None, None).unwrap().is_none()); assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
// One root alone grants only its own access. // 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(); let e = read_only.check(s(base.join("in/x.scraft")), Access::Write).unwrap_err();
assert!(e.contains("write authority is absent"), "{e}"); assert!(e.contains("write authority is absent"), "{e}");
let _ = std::fs::remove_dir_all(base); 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(())); 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. // A root given through a link is the folder it leads to.
assert!(made(&base.join("in"), &base.join("in-link"))); 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(())); assert_eq!(linked.check(s(base.join("in/a.scraft")), Access::Read), Ok(()));
let _ = std::fs::remove_dir_all(base); 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)] #[cfg(windows)]
#[test] #[test]
fn windows_ignores_case_and_refuses_devices() { fn windows_ignores_case_and_refuses_devices() {
+85 -29
View File
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat}; use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind}; use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples}; use soundcraft_time::{Range, Samples};
use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -68,19 +69,16 @@ pub fn file_found(path: impl AsRef<Path>) -> bool {
/// Paths the person picks in this session (imports) are not stored paths and skip this. /// Paths the person picks in this session (imports) are not stored paths and skip this.
pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> { pub fn stored_path(stored: &str, session: &Path) -> std::result::Result<PathBuf, String> {
let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf()); let session = std::path::absolute(session).unwrap_or_else(|_| session.to_path_buf());
let path = craft_docpath::resolve(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))?; craft_docpath::resolve_absolute(stored, Some(&session)).map_err(|why| format!("not opened: {why}"))
// A path that is still relative would be taken from the working directory, which the session
// does not choose.
if path.is_relative() {
return Err("not opened: the path is not absolute".into());
}
Ok(path)
} }
/// Create `dir` and its parents, inside the automation write root when there is one. /// Create `dir` and its parents, inside the automation write root when there is one.
pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> { pub(crate) fn make_dir(dir: impl AsRef<Path>) -> Result<()> {
let dir = dir.as_ref(); 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()))) std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))
} }
@@ -120,7 +118,7 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
buf.sample_rate = sr; buf.sample_rate = sr;
} }
let id = SourceId(s.alloc()); let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or(name).to_string();
let stored = match path { let stored = match path {
Some(p) => p.to_string(), Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem), None => unique_audio_rel(s, &stem),
@@ -151,7 +149,7 @@ fn stamp_at_rate(stamp: u64, file_rate: u32, session_rate: u32) -> u64 {
/// Decode and place an audio file. With no target track, a new track of matching width is made. /// 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> { pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option<&str>, target: Option<TrackId>, at: Samples) -> Result<Value> {
let ext = Path::new(name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase); 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 (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) 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")))?; .ok_or_else(|| EngineError::Io(format!("{name}: file is too large to import")))?;
@@ -170,7 +168,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
let track = match target { let track = match target {
Some(t) => t, Some(t) => t,
None => { None => {
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem)) s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
} }
}; };
@@ -194,8 +192,8 @@ pub fn load_sampler_bytes(e: &mut Engine, track: TrackId, name: &str, bytes: &[u
/// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails /// never copied) plus its audio on a new audio track when it has a usable audio stream. Fails
/// only when the file has neither a readable picture nor audio. /// only when the file has neither a readable picture nor audio.
pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> { pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Samples) -> Result<Value> {
let stem = Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Video").to_string(); let stem = Path::new(path).file_stem().and_then(OsStr::to_str).unwrap_or("Video").to_string();
let ext = Path::new(path).extension().and_then(|x| x.to_str()).unwrap_or("mp4").to_ascii_lowercase(); let ext = Path::new(path).extension().and_then(OsStr::to_str).unwrap_or("mp4").to_ascii_lowercase();
let bytes: Arc<[u8]> = bytes.into(); let bytes: Arc<[u8]> = bytes.into();
let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone()); let movie = soundcraft_video::Movie::open(Arc::clone(&bytes)).map(|m| m.info().clone());
let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() }); let audio_name = format!("{stem} audio.{}", if ext == "mov" { "mp4" } else { ext.as_str() });
@@ -216,7 +214,7 @@ pub fn import_video_bytes(e: &mut Engine, path: &str, bytes: Vec<u8>, at: Sample
let vid = SourceId(s.alloc()); let vid = SourceId(s.alloc());
s.videos.push(soundcraft_model::VideoSource { s.videos.push(soundcraft_model::VideoSource {
id: vid, id: vid,
name: Path::new(path).file_name().and_then(|n| n.to_str()).unwrap_or("movie").to_string(), name: Path::new(path).file_name().and_then(OsStr::to_str).unwrap_or("movie").to_string(),
path: abs, path: abs,
width: info.width, width: info.width,
height: info.height, height: info.height,
@@ -998,7 +996,7 @@ fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Sess
sync_dir(&audio_dir); sync_dir(&audio_dir);
} }
s.folder = Some(dir.clone()); s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) { if !overwrite && let Some(stem) = p.file_stem().and_then(OsStr::to_str) {
s.name = stem.to_string(); s.name = stem.to_string();
} }
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?; let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
@@ -1058,7 +1056,7 @@ fn copy_new(from: &Path, to: &Path) -> std::io::Result<()> {
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the /// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
/// newest ten. /// newest ten.
fn back_up(file: &Path) { fn back_up(file: &Path) {
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str())) let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(OsStr::to_str), file.extension().and_then(OsStr::to_str))
else { else {
return; return;
}; };
@@ -1109,7 +1107,7 @@ pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
"session.save" | "session.save_as" | "session.save_copy" => { "session.save" | "session.save_as" | "session.save_copy" => {
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file)) 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, _ => false,
} }
} }
@@ -1201,7 +1199,7 @@ pub(crate) fn decode_file(files: &mut DecodedFiles, p: &Path, rate: u32) -> std:
.entry(file_key(p)) .entry(file_key(p))
.or_insert_with(|| { .or_insert_with(|| {
let bytes = read_file(p).map_err(|_| p.display().to_string())?; let bytes = read_file(p).map_err(|_| p.display().to_string())?;
decode_source(&bytes, p.extension().and_then(|x| x.to_str()), rate).map_err(|err| format!("{}: {err}", p.display())) decode_source(&bytes, p.extension().and_then(OsStr::to_str), rate).map_err(|err| format!("{}: {err}", p.display()))
}) })
.clone() .clone()
} }
@@ -1233,7 +1231,7 @@ pub fn set_deliver(h: DeliverHook) {
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> { pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") { if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?; let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled"); let name = Path::new(path).file_name().and_then(OsStr::to_str).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io); return deliver(name, bytes).map_err(EngineError::Io);
} }
crate::file_access::check_write(path).map_err(EngineError::Io)?; crate::file_access::check_write(path).map_err(EngineError::Io)?;
@@ -1278,7 +1276,7 @@ pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]). /// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> { fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string); let stem = Path::new(path).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone(); let mut s = e.session().clone();
if let Some(stem) = stem { if let Some(stem) = stem {
s.name = stem; s.name = stem;
@@ -1317,7 +1315,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
continue; continue;
} }
let audio = match files.remove(&src.path) { let audio = match files.remove(&src.path) {
Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(|x| x.to_str()), s.sample_rate.hz()) { Some(bytes) => match decode_source(&bytes, Path::new(&src.path).extension().and_then(OsStr::to_str), s.sample_rate.hz()) {
Ok(audio) => Some(audio), Ok(audio) => Some(audio),
Err(err) => { Err(err) => {
missing.push(format!("{}: {err}", src.path)); missing.push(format!("{}: {err}", src.path));
@@ -1336,7 +1334,7 @@ pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Ve
} }
// Movies are referenced by path, never copied into a session: none can be found here. // Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone())); missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string(); let stem = Path::new(name).file_stem().and_then(OsStr::to_str).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s); e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem))); e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing { for m in &missing {
@@ -1368,7 +1366,7 @@ fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String
let mut data = Vec::new(); let mut data = Vec::new();
// The declared size is hostile too: never read past it. // The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?; f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION); let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(OsStr::to_str) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() { if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?); text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else { } else {
@@ -1456,13 +1454,18 @@ fn add_recording_in(
if s.track(tid).is_none() { if s.track(tid).is_none() {
continue; continue;
} }
let resampled = buf.sample_rate != s.sample_rate.hz(); let hz = s.sample_rate.hz();
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take"); let resampled = buf.sample_rate != hz;
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav); let mut buf = buf;
let Some(x) = s.sources.last_mut() else { continue }; if resampled && buf.sample_rate > 0 {
x.unsaved = resampled; buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, hz);
let len = i64::try_from(x.frames).unwrap_or(0); buf.sample_rate = hz;
}
let name = path.file_stem().and_then(OsStr::to_str).unwrap_or("Take");
let len = i64::try_from(buf.frames()).unwrap_or(0);
let pass = pass.unwrap_or(len).max(1); 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) { for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
let n = pass.min(len - offset); let n = pass.min(len - offset);
if offset > 0 && n < pass / 8 { if offset > 0 && n < pass / 8 {
@@ -1475,6 +1478,16 @@ fn add_recording_in(
} }
None => (start, 0, n), 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 if offset > 0
&& let Some(t) = s.track_mut(tid) && let Some(t) = s.track_mut(tid)
{ {
@@ -1588,6 +1601,39 @@ mod record_tests {
let _ = std::fs::remove_dir_all(&dir); 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] #[test]
fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() { fn a_take_shows_the_pass_under_way_within_the_punch_while_it_records() {
assert_eq!(live_span(1_000, None, None, 0), None); assert_eq!(live_span(1_000, None, None, 0), None);
@@ -1883,6 +1929,16 @@ mod open_tests {
assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1); assert_eq!(std::fs::read_dir(dir.join("copy/Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
#[test]
fn stored_paths_are_taken_from_the_session_folder() {
let session = std::env::temp_dir().join("s").join("Song.scraft");
assert_eq!(stored_path("Audio Files/k.wav", &session), Ok(session.with_file_name("Audio Files").join("k.wav")));
#[cfg(windows)]
assert_eq!(stored_path("Audio Files/take:2.wav", &session), Err("not opened: the path is not valid".into()));
#[cfg(not(windows))]
assert_eq!(stored_path(r"C:\Audio\k.wav", &session), Err("not opened: the path is not valid".into()));
}
#[test] #[test]
fn stored_media_paths_are_followed_only_on_this_computer() { fn stored_media_paths_are_followed_only_on_this_computer() {
let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id())); let dir = std::env::temp_dir().join(format!("soundcraft-stored-paths-{}", std::process::id()));
+31 -1
View File
@@ -316,6 +316,7 @@ impl Engine {
let before = Arc::clone(&self.doc); let before = Arc::clone(&self.doc);
let rev = self.revision; let rev = self.revision;
let dirty = self.dirty; 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 = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)));
let result = match result { let result = match result {
Ok(r) => r, Ok(r) => r,
@@ -323,6 +324,7 @@ impl Engine {
self.doc = before; self.doc = before;
self.revision = rev.wrapping_add(1); self.revision = rev.wrapping_add(1);
self.dirty = dirty; 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))); return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
} }
}; };
@@ -394,6 +396,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`. /// End a gesture started with `execute_merged`.
pub fn end_merge(&mut self) { pub fn end_merge(&mut self) {
self.merge_key = None; self.merge_key = None;
@@ -449,7 +456,7 @@ fn install_panic_hook() {
} }
fn panic_message(p: &(dyn std::any::Any + Send)) -> String { fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into()) p.downcast_ref::<&str>().map(ToString::to_string).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
} }
#[cfg(test)] #[cfg(test)]
@@ -521,3 +528,26 @@ mod dirty_tests {
assert!(!e.is_dirty()); 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);
}
}

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