Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor 2b5785f2e2 AudioSuite: process each listed clip once, within a total budget
A clip listed more than once in audiosuite.process was processed again
on its own result: the replacement keeps the clip id, so an expanding
process (time stretch, varispeed, pitch shift) compounded the clip's
length each time, and every intermediate result stayed in the source
pool for the rest of the command.

Deduplicate the clip ids, and before processing anything check each
clip's per-render limit and the projected output of all clips together
against MAX_RENDER_SAMPLES, the same total budget the signal generator
already uses.
2026-11-02 22:29:03 -05:00
Brandon ThomasandClaude Opus 5.5 b35dd0f9eb Release 0.17.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:25:27 -05:00
FredApps e5d3174929 Merge pull request #637 from danielmigueltejedor/fix/gnome-window-decorations
Fix GNOME/Wayland window controls
2026-11-02 19:32:54 +01:00
FredApps 27329587c6 Merge current main into fix/gnome-window-decorations 2026-11-02 19:10:30 +01:00
FredApps 1dbc185e01 Merge pull request #559 from SlyLaM/fix/new-tracks-keyboard-ux
feat(ui): improve the New Tracks creation workflow
2026-11-02 18:23:48 +01:00
Brandon Thomas 8444edf075 Merge pull request #1248 from Silvertree2010/fix/1247-vst3-multi-out-instruments-e-g-s
Accept VST3 multi-out instruments with up to 64 audio buses
2026-11-02 00:30:05 -05:00
NetterSilverandClaude Opus 5.5 6c3a0dfc50 Accept VST3 multi-out instruments with up to 64 audio buses
Fixes #1247

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 02:29:44 +00:00
Kushagra Kumar 966e253873 fix(engine): skip duplicate MIDI tempos only when session tempo matches
Collapse consecutive duplicate SMF tempos only if the session tempo at
the event tick already matches, so existing session tempo markers are
overridden as expected.
2026-10-31 05:50:50 +05:30
Kushagra Kumar af4dd7ce4c Contributors: add kk376 to people.toml 2026-10-31 05:35:13 +05:30
Kushagra Kumar f7b0805de6 fix(engine): bound MIDI import tempo and track floods (#890)
import_midi_bytes capped meter changes at MAX_MIDI_METERS (10,000), but
left imported tempo events and synth tracks unbounded. Dense MIDI files
inserted unlimited tempo markers into the session tempo map, degrading
linear tick-to-sample conversions across playback and audio processing,
and instantiated up to 4,096 synth tracks for controller-only tracks.

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 06:40:00 +01:00
ydking0911 9f5f312051 Clear: close the gaps of clips removed by id in Shuffle 2026-10-30 14:30:58 +09:00
ydking0911 ffb95ebcf3 Clip menu: pass the clicked clip's track when Delete removes selected clips by id 2026-10-30 13:47:53 +09:00
NetterSilverandClaude Haiku 5.5 440999a51f Play transport.play {from} at the requested position, not the selection
Fixes #1069

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

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 05:24:39 +01:00
ydking0911 14a081304b Clip menu: act on the whole edit selection when the right-clicked clip is part of it 2026-10-30 11:35:49 +09:00
dotCooCoo dcdcca4704 Zoom Toggle: a cancel with Zoom Toggle off keeps the scroll to restore
Cancel Zoom Toggle clears the saved vertical scroll only when it turns
Zoom Toggle off, so a cancel run before the next frame after turning it
off no longer drops the scroll position the toggle-off restores.
2026-10-28 03:31:06 -07:00
Daniel fd9c673694 Merge upstream main and reconcile Linux Wayland CSD dependencies
Preserve Unix X11 support, add Linux Adwaita no-title controls,
refresh Cargo.lock and keep MIT notice in existing packaging paths.
2026-10-28 10:48:57 +01:00
dotCooCoo 1ffc164082 Merge commit 'refs/check/mw-soundcraft-main' into feat/zoom-toggle-cancel
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/edit_window.rs
#	crates/ui-egui/src/lib.rs
#	docs/ui-parity.md
2026-10-28 02:03:56 -07:00
afa1532aba Mix: clamp clip-FX gain and send-level automation (fixes #884)
A shared session or an MCP clip.effects_set could store clip-FX gain or a
send-level automation point far above any fader (up to the +200 dB cap in
db_to_gain). Clamp clip-FX gain to the slider's +24 dB, send-level lanes to
+12 dB, and every loaded automation point to its AutoParam range on sanitize.

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

When the command runs, from a key, the button or the control channel,
the Edit window clears the vertical scroll position that Zoom Toggle
would restore.
2026-10-25 17:17:19 -07:00
Daniel 1f8b267722 Merge upstream main and resolve GNOME Wayland controls conflicts 2026-10-25 23:57:47 +01:00
Sly LaMenace 95f25979eb docs(ui): document New Tracks changes 2026-10-24 21:57:11 +00:00
Sly LaMenace 929a7c5307 Merge remote-tracking branch 'upstream/main' into fix/new-tracks-keyboard-ux
# Conflicts:
#	crates/ui-egui/src/dialogs.rs
2026-10-24 21:50:52 +00:00
Sly LaMenace ee63b09cbe fix(ui): omit hidden New Tracks row fields 2026-10-24 21:45:19 +00:00
Daniel 4071975aa4 Merge upstream main; retain GNOME window controls
Integrate updated SoundCraft dependencies and packaging while preserving Linux winit CSD, required lockfile crates and license notices.
2026-10-24 10:22:45 +02:00
Sly LaMenace 9e9177f1af fix(ui): hide inapplicable New Tracks fields 2026-10-20 14:33:48 +00:00
Sly LaMenace 3ea1f34a1d feat(ui): add multiple New Tracks rows with fixed field widths 2026-10-20 14:33:48 +00:00
Sly LaMenace 6e8c039097 fix(ui): improve New Tracks keyboard navigation and field sizing 2026-10-20 14:33:15 +00:00
Daniel 5d7197a2d0 fix(linux): keep GNOME Wayland controls without redundant NOTICE copy 2026-10-20 03:53:35 +02:00
81 changed files with 2357 additions and 333 deletions
+1
View File
@@ -19,6 +19,7 @@ jobs:
# The shared workflow creates the draft release, which needs write access to contents.
permissions:
contents: write
packages: write
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with:
+4 -9
View File
@@ -2,11 +2,9 @@
SoundCraft is a clean-room, open-source, pure-Rust digital audio workstation targeting Avid Pro Tools parity — and superiority (speed, openness, agent control). It runs natively on macOS, Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conventions: `../photocraft` (Photoshop), `../vectorcraft` (Illustrator), `../filmcraft` (Premiere), `../lightcraft` (Lightroom), `../pdfcraft` (Acrobat), `../effectcraft` (After Effects), `../designcraft` (InDesign).
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out next to the craft-apps folder, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a session, follow its standards, and contribute reusable learnings back there. Never code: repos don't share code.
## Start every session here
1. Read `plan/STATUS.md` (current milestone, next task) and `plan/execution-plan.md`. `plan/` is gitignored (local only). Behaviour reference: `plan/protools/` (observed menu tree `menus-clean.txt` and screenshots; never committed).
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs follow craftrules `standards/progress-docs.md`: `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
2. Read `ROADMAP.md` (committed) for stage, status and the parity numbers; pick work from `docs/gaps.md`. The progress docs are `docs/target-app-parity.md` (assessment), `docs/gaps.md`, `docs/roadmap.md`, `docs/ui-parity.md`, `docs/file-format-parity.md`, `docs/hardware-parity.md`, `docs/plugin-parity.md`, `docs/localization-parity.md`; `docs/parity-checklist.md` is generated. Update their timestamps and revision history when you change them.
3. Work autonomously; don't stop to ask unless a decision is genuinely the user's.
## ⚠️ Assets: the absolute rule
@@ -22,7 +20,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
Pro Tools is installed on the dev machine and may be *observed* black-box: run it, use its UI with synthetic sessions, take screenshots by window id (`screencapture -l <id>`), stored only under `plan/protools/screenshots/`. Never read, disassemble or copy anything inside the Pro Tools bundle; never copy Avid icons, artwork, plugin names, presets, or wording beyond feature names. Never copy GPL/AGPL/LGPL code (Ardour, Audacity, LMMS, Freeverb sources…). Our plugins have our own names.
## Never crash
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.**
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
- The same exception, same rules, for `soundcraft-vst3-host` (`crates/vst3-host`), the isolated unsafe helper crate for VST3 plugin FFI (COM vtables via the `vst3` bindings, module loading, host-side COM objects).
@@ -67,7 +65,7 @@ Parity is measured, not guessed: `crates/engine/catalog/menus.txt` lists the inc
`cargo xtask ci` = fmt check, `clippy --workspace --all-targets -D warnings`, tests, assets, layers, wasm check. Commit after each arc of work that builds; one topic per commit.
## Release
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/release-playbook.md` and craftrules `release/playbook.md`.
Push to the `release` branch to build signed installers for macOS (universal DMG), Windows (x64/x86 MSI + zip), Linux (AppImage, deb, rpm, tar.gz, Flatpak; riscv64 tar.gz), FreeBSD and Web (wasm zip). Version: `cargo xtask version [set X.Y.Z]`. See `docs/releasing.md`.
## Roadmap
`ROADMAP.md` (committed) is the one-page summary (stage, numbers, dimensions, languages, progress log); milestones live in `docs/roadmap.md`. Update both whenever a milestone lands, and close or shrink the matching `docs/gaps.md` entry.
@@ -81,14 +79,11 @@ Push to the `release` branch to build signed installers for macOS (universal DMG
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
whether they want to be credited by more than their username: a real name, a display name, and/or
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
(copy the template at the top of the file, or run
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
(copy the template at the top of the file)
and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
GitHub profiles). Never hand-edit `contributors.json`.
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
+8 -8
View File
@@ -39,14 +39,14 @@ here. The subset uses default_fonts; the parent face uses monochrome_emoji_fonts
| Path | Author | Source | License |
|---|---|---|---|
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | craftrules `assets/brand/` | ArtCraft brand terms |
| `docs/brand/artcraft-logo.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms (`docs/brand/LICENSE-brand.txt`) |
| `docs/brand/artcraft-logo.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.svg` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand assets | ArtCraft brand terms |
| `assets/app-icon/icon.svg` | SoundCraft contributors | original (hand-placed SVG paths; placeholder app icon) | MIT OR Apache-2.0 |
| `packaging/macos/dmg/` (all files) | @XusBadia | original (macOS DMG window background and layout, from the app icon; see `packaging/macos/dmg/README.md`) | MIT OR Apache-2.0 |
| `assets/app-icon/soundcraft-1024.png` | SoundCraft contributors | original, rendered from `icon.svg` | MIT OR Apache-2.0 |
Generated
+81 -32
View File
@@ -272,11 +272,17 @@ dependencies = [
"objc2-foundation 0.3.2",
"parking_lot",
"percent-encoding",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
"wl-clipboard-rs",
"x11rb",
]
[[package]]
name = "arrayref"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
[[package]]
name = "arrayvec"
version = "0.7.8"
@@ -1058,17 +1064,17 @@ dependencies = [
[[package]]
name = "craft-docpath"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"fixed_decimal",
"icu_decimal",
@@ -1091,7 +1097,7 @@ dependencies = [
[[package]]
name = "craft-media"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"thiserror 2.0.21",
]
@@ -1099,7 +1105,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"craft-fonts-build",
"craft-i18n",
@@ -1120,7 +1126,7 @@ dependencies = [
[[package]]
name = "craft-update"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -1534,7 +1540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3245,7 +3251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3881,7 +3887,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3938,7 +3944,7 @@ dependencies = [
"security-framework",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4006,6 +4012,17 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "sctk-adwaita"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
dependencies = [
"log",
"smithay-client-toolkit 0.19.2",
"tiny-skia",
]
[[package]]
name = "security-framework"
version = "3.7.0"
@@ -4280,7 +4297,7 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"chrono",
"clap",
@@ -4304,7 +4321,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"log",
"serde",
@@ -4314,7 +4331,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4326,7 +4343,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"clap",
"log",
@@ -4339,7 +4356,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"clap-sys",
"libloading 0.9.0",
@@ -4352,7 +4369,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"clap",
"clap_complete",
@@ -4369,7 +4386,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"log",
"proptest",
@@ -4380,7 +4397,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"craft-docpath",
"log",
@@ -4403,7 +4420,7 @@ dependencies = [
[[package]]
name = "soundcraft-midi"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4413,7 +4430,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"log",
"rayon",
@@ -4429,7 +4446,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4442,7 +4459,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"cpal",
"log",
@@ -4457,7 +4474,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"proptest",
"serde",
@@ -4467,7 +4484,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -4496,7 +4513,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"craft-media",
"image",
@@ -4508,7 +4525,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"libloading 0.9.0",
"log",
@@ -4524,7 +4541,7 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"eframe",
"egui",
@@ -4569,6 +4586,12 @@ version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "strict-num"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strsim"
version = "0.11.1"
@@ -4840,10 +4863,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"getrandom 0.4.3",
"once_cell",
"rustix 1.1.5",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4939,6 +4962,31 @@ dependencies = [
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
dependencies = [
"arrayref",
"arrayvec",
"bytemuck",
"cfg-if",
"log",
"tiny-skia-path",
]
[[package]]
name = "tiny-skia-path"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
dependencies = [
"arrayref",
"bytemuck",
"strict-num",
]
[[package]]
name = "tinystr"
version = "0.8.5"
@@ -5736,7 +5784,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5983,6 +6031,7 @@ dependencies = [
"raw-window-handle",
"redox_syscall 0.4.1",
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit 0.19.2",
"smol_str",
"tracing",
@@ -6140,7 +6189,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.15.0"
version = "0.17.0"
dependencies = [
"serde_json",
]
+5 -5
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.15.0"
version = "0.17.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -50,15 +50,15 @@ zip = { version = "9", default-features = false }
cpal = "0.18"
# Which file paths stored in a session may be opened.
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# Video containers and bitstreams, shared with the other craft apps.
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7" }
# craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
craft-update = { version = "=0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
# ui
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["serde"] }
egui = { version = "0.37" }
eframe = { version = "0.37", default-features = false }
# Monotonic clock on native and the browser (the `web_time::Instant` panics-free equivalent).
+31
View File
@@ -286,3 +286,34 @@ The Font Software may be sold as part of a larger software package but no copy o
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
Linux Wayland window decorations
-------------------------------
sctk-adwaita 0.10.1 (https://github.com/PolyMeilex/sctk-adwaita,
commit d5f343c45c04d4a026db57f76760819b7325db00) is used by winit on Linux
for vector window controls. Its title-font features are disabled: no Cantarell
font is embedded by this dependency and no font file is added to this repo.
sctk-adwaita's MIT licence:
MIT License
Copyright (c) 2022 Bartłomiej Maryńczak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+19 -1
View File
@@ -168,6 +168,16 @@ Useful shortcuts: <kbd>Space</kbd> play/stop, <kbd>⌘</kbd><kbd>=</kbd> Mix/Edi
separate, <kbd>⌘</kbd><kbd>D</kbd> duplicate, <kbd>Enter</kbd> new marker, <kbd>⌘</kbd><kbd>⇧</kbd><kbd>N</kbd>
new tracks. **Setup › Keyboard Shortcuts** lists them all.
In **New Tracks**, each row has its own count, format, type, Samples/Ticks timebase and name.
Use **+** to add a row below or **−** to remove it; **Create** makes all rows in order, with one
Undo step (up to 128 tracks per creation). The last row stays available. The dropdown widths
fit their longest option and remain fixed when the selection changes.
On macOS, **⌘←/→** changes the active row's format, **⌘↑/↓** its type (also with **⌃**),
and **⌥⌘↑/↓** its timebase. **⇧⌃⌘↓/↑** adds/removes the active row, also with **⌥**;
**⇧⌘↓/↑** works too. Windows/Linux use **Ctrl** for **⌘**. The active row follows the
field you click. A new row starts with its count focused and selected; Enter creates and Escape cancels.
### Logs
The desktop app writes its `log` records to standard error and to `logs/soundcraft.log` in the
@@ -340,7 +350,15 @@ stand on its own.
| <img src="https://raw.githubusercontent.com/storytold/pdfcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.pdfcraft.png" alt="" width="32" height="32"> | **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
| <img src="https://raw.githubusercontent.com/storytold/effectcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.effectcraft.png" alt="" width="32" height="32"> | **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
| <img src="https://raw.githubusercontent.com/storytold/designcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.designcraft.png" alt="" width="32" height="32"> | **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | **Recording, editing and mixing audio · you are here** | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/figcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.figcraft.png" alt="" width="32" height="32"> | **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
| <img src="https://raw.githubusercontent.com/storytold/wordcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.wordcraft.png" alt="" width="32" height="32"> | **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
| <img src="https://raw.githubusercontent.com/storytold/soundcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.soundcraft.png" alt="" width="32" height="32"> | **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
| <img src="https://raw.githubusercontent.com/storytold/deckcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.deckcraft.png" alt="" width="32" height="32"> | **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
| <img src="https://raw.githubusercontent.com/storytold/cadcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.cadcraft.png" alt="" width="32" height="32"> | **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
| <img src="https://raw.githubusercontent.com/storytold/gridcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.gridcraft.png" alt="" width="32" height="32"> | **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
| <img src="https://raw.githubusercontent.com/storytold/texturecraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.texturecraft.png" alt="" width="32" height="32"> | **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
| <img src="https://raw.githubusercontent.com/storytold/craft-anything/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.craft-anything.png" alt="" width="32" height="32"> | **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
| <img src="https://raw.githubusercontent.com/tangency-labs/solidcraft/main/assets/icons/app/linux/exports/hicolor/64x64/apps/io.tangencylabs.solidcraft.png" alt="" width="32" height="32"> | **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
+6 -4
View File
@@ -2,7 +2,7 @@
**Stage: pre-alpha** · next: alpha, ~5% and ~110–180 h away (the alpha gate's three failing workflows)
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-10-25 · **Change:** trivial (Cancel Zoom Toggle, side panels in dock columns, Auto Pan stereo balance, Clip List Auto Rename, Strip Silence rectangles, Strip Silence Rename, Strip Silence Extract and Separate, Clip Looping dialog, AudioSuite Vari-Fi speed ramps, Auto Pan plugin, Subharmonic plugin, Ring Modulator plugin, Float32 file headroom) · **Target:** Avid Pro Tools Ultimate 2026.4.1
SoundCraft is a clean-room, pure-Rust digital audio workstation that aims at full parity with
Avid Pro Tools, and then beyond it: faster, open, scriptable and agent-controllable. This page is
@@ -122,6 +122,7 @@ mutated and truncated movies. Field reports from the first public week are track
## Progress log
- **2026-10-25:** Alt+Shift+E (`view.zoom_toggle_cancel`) and an Alt-click on the Zoom Toggle button cancel Zoom Toggle and keep the current view: the zoom, the scroll and the track heights stay as they are, and the button is no longer lit. The next Zoom Toggle zooms in from that view and goes back to it. No readiness percentage change.
- **2026-10-25:** Tracks and Groups and the Clip List sit in dock columns that run the Edit window's full height at its left and right edges, with the MIDI Editor under the session view between them; Mix docks Tracks and Groups at its left edge. A panel dragged onto a dock group joins it as a tab, stacks above or below it, or makes a new column; one dragged to an empty window edge makes a dock there. A list with no saved place reopens in its dock, and makes the dock again when it is gone. Preferences and window configurations saved with the earlier split-frame layouts load with the new default layouts and keep their other settings. No readiness percentage change.
- **2026-10-25:** Auto Pan moves the balance of a stereo track: the left channel's level falls as the position moves right and the right channel's as it moves left, at constant power, and each channel stays on its own side. At the center the track passes unchanged, so a wide or out-of-phase stereo image keeps its width, and the envelope follower follows the louder channel. No readiness percentage change.
- **2026-10-24:** Clip List › Auto Rename (`clip.auto_rename`) sets the names that editing gives the clips it separates from the selected clips: a name, a starting number, a number of places (zeros before the number) and a suffix. The piece after each cut takes the next name, the piece before it keeps its name, and all the pieces count up one number sequence. Pieces that Strip Silence names with its own Rename fields, or removes, take no Auto Rename name. Copied clips keep their scheme when they are pasted, saved sessions hold the schemes, and Import Session Data brings them along. No readiness percentage change.
@@ -156,8 +157,8 @@ mutated and truncated movies. Field reports from the first public week are track
- **2026-10-12 (Mix in a separate window):** Window › Mix in Separate Window shows the Mix window in its own native window, so Edit and Mix can be on screen at once (e.g. one per monitor), as in Pro Tools. No readiness percentage change.
- **2026-10-12 (audio device settings):** Setup › Playback Engine chooses the audio system, output and input device, sample rate and buffer size, kept per machine with the preferences; 16/32-bit integer devices are converted; ASIO is available behind the opt-in `asio` build feature (verified with an SSL 2+ at 128 samples). Channel routing, one duplex clock and latency compensation remain (G-01). No readiness percentage change.
- **2026-10-10 (UI docking):** Tracks and Groups, Clips, MIDI Editor, Memory Locations, and Undo History use movable panels. Edit and Mix retain separate arrangements; Clips and MIDI Editor belong to Edit. Transport, plug-in, and specialist utility windows retain their existing hosts. Panel moves and reset are available as UI commands, and saved UI preferences retain arrangements. Common toolbar and track record-enable buttons share interaction, accessible labels and keyboard focus through `craft-ui`, retaining blue tool selection and red record-arm states. No readiness percentage change.
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the standard
progress-docs set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
- **2026-10-10** — Stage re-normalized to **pre-alpha** by the core-workflow gate (interface recording, MIDI recording and session interchange fail). Full re-measure against Pro Tools Ultimate 2026.4.1 and the progress docs
set (this page, target-app parity, gaps, roadmap, UI/format/hardware/plugin/
localization parity). v0.4.0 released. Community fixes: Cut All Automation, snap to neighbours,
tempo stretch, I/O Setup, clip export names, menu-only dialogs, playback stop on new session.
- **2026-10-09** — Light theme and live system appearance, clip-gain fader and nudge, missing-media
@@ -176,6 +177,7 @@ mutated and truncated movies. Field reports from the first public week are track
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | trivial | Progress log: Cancel Zoom Toggle; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: side panels in dock columns; readiness estimates unchanged |
| 2026-10-25 | trivial | Progress log: Auto Pan stereo balance; readiness estimates unchanged |
| 2026-10-24 | trivial | Progress log: Clip List Auto Rename; readiness estimates unchanged |
@@ -204,6 +206,6 @@ mutated and truncated movies. Field reports from the first public week are track
| 2026-10-10 | minor | Readiness-by-audience table with hours: full 950–1,450 h, mainstream 380–600 h, essentials 110–200 h |
| 2026-10-10 | minor | Added mainstream practitioner ~36 % and essentials user ~57 % to the headline numbers |
| 2026-10-10 | minor | Stage alpha → pre-alpha: the core-workflow gate fails on interface recording, MIDI recording and session interchange; alpha distance added |
| 2026-10-10 | major | Rewritten to the progress-docs standard: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-10 | major | Progress docs restructured: stage alpha (re-normalized the same day), two numbers (breadth ~70 %, ready ~40 %), dimension/feature/language tables, hours re-calibrated; area table moved to docs/target-app-parity.md, milestones to docs/roadmap.md |
| 2026-10-07 | major | Menu parity 94 %, ~65 % estimated feature parity, alpha list |
| 2026-10-06 | minor | Alpha and parity estimates |
+5 -1
View File
@@ -27,9 +27,13 @@ serde_json.workspace = true
log.workspace = true
chrono.workspace = true
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "linux"))))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Preserve X11 fallback while enabling font-free Wayland client-side controls.
winit = { version = "0.30", default-features = false, features = ["x11", "wayland-csd-adwaita-notitle"] }
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rfd.workspace = true
+1 -1
View File
@@ -1,7 +1,7 @@
# SoundCraft app icon (placeholder)
**A nightingale singing**, as an engraving-style head-and-shoulders portrait in the Crafting Apps'
owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colour field, the bird
owl-template framing: a full-bleed colour field, the bird
in three-quarter view with its bill open and its eye on the viewer, its breast and wing running off
the bottom and right edges, three song arcs leaving the bill.
+6 -5
View File
@@ -1,13 +1,10 @@
# Contributor names for the About window (craftrules standards/contributors.md).
# Contributor names for the About window.
#
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
# maintainer may add a person only when that person asked, recording where in consent_source.
#
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# Copy this block, replacing your-github-username (every field is optional):
#
# [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -68,3 +65,7 @@ sync_github_name = true
[people.dylanmtaylor]
sync_github_name = true
[people.kk376]
real_name = "Kushagra Kumar"
sync_github_name = true
+1 -1
View File
@@ -13,7 +13,7 @@ serde.workspace = true
thiserror.workspace = true
log.workspace = true
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-vst3-host: this crate
# An isolated unsafe helper crate, like soundcraft-vst3-host: this crate
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+1 -1
View File
@@ -9,7 +9,7 @@
//! values are the Audio Unit's own (plain) values, ranges and units, so no mapping is needed.
//! Boolean parameters become toggles, indexed ones starting at 0 become choices.
//!
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate (craftrules never-crash):
//! Like `soundcraft-vst3-host`, this is an isolated `unsafe` crate:
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module (hand-written
//! AudioToolbox / CoreFoundation declarations). The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: an Audio Unit that itself
+15 -4
View File
@@ -20,7 +20,7 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
// Whether the packet table lists each packet's size and frame count (variable per packet).
let (mut lists_sizes, mut lists_frames, mut described) = (false, false, false);
while let (Some(kind), Some(size)) = (b.get(pos..pos + 4), be_u64(b, pos + 4)) {
let (body, size) = (pos + 12, size as i64);
let body = pos + 12;
let used = match kind {
b"desc" if size == 32 => {
lists_sizes = be_u32(b, body + 16) == Some(0);
@@ -28,12 +28,12 @@ fn chunks(b: &[u8], mut pos: usize) -> Result<()> {
described = true;
Some(32)
}
b"data" if size == -1 => Some(4),
b"data" if size == u64::MAX => Some(4),
b"chan" if size >= 12 => be_u32(b, body + 8).map(|n| 12 + u64::from(n) * 20),
b"pakt" if size >= 24 && described => packet_table(b, body, lists_sizes, lists_frames)?,
b"kuki" if size >= 0 && size as u64 > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"kuki" if size > b.len().saturating_sub(body) as u64 => return Err(malformed("magic cookie")),
b"desc" | b"pakt" | b"chan" => None,
_ => u64::try_from(size).ok(),
_ => Some(size),
};
match used.filter(|_| described).and_then(|n| usize::try_from(n).ok()).and_then(|n| body.checked_add(n)) {
Some(next) if next < b.len() => pos = next,
@@ -85,3 +85,14 @@ fn varint(b: &[u8], at: usize) -> Option<(u64, usize)> {
}
None
}
#[cfg(test)]
mod tests {
#[test]
fn rejects_cookie_size_above_signed_range() {
let mut bytes = b"caff\0\x01\0\0kuki".to_vec();
bytes.extend_from_slice(&(1u64 << 63).to_be_bytes());
assert!(super::check(&bytes, 0).is_err());
}
}
+6 -3
View File
@@ -511,10 +511,13 @@ pub(crate) fn empty_stream_info(b: &[u8], at: usize) -> Option<AudioInfo> {
Some(AudioInfo { format: FileFormat::Flac, sample_format, sample_rate, channels, frames: 0, bwf: None })
}
/// Whether a comment in a (little-endian) Vorbis comment block claims more than the block holds.
/// The vendor string is skipped unread.
/// Whether a vendor string or comment in a (little-endian) Vorbis comment block claims more than
/// the block holds.
fn comments_overrun(b: &[u8], body: usize, end: usize) -> bool {
let Some(vendor) = le_u32(b, body) else { return false };
let Some(vendor) = le_u32(b, body).filter(|_| body.saturating_add(4) <= end) else { return false };
if vendor as usize > end - (body + 4) {
return true;
}
let at = (body + 4).saturating_add(vendor as usize);
let Some(count) = le_u32(b, at).filter(|_| at.saturating_add(4) <= end) else { return false };
overruns(b, at + 4, end, le_u32, std::iter::repeat_n(0, count as usize))
+10 -3
View File
@@ -24,14 +24,14 @@ pub struct Peaks {
struct Block {
min: f32,
max: f32,
squares: f32,
squares: f64,
}
impl Block {
const EMPTY: Block = Block { min: f32::INFINITY, max: f32::NEG_INFINITY, squares: 0.0 };
fn point(s: f32) -> Block {
Block { min: s, max: s, squares: if s.is_finite() { s * s } else { 0.0 } }
Block { min: s, max: s, squares: if s.is_finite() { f64::from(s) * f64::from(s) } else { 0.0 } }
}
fn of(samples: &[f32]) -> Block {
@@ -146,7 +146,7 @@ impl Peaks {
.map(|w| {
w.map(|(a, b)| {
let (s, e) = (a.floor() as usize, (b.ceil() as usize).saturating_add(1).min(self.len).min(samples.len()));
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f32).sqrt()
(self.sum(samples, s, e).squares / e.saturating_sub(s).max(1) as f64).sqrt() as f32
})
})
.collect()
@@ -215,6 +215,13 @@ mod tests {
assert!(p.rms_spans(&x, 0.0, 1.0, 0.0, 1)[0].is_some_and(f32::is_finite));
}
#[test]
fn finite_large_samples_keep_finite_rms() {
let samples = vec![1e20_f32; BLOCK];
let peaks = Peaks::build(&samples);
assert_eq!(peaks.rms_spans(&samples, 0.0, BLOCK as f64, 0.0, 1), vec![Some(1e20)]);
}
#[test]
fn rms_spans_match_the_samples() {
let x = signal(20_000);
+13 -1
View File
@@ -503,6 +503,7 @@ pub(crate) fn header(
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let byte_rate = sample_rate.checked_mul(u32::from(block_align)).ok_or_else(|| AudioError::Encode("WAV byte rate overflow".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
@@ -511,7 +512,7 @@ pub(crate) fn header(
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&byte_rate.to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
@@ -560,3 +561,14 @@ pub(crate) fn header(
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
Ok(head)
}
#[cfg(test)]
mod tests {
use super::header;
#[test]
fn rejects_byte_rate_overflow() {
let result = header(2, 1_100_000_000, (16, false), None, None, 0, false);
assert!(result.is_err(), "overflowing byte rate was accepted");
}
}
+12 -1
View File
@@ -110,6 +110,15 @@ fn zero_channels_is_malformed() {
assert!(matches!(decode(&aiff, None), Err(AudioError::Malformed(_))));
}
#[test]
fn aiff_negative_channel_count_is_malformed() {
let mut aiff = small(FileFormat::Aiff, BitDepth::Int16, 1);
let comm = aiff.windows(4).position(|w| w == b"COMM").unwrap();
aiff[comm + 8] = 0x80;
aiff[comm + 9] = 0;
assert!(probe(&aiff, None).is_err());
}
#[test]
fn zero_sample_rate_is_malformed() {
let wav = riff(&[(b"fmt ", fmt(1, 2, 0, 16)), (b"data", vec![0; 16])]);
@@ -366,6 +375,7 @@ fn packets_longer_than_any_codec_uses_are_refused() {
}
}
/// FLAC metadata blocks with field lengths exceeding their block boundary are refused as malformed.
#[test]
fn flac_metadata_fields_beyond_their_block_are_refused() {
let with_block = |kind: u8, body: &[u8]| {
@@ -376,7 +386,8 @@ fn flac_metadata_fields_beyond_their_block_are_refused() {
let picture = |data: u32| [&[0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0][..], &[0; 16], &data.to_be_bytes(), &[1, 2, 3]].concat();
assert_eq!(decode(&with_block(6, &picture(3)), None).unwrap().1.frames(), 300);
let comment = [&0u32.to_le_bytes()[..], &1u32.to_le_bytes(), &u32::MAX.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment)] {
let vendor_overflow = [&0x7fff_fff0u32.to_le_bytes()[..], &0u32.to_le_bytes()].concat();
for f in [with_block(6, &picture(0x7fff_fff0)), with_block(4, &comment), with_block(4, &vendor_overflow)] {
assert!(matches!(decode(&f, None), Err(AudioError::Malformed(m)) if m.contains("larger than its block")));
}
}
+15
View File
@@ -317,4 +317,19 @@ mod tests {
assert!(r.call("session.inspect", json!({})).is_ok());
assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 2);
}
#[test]
fn remote_returns_an_app_error_unchanged() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = ControlTarget { host: None, port: listener.local_addr().unwrap().port() };
std::thread::spawn(move || {
let Ok((mut sock, _)) = listener.accept() else { return };
let mut line = String::new();
let _ = BufReader::new(sock.try_clone().unwrap()).read_line(&mut line);
let _ = writeln!(sock, "{}", json!({"id": 1, "ok": false, "error": "transport.locate: nothing to reset"}));
});
let mut r = Remote::new(&addr);
let err = r.call("engine.execute", json!({"command": "transport.locate"})).unwrap_err();
assert_eq!(err, "transport.locate: nothing to reset");
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ harness = false
clap-sys = "0.5"
libloading = "0.9"
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
# The isolated unsafe helper crate: this crate opts out of the workspace
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+1 -1
View File
@@ -5,7 +5,7 @@
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
//!
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
//! This is SoundCraft's single isolated `unsafe` crate: `unsafe` is
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
//! (segfault) takes the process down with it; we cannot sandbox native code.
+15 -2
View File
@@ -127,8 +127,11 @@ pub fn remove_dc(ch: &mut [Vec<f32>]) {
if c.is_empty() {
continue;
}
let mean = c.iter().filter(|v| v.is_finite()).map(|&v| f64::from(v)).sum::<f64>() / c.len() as f64;
let m = mean as f32;
let (sum, n) = c.iter().filter(|v| v.is_finite()).fold((0.0f64, 0usize), |(s, n), &v| (s + f64::from(v), n + 1));
if n == 0 {
continue;
}
let m = (sum / n as f64) as f32;
c.iter_mut().for_each(|s| *s -= m);
}
}
@@ -889,4 +892,14 @@ mod tests {
assert_eq!(ch[0][50], 1.0);
fade(&mut ch, 1000, 1000, FadeShape::SCurve);
}
#[test]
fn remove_dc_ignores_non_finite_samples_in_the_mean() {
let mut ch = vec![vec![f32::NAN, 1.0, 1.0, 1.0]];
remove_dc(&mut ch);
assert!(ch[0][1..].iter().all(|x| x.abs() < 1e-6), "left over dc {:?}", ch[0]);
let mut all_bad = vec![vec![f32::NAN, f32::INFINITY]];
remove_dc(&mut all_bad);
assert!(all_bad[0][0].is_nan() && all_bad[0][1].is_infinite());
}
}
+6 -2
View File
@@ -324,11 +324,15 @@ fn position_param_raw(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Opt
Err(bad(cmd, format!("`{key}` must be samples, {{seconds}}, or a time string")))
}
/// Range from `start`/`end` params, else the edit selection.
/// Range from `start`/`end`/`length` params. A missing `start` defaults to the edit selection start;
/// with neither `end` nor `length` either, the whole edit selection is used.
pub fn range_param(e: &Engine, cmd: &str, p: &Value) -> Result<Range> {
let st = position_param(e, cmd, p, "start")?;
let mut st = if p.get("start").is_some_and(Value::is_null) { None } else { position_param(e, cmd, p, "start")? };
let en = position_param(e, cmd, p, "end")?;
let len = position_param(e, cmd, p, "length")?;
if st.is_none() && (en.is_some() || len.is_some()) {
st = Some(e.session().edit.selection.start);
}
match (st, en, len) {
(Some(a), Some(b), _) => Ok(Range::new(a, b)),
(Some(a), None, Some(l)) => Ok(Range::new(a, a.saturating_add(l))),
+68 -7
View File
@@ -20,14 +20,23 @@ pub fn specs() -> Vec<CommandSpec> {
)]
}
/// How many times longer than its input a process's output can be.
fn growth(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> f64 {
match name {
"time_stretch" => ratio,
"pitch_shift" => 2f64.powf(f64::from(semitones) / 12.0),
"varispeed" => f64::from(to) / sr,
"speed_ramp" if selection == SpeedRampSelection::Extend => 1.5,
_ => 1.0,
}
}
/// The most frames a process can take in: these processes cut their output at MAX_OFFLINE_SAMPLES frames.
fn frame_limit(name: &str, ratio: f64, semitones: f32, sr: f64, to: u32, selection: SpeedRampSelection) -> usize {
let longest = |grows: f64| (soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / grows) as usize;
match name {
"time_stretch" => longest(ratio),
"pitch_shift" => longest(2f64.powf(f64::from(semitones) / 12.0)),
"varispeed" => longest(f64::from(to) / sr),
"speed_ramp" => longest(if selection == SpeedRampSelection::Extend { 1.5 } else { 1.0 }),
"time_stretch" | "pitch_shift" | "varispeed" | "speed_ramp" => {
(soundcraft_dsp::offline::MAX_OFFLINE_SAMPLES as f64 / growth(name, ratio, semitones, sr, to, selection)) as usize
}
_ => usize::MAX,
}
}
@@ -76,14 +85,32 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
let selection = if get("selection", 0.0) >= 0.5 { SpeedRampSelection::Extend } else { SpeedRampSelection::FitTo };
let fades = get("fades", 1.0) >= 0.5;
let most = frame_limit(&name, ratio, semitones, sr.as_f64(), to, selection);
// Each clip once: a clip listed again would otherwise process the result of its first pass
// (an expanding process compounding its length every time).
let mut seen = std::collections::HashSet::new();
let ids: Vec<_> = ids.into_iter().filter(|id| seen.insert(*id)).collect();
// Refuse before processing anything. Every result is kept as a new source for the life of
// the command (and undo holds it), so cap the total output across clips, not just each one.
let grows = growth(&name, ratio, semitones, sr.as_f64(), to, selection).max(1.0);
let mut budget = soundcraft_mix::MAX_RENDER_SAMPLES;
for id in &ids {
let Some((track, clip)) = e.session().find_clip(*id) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels()).max(1);
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let frames = (clip.length.max(0) as f64 * grows).ceil() as usize;
budget = budget
.checked_sub(frames.saturating_mul(channels))
.ok_or_else(|| bad("audiosuite.process", "too much audio to process in one command"))?;
}
let mut done = 0;
for id in ids {
let Some((track, clip)) = e.session().find_clip(id).map(|(t, c)| (t, c.clone())) else { continue };
if !clip.is_audio() {
continue;
}
let channels = e.session().track(track).map_or(1, |t| t.channels());
crate::io::refuse_longer(e.session(), "audiosuite.process", &clip.name, "process", clip.length, most.min(crate::io::render_limit(channels)))?;
let mut audio = soundcraft_mix::render_clips(e.session(), track, clip.range());
// Render without the clip's own fades: re-render a copy without fades so they stay editable.
let mut tmp = e.session().clone();
@@ -350,6 +377,40 @@ mod tests {
assert_eq!(e.session().tracks[0].clips().len(), 1, "one clip, not one per duplicate listing");
}
#[test]
fn a_clip_listed_twice_is_processed_once() {
let (mut e, id) = gained_clip();
let out =
e.execute("audiosuite.process", &json!({"process": "time_stretch", "params": {"ratio": 4.0}, "clips": [id.0, id.0, id.0]})).unwrap();
assert_eq!(out["processed"], 1);
let c = e.session().tracks[0].clips()[0].clone();
assert!(c.length <= 64 * 4 + 64, "stretched once, not compounded: {}", c.length);
assert_eq!(e.session().sources.len(), 2, "one new source, not one per listing");
}
#[test]
fn process_refuses_a_total_budget_across_clips() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let src = crate::io::add_source(&mut s, "tone", buf, None, FileFormat::Wav);
// Three clips each within the per-clip limit whose results together exceed the budget.
let len = (soundcraft_mix::MAX_RENDER_SAMPLES / 3 + 1) as i64;
let ids: Vec<u64> = (0..3)
.map(|k| {
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, k * len, len));
id.0
})
.collect();
let mut e = crate::Engine::new(s);
let before = serde_json::to_value(e.session()).unwrap();
let out = e.execute("audiosuite.process", &json!({"process": "reverse", "clips": ids}));
let ok = matches!(out, Err(crate::EngineError::BadParams(_, ref m)) if m.contains("too much audio"));
assert!(ok, "three clips over budget, but got: {out:?}");
assert_eq!(serde_json::to_value(e.session()).unwrap(), before, "refused before processing anything");
}
#[test]
fn generate_refuses_a_total_budget_across_tracks() {
let mut e = Engine::new(Session::default());
+15 -2
View File
@@ -196,8 +196,14 @@ fn unloop(e: &mut Engine, p: &Value) -> Result<Value> {
fn effects_set(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = clip_ids_param(e, p);
let params = p.get("params").and_then(Value::as_object).ok_or_else(|| bad("clip.effects_set", "`params` must be an object of numbers"))?;
let vals: Vec<(String, f64)> =
params.iter().filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x))).collect();
let vals: Vec<(String, f64)> = params
.iter()
.filter_map(|(k, v)| v.as_f64().filter(|x| x.is_finite()).map(|x| (k.chars().take(64).collect(), x)))
.map(|(k, v)| {
let v = if k == "gain" { v.clamp(f64::from(soundcraft_dsp::MIN_DB), f64::from(soundcraft_mix::CLIP_FX_MAX_GAIN_DB)) } else { v };
(k, v)
})
.collect();
let s = e.session_mut();
for c in &ids {
for (k, v) in &vals {
@@ -316,6 +322,13 @@ mod tests {
(Engine::new(s), t, ids)
}
#[test]
fn effects_set_caps_gain() {
let (mut e, _t, ids) = engine_with(&[(0, 100, 0)]);
e.execute("clip.effects_set", &json!({"clip": ids[0].0, "params": {"gain": 500.0}})).unwrap();
assert_eq!(e.session().edit.values.get(&format!("clip_fx.{}.gain", ids[0].0)).copied(), Some(24.0));
}
#[test]
fn unloop_removes_copies() {
let (mut e, t, ids) = engine_with(&[(0, 100, 0), (100, 100, 0), (200, 100, 0), (300, 100, 50)]);
+1 -7
View File
@@ -376,13 +376,7 @@ fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
let mut n = 0;
if range.is_empty() && !ids.is_empty() {
for t in &mut s.tracks {
if let Some(pl) = t.playlist_mut() {
let before = pl.clips.len();
pl.clips.retain(|c| !ids.contains(&c.id));
n += before - pl.clips.len();
}
}
n = ops::remove_clips(s, &ids, sh);
s.edit.selected_clips.clear();
} else {
for t in &tracks {
+45 -3
View File
@@ -115,11 +115,13 @@ fn tempo_constant(e: &mut Engine, p: &Value) -> Result<Value> {
let r = range_param(e, id, p)?;
let s = e.session();
let (t0, t1) = (s.tempo.samples_to_ticks(r.start, s.sample_rate), s.tempo.samples_to_ticks(r.end, s.sample_rate));
if t1 <= t0 {
return tempo(e, p);
}
let bpm = f64_or(p, "bpm", f64::NAN);
let s = e.session_mut();
if t1 <= t0 {
// An empty range sets the tempo at its start, which may come from the selection.
s.tempo.set_tempo(t0, bpm).map_err(|err| bad(id, err.to_string()))?;
return Ok(json!({"tick": t0, "bpm": bpm}));
}
// Read before the edit: the end is a musical position, and the new tempo moves it in samples.
let after = s.tempo.tempo_at_tick(t1);
let had_end = s.tempo.tempos().iter().any(|x| x.tick == t1);
@@ -427,6 +429,46 @@ mod tests {
use crate::demo::demo_engine;
use serde_json::json;
#[test]
fn tempo_constant_with_an_empty_range_sets_the_tempo_at_its_start() {
use soundcraft_time::Range;
let mut e = demo_engine();
e.session_mut().edit.selection = Range::point(96_000);
let r = e.execute("event.tempo_constant", &json!({"bpm": 90.0, "end": 96_000})).unwrap();
let s = e.session();
let tick = s.tempo.samples_to_ticks(96_000, s.sample_rate);
assert_eq!(r["tick"], tick);
assert_eq!(s.tempo.tempo_at_tick(tick), 90.0);
}
/// `start` is optional for insert/cut time: it defaults to the selection start, then `end`/`length` apply.
#[test]
fn insert_and_cut_time_without_start_use_selection_start() {
use soundcraft_time::Range;
let mut e = demo_engine();
let a = e.session().edit.selection.start;
let run = |e: &mut crate::Engine, sel: Range, id: &str, params: serde_json::Value| {
e.session_mut().edit.selection = sel;
e.execute(id, &params).unwrap()
};
// Point selection.
assert_eq!(run(&mut e, Range::point(a), "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
assert_eq!(run(&mut e, Range::point(a), "event.cut_time", json!({"length": 700}))["removed"], 700);
// A non-empty selection must not override the given `end`/`length`.
let sel = Range::new(a, a + 100);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"length": 48000}))["inserted"], 48000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"end": a + 2000}))["inserted"], 2000);
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"end": a + 5000}))["removed"], 5000);
// A null `start` takes the same path.
assert_eq!(run(&mut e, sel, "event.cut_time", json!({"start": null, "end": a + 3000}))["removed"], 3000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": null, "length": 900}))["inserted"], 900);
// An explicit `start` still wins over the selection.
let r = run(&mut e, sel, "event.cut_time", json!({"start": a + 1000, "end": a + 3000}));
assert_eq!(r["removed"], 2000);
assert_eq!(run(&mut e, sel, "event.insert_time", json!({"start": a + 1000, "length": 500}))["inserted"], 500);
}
/// The tempo-ruler dialog runs `event.tempo` with `bpm` and `at`; the change must land at that position.
#[test]
fn tempo_change_at_position_is_applied() {
+15 -1
View File
@@ -274,7 +274,12 @@ fn tempo_curve(e: &mut Engine, p: &Value, curve: Curve, id: &str) -> Result<Valu
fn tempo_stretch(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "event.tempo_stretch";
let (r, t0, t1) = selection_ticks(e, p, id)?;
// Here `length` is the new length of the selection, not part of the range.
let mut range_params = p.clone();
if let Some(m) = range_params.as_object_mut() {
m.remove("length");
}
let (r, t0, t1) = selection_ticks(e, &range_params, id)?;
let factor = match position_param(e, id, p, "length")? {
Some(l) if l > 0 => l as f64 / r.len() as f64,
_ => f64_or(p, "factor", f64::NAN),
@@ -901,6 +906,15 @@ mod tests {
}
}
/// For `event.tempo_stretch`, `length` is the new length of the selection, so the selection stays the range.
#[test]
fn tempo_stretch_length_keeps_selection_range() {
let mut e = Engine::default();
e.session_mut().edit.selection = Range::new(0, 96_000);
e.execute("event.tempo_stretch", &json!({"length": 192_000})).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(0), 60.0);
}
#[test]
fn tempo_curves() {
let mut e = Engine::default();
+33 -3
View File
@@ -7,6 +7,7 @@ use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, ClipContent, Group, GroupId, Playlist, Route, Session, Track, TrackHeight, TrackId, TrackKind};
/// Returns the track command specifications exposed to UI and automation clients.
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
@@ -14,7 +15,7 @@ pub fn specs() -> Vec<CommandSpec> {
"New...",
["Track"],
Some("Cmd+Shift+N"),
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id}",
"{count?: 1, format?: mono|stereo|5.1…, kind?: audio|aux|master|midi|instrument|vca|folder, name?, timebase?: samples|ticks, instrument?: plugin id, rows?: [objects with the same fields] (1–128 tracks total)}",
always,
new_track
),
@@ -231,7 +232,37 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Creates either one legacy row or several validated rows as one command.
fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
let ids = if let Some(rows) = p.get("rows") {
let rows = rows.as_array().ok_or_else(|| bad("track.new", "`rows` must be an array"))?;
if rows.is_empty() || rows.len() > 128 {
return Err(bad("track.new", "`rows` must contain 1–128 rows"));
}
let mut total = 0_i64;
for row in rows {
if !row.is_object() || row.get("rows").is_some() {
return Err(bad("track.new", "each row must be an object without nested rows"));
}
total = total.saturating_add(i64_or(row, "count", 1).clamp(1, 128));
}
if total > 128 {
return Err(bad("track.new", "a single creation is limited to 128 tracks"));
}
let mut ids = Vec::new();
for row in rows {
ids.extend(new_track_row(e, row)?);
}
ids
} else {
new_track_row(e, p)?
};
e.session_mut().edit.selected_tracks = ids.clone();
Ok(json!({"tracks": ids}))
}
/// All rows run inside one command, so failures roll back and Undo covers the whole creation.
fn new_track_row(e: &mut Engine, p: &Value) -> Result<Vec<TrackId>> {
let count = i64_or(p, "count", 1).clamp(1, 128);
let kind = match str_param(p, "kind") {
Some(k) => TrackKind::from_id(k).ok_or_else(|| bad("track.new", format!("unknown kind `{k}`")))?,
@@ -271,8 +302,7 @@ fn new_track(e: &mut Engine, p: &Value) -> Result<Value> {
}
ids.push(id);
}
s.edit.selected_tracks = ids.clone();
Ok(json!({"tracks": ids}))
Ok(ids)
}
fn group(e: &mut Engine, p: &Value) -> Result<Value> {
+3 -2
View File
@@ -9,9 +9,10 @@ pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "transport.play", "Play", [], Some("Space"), "{from?: position}", always, |e, p| {
if let Some(at) = position_param(e, "transport.play", p, "from")? {
e.transport_requests.push(TransportRequest::Locate(at.max(0)));
e.transport_requests.push(TransportRequest::PlayFrom(at.max(0)));
} else {
e.transport_requests.push(TransportRequest::Play);
}
e.transport_requests.push(TransportRequest::Play);
Ok(json!({}))
}),
cmd!(noundo "transport.stop", "Stop", [], Some("Space"), "{}", always, |e, _| { e.transport_requests.push(TransportRequest::Stop); Ok(json!({})) }),
+30 -2
View File
@@ -1,4 +1,5 @@
//! 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. Cancel Zoom Toggle turns it off
//! and keeps the current view.
use super::{CommandSpec, always, f64_or};
use soundcraft_model::{TrackHeight, ZoomToggle};
use soundcraft_time::MAX_POSITION;
@@ -7,7 +8,16 @@ use crate::{Engine, Result, cmd};
use serde_json::{Value, json};
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),
cmd!(noundo "view.zoom_toggle_cancel", "Cancel Zoom Toggle", [], Some("Alt+Shift+E"), "{}", always, cancel),
]
}
/// Forget the view Zoom Toggle would go back to, leaving the zoom, scroll and track heights as they are.
fn cancel(e: &mut Engine, _: &Value) -> Result<Value> {
let was_on = e.session_mut().edit.zoom_toggle.take().is_some();
Ok(json!({"on": false, "canceled": was_on}))
}
fn toggle(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -73,6 +83,24 @@ mod tests {
assert_eq!(view(&e), (500.0, 1000, vec![Medium, Fit(299), Medium]));
}
#[test]
fn cancel_keeps_the_zoomed_view_and_the_next_toggle_zooms_in_again() {
let mut e = engine(json!({"tracks": ["Audio 1", "Audio 3"], "start": 48_000, "end": 96_000}));
e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap();
let zoomed = view(&e);
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": true}));
assert_eq!(e.session().edit.zoom_toggle, None);
assert_eq!(view(&e), zoomed, "cancel keeps the view Zoom Toggle zoomed in to");
assert_eq!(e.execute("view.zoom_toggle_cancel", &json!({})).unwrap(), json!({"on": false, "canceled": false}));
assert_eq!(view(&e), zoomed);
e.execute("view.zoom_set", &json!({"samples_per_px": 200.0})).unwrap();
assert_eq!(e.execute("view.zoom_toggle", &json!({"width_px": 1000.0, "height_px": 500.0})).unwrap(), json!({"on": true}));
e.execute("view.zoom_toggle", &json!({})).unwrap();
assert_eq!(view(&e), (200.0, 48_000, vec![Fit(249), Medium, Fit(249)]), "the toggle goes back to the view it saw after the cancel");
let spec = crate::cmd::command_specs().iter().find(|c| c.id == "view.zoom_toggle_cancel").unwrap();
assert_eq!(spec.shortcut, Some("Alt+Shift+E"));
}
#[test]
fn hostile_sizes_and_stored_views_stay_in_range() {
let mut e = engine(json!({"tracks": ["Audio 1"], "start": 0, "end": MAX_POSITION}));
+20
View File
@@ -177,6 +177,26 @@ pub fn clear_range(s: &mut Session, track: TrackId, range: Range, shuffle: bool)
removed
}
/// Remove the clips `ids` from every track. In Shuffle the later clips on each track close the holes the movable
/// ones leave, as a Shuffle move lifts them (see [`lift_plan`]), so a crossfade partner keeps its material. Returns
/// how many clips went.
pub fn remove_clips(s: &mut Session, ids: &[ClipId], shuffle: bool) -> usize {
let ids: HashSet<ClipId> = ids.iter().copied().collect();
let mut n = 0;
for track in s.tracks.iter().map(|t| t.id).collect::<Vec<_>>() {
if shuffle {
n += lift_all(s, track, &ids).len();
}
// Locked clips don't lift; they go without closing up, as everything does outside Shuffle.
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
let before = pl.clips.len();
pl.clips.retain(|c| !ids.contains(&c.id));
n += before - pl.clips.len();
}
}
n
}
/// Copy the material inside `range` (clips trimmed to the range), relative to `range.start`.
pub fn copy_range(s: &Session, track: TrackId, range: Range) -> Vec<Clip> {
let t = TimeCtx::of(s);
+34
View File
@@ -159,6 +159,40 @@ fn clear_slip_and_shuffle() {
assert_eq!(clips(&s, t), vec![(1000, 1000, 1000), (2000, 2000, 3000)]);
}
#[test]
fn remove_clips_closes_each_gap_in_shuffle_only() {
let split = |shuffle| {
let (mut s, t) = session_with_clip();
separate_at(&mut s, t, 2000);
separate_at(&mut s, t, 3000);
separate_at(&mut s, t, 4000);
let ids: Vec<ClipId> = s.track(t).unwrap().clips().iter().map(|c| c.id).collect();
// The first and third pieces: [1000, 2000) and [3000, 4000).
assert_eq!(remove_clips(&mut s, &[ids[0], ids[2]], shuffle), 2);
clips(&s, t)
};
assert_eq!(split(false), vec![(2000, 1000, 2000), (4000, 1000, 4000)]);
assert_eq!(split(true), vec![(1000, 1000, 2000), (2000, 1000, 4000)]);
}
#[test]
fn remove_clips_in_shuffle_keeps_crossfade_partners_apart() {
// `(start, end)` of each clip; the second one goes.
let remove_second = |spans: &[(Samples, Samples)]| {
let (mut s, t) = session_with_clip();
let ids: Vec<ClipId> = spans.iter().map(|_| s.new_clip_id()).collect();
if let Some(pl) = s.track_mut(t).and_then(|t| t.playlist_mut()) {
pl.clips = ids.iter().zip(spans).map(|(id, (a, b))| Clip::audio(*id, "c", SourceId(500), *a, *a, b - a)).collect();
}
assert_eq!(remove_clips(&mut s, &[ids[1]], true), 1);
clips(&s, t).into_iter().map(|(start, len, _)| (start, start + len)).collect::<Vec<_>>()
};
// Crossfaded with the clip before: the hole starts where that one ends.
assert_eq!(remove_second(&[(0, 1050), (950, 2000), (2000, 3000)]), vec![(0, 1050), (1050, 2050)]);
// Crossfaded with the clip after: the hole ends where that one starts.
assert_eq!(remove_second(&[(0, 1000), (1000, 2050), (1950, 3000), (4000, 5000)]), vec![(0, 1000), (1000, 2050), (3050, 4050)]);
}
#[test]
fn copy_paste_overwrites() {
let (mut s, t) = session_with_clip();
+42 -4
View File
@@ -2,17 +2,33 @@ use super::{ChannelFormat, Clip, ClipContent, Engine, EngineError, Result, Sampl
/// Most meter changes a MIDI file may bring into the tempo map; scores have a handful.
pub(super) const MAX_MIDI_METERS: usize = 10_000;
/// Most tempo changes a MIDI file may bring into the tempo map: conversions walk the tempo events.
pub(super) const MAX_MIDI_TEMPOS: usize = 10_000;
/// Most synth tracks a MIDI file may create in the session.
pub(super) const MAX_MIDI_TRACKS: usize = 512;
/// Import a Standard MIDI File as MIDI tracks (one per SMF track).
pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: bool) -> Result<Vec<TrackId>> {
let smf = soundcraft_midi::read_smf(bytes).map_err(|err| EngineError::Io(err.to_string()))?;
let s = e.session_mut();
if tempo_map {
// Each meter is placed by walking the ones before it.
if smf.meters.len() > MAX_MIDI_METERS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_METERS} meter changes")));
}
if smf.tempos.len() > MAX_MIDI_TEMPOS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TEMPOS} tempo changes")));
}
}
let tracks_to_import = smf.tracks.iter().filter(|tr| !tr.sequence.notes.is_empty() || !tr.sequence.ctrls.is_empty()).count();
if tracks_to_import > MAX_MIDI_TRACKS {
return Err(EngineError::Io(format!("MIDI file: more than {MAX_MIDI_TRACKS} tracks")));
}
let s = e.session_mut();
if tempo_map {
for (tick, bpm) in &smf.tempos {
if s.tempo.tempo_at_tick(*tick) == *bpm {
continue;
}
let _ = s.tempo.set_tempo(*tick, *bpm);
}
for (tick, n, d) in &smf.meters {
@@ -66,15 +82,37 @@ pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
for c in t.clips() {
if let ClipContent::Midi { sequence } = &c.content {
let base = s.tempo.samples_to_ticks(c.start, s.sample_rate);
for n in &sequence.notes {
// Only what plays: notes starting inside the clip, cut at its end.
let clip_end = s.tempo.samples_to_ticks(c.end(), s.sample_rate);
for n in sequence.notes.iter().filter(|n| n.start.saturating_add(base) < clip_end) {
let mut n = *n;
n.start = n.start.saturating_add(base);
n.length = n.length.min(clip_end - n.start).max(1);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut kept: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in sequence.ctrls.iter().filter(|ev| ev.tick.saturating_add(base) < clip_end) {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
kept.push(ev);
}
// A sustain still held at the clip end is released there.
let mut release: Vec<soundcraft_midi::CtrlEvent> = Vec::new();
for ev in &kept {
if ev.kind != soundcraft_midi::CtrlKind::Cc(64) {
continue;
}
match release.iter_mut().find(|r| r.channel == ev.channel) {
Some(r) if ev.tick >= r.tick => *r = *ev,
Some(_) => {}
None => release.push(*ev),
}
}
seq.ctrls.append(&mut kept);
for mut r in release.into_iter().filter(|r| r.value >= 64) {
r.tick = clip_end;
r.value = 0;
seq.ctrls.push(r);
}
}
}
+72 -1
View File
@@ -1,6 +1,6 @@
use super::import::add_source;
#[cfg(test)]
use super::midi::{MAX_MIDI_METERS, import_midi_bytes};
use super::midi::{MAX_MIDI_METERS, MAX_MIDI_TEMPOS, MAX_MIDI_TRACKS, import_midi_bytes};
use super::{AudioBuffer, Clip, ClipContent, Engine, EngineError, FileFormat, Range, Result, Samples, Session, TrackId, TrackKind};
/// Consolidate `r` on a track into one new clip backed by a rendered source. The clip covers the
@@ -196,6 +196,77 @@ mod name_tests {
assert!(err.to_string().contains("meter changes"), "{err}");
}
/// A MIDI file with more tempo changes than MAX_MIDI_TEMPOS is refused without mutating the session.
#[test]
fn midi_with_more_tempo_changes_than_performances_have_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = |n: usize| soundcraft_midi::Smf {
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence: sequence.clone() }],
tempos: (0..n as i64).map(|beat| (beat * 960, 100.0 + (beat % 40) as f64)).collect(),
..Default::default()
};
let mut e = Engine::default();
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS + 1)).unwrap(), 0, true).unwrap_err();
assert!(err.to_string().contains("tempo changes"), "{err}");
assert_eq!((e.session().tempo.tempos().len(), e.session().tracks.len()), (1, 0), "nothing is imported");
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TEMPOS)).unwrap(), 0, true).unwrap();
assert_eq!(e.session().tempo.tempos().len(), MAX_MIDI_TEMPOS);
}
/// A MIDI file with more tracks than MAX_MIDI_TRACKS is refused without mutating the session.
#[test]
fn midi_with_more_tracks_than_supported_is_refused() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = |n: usize| soundcraft_midi::Smf {
tracks: (0..n).map(|i| soundcraft_midi::SmfTrack { name: format!("Tr {i}"), sequence: sequence.clone() }).collect(),
..Default::default()
};
let mut e = Engine::default();
let err = import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS + 1)).unwrap(), 0, false).unwrap_err();
assert!(err.to_string().contains("tracks"), "{err}");
assert_eq!(e.session().tracks.len(), 0, "no tracks imported");
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf(MAX_MIDI_TRACKS)).unwrap(), 0, false).unwrap();
assert_eq!(e.session().tracks.len(), MAX_MIDI_TRACKS);
}
/// Consecutive equal tempo events in a MIDI file are collapsed to actual tempo changes.
#[test]
fn midi_collapses_equal_consecutive_tempos() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = soundcraft_midi::Smf {
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
tempos: vec![(0, 120.0), (960, 120.0), (1920, 140.0), (2880, 140.0), (3840, 100.0)],
..Default::default()
};
let mut e = Engine::default();
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
let tempos = e.session().tempo.tempos();
assert_eq!(tempos.len(), 3);
assert!((tempos[0].bpm - 120.0).abs() < 1e-3);
assert!((tempos[1].bpm - 140.0).abs() < 1e-3);
assert!((tempos[2].bpm - 100.0).abs() < 1e-3);
}
/// A MIDI file replaces an existing session tempo event even if the MIDI file has consecutive equal tempos.
#[test]
fn midi_import_replaces_differing_existing_session_tempo() {
let note = soundcraft_midi::Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 };
let sequence = soundcraft_midi::Sequence { notes: vec![note], ..Default::default() };
let smf = soundcraft_midi::Smf {
tracks: vec![soundcraft_midi::SmfTrack { name: String::new(), sequence }],
tempos: vec![(0, 120.0), (960, 120.0)],
..Default::default()
};
let mut e = Engine::default();
e.session_mut().tempo.set_tempo(960, 80.0).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(960), 80.0);
import_midi_bytes(&mut e, &soundcraft_midi::write_smf(&smf).unwrap(), 0, true).unwrap();
assert_eq!(e.session().tempo.tempo_at_tick(960), 120.0);
}
#[test]
fn new_sources_take_the_first_free_file_name() {
let mut s = Session::default();
+2
View File
@@ -84,6 +84,8 @@ pub enum TransportRequest {
/// Stop the old session's transport without applying stop-time edits to the new document.
ResetSession,
Play,
/// Start playing at this position (or move there if already playing), ignoring the selection.
PlayFrom(Samples),
Stop,
TogglePlay,
Record,
+63 -3
View File
@@ -146,12 +146,20 @@ fn measure_grid(s: &Session, end_unit: i64) -> Vec<(i64, i64, u32, u32)> {
let a = s.tempo.bar_start_tick(bar);
let b = s.tempo.bar_start_tick(bar + 1);
let m = s.tempo.meter_at_tick(a);
let (ua, ub) = (a / UNIT, b / UNIT);
if ub <= ua {
if b <= a {
break;
}
v.push((ua, ub - ua, m.numerator, m.denominator));
let (ua, ub) = (a / UNIT, b / UNIT);
bar += 1;
if ub <= ua {
// A bar shorter than a sixteenth that does not reach the next unit: the following
// bar carries this span, so the grid stays contiguous.
continue;
}
// Bars shorter than a sixteenth are written as one measure of the units they cover, so
// the time signature matches the measure's content.
let (num, den) = if m.ticks_per_bar() < UNIT { (u32::try_from(ub - ua).unwrap_or(1), 16) } else { (m.numerator, m.denominator) };
v.push((ua, ub - ua, num, den));
if ub >= end_unit.max(1) || v.len() >= MAX_MEASURES {
break;
}
@@ -644,6 +652,58 @@ mod tests {
assert!(s.starts_with("<svg") && s.contains("<ellipse"));
}
/// Note onsets (pitch count of every chord that is not a tie continuation).
fn note_starts(parts: &[Part]) -> usize {
parts.iter().flat_map(|p| &p.measures).flat_map(|m| &m.items).filter(|i| !i.tie_stop).map(|i| i.pitches.len()).sum()
}
#[test]
fn bars_shorter_than_a_sixteenth_keep_every_note() {
let base = crate::demo::demo_engine();
let baseline = note_starts(&layout(base.session(), &[]));
assert!(baseline > 0, "demo has notes");
for (n, d) in [(1, 32), (1, 64), (2, 64), (3, 64)] {
for mid in [false, true] {
let mut e = crate::demo::demo_engine();
let tick = if mid { e.session().tempo.bar_start_tick(5) } else { 0 };
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
let parts = layout(e.session(), &[]);
assert!(parts.iter().all(|p| !p.measures.is_empty()), "{n}/{d} mid={mid}: empty part");
assert_eq!(note_starts(&parts), baseline, "{n}/{d} mid={mid}: notes lost");
for p in &parts {
for m in &p.measures {
let units: u32 = m.items.iter().map(|i| i.units).sum();
// The written meter matches the measure's content (divisions are sixteenths).
assert_eq!(units * m.denominator, 16 * m.numerator, "{n}/{d} mid={mid}: meter/content mismatch");
}
}
let setup = ScoreSetup::from_session(e.session());
let x = musicxml(&parts, &setup).expect("musicxml fits");
assert!(x.contains("<pitch>"), "{n}/{d} mid={mid}: musicxml has no notes");
let s = svg(&parts, &setup).expect("svg fits");
assert!(s.contains("<ellipse"), "{n}/{d} mid={mid}: svg has no notes");
}
}
}
#[test]
fn bars_of_a_sixteenth_a_32nd_and_a_64th_in_a_row_keep_a_contiguous_grid() {
let base = crate::demo::demo_engine();
let baseline = note_starts(&layout(base.session(), &[]));
let mut e = crate::demo::demo_engine();
// Bars 5 to 8 are 1/16, 1/32, 1/64 and 3/64 long, then 4/4 resumes.
for (bar, (n, d)) in (5..).zip([(1, 16), (1, 32), (1, 64), (3, 64), (4, 4)]) {
let tick = e.session().tempo.bar_start_tick(bar);
e.session_mut().tempo.set_meter(tick, n, d).expect("valid meter");
}
let grid = measure_grid(e.session(), i64::MAX);
assert_eq!(grid.first().map(|m| m.0), Some(0));
for w in grid.windows(2) {
assert_eq!(w[0].0 + w[0].1, w[1].0, "measures leave no gap or overlap: {w:?}");
}
assert_eq!(note_starts(&layout(e.session(), &[])), baseline, "notes lost");
}
#[test]
fn output_is_bounded_by_byte_budget() {
// A part with many notes must not produce an unbounded document.
+16
View File
@@ -299,6 +299,22 @@ fn shuffle_clear_moves_automation_with_the_clips() {
assert_eq!(points(&e, t), [0, 7_000, 18_000, 27_000]);
}
/// Clearing selected clips by id closes their gaps in Shuffle, as clearing their ranges does, and moves the
/// automation with the clips.
#[test]
fn shuffle_clear_by_clip_id_closes_each_gap() {
for (mode, want, want_points) in [
("shuffle", [(0, 10_000), (20_000, 10_000)], [0, 9_000, 20_000, 29_000]),
("slip", [(0, 10_000), (40_000, 10_000)], [0, 9_000, 40_000, 49_000]),
] {
let (mut e, t, ids) = long(mode, &[(0, 10_000, 0), (20_000, 10_000, 0), (30_000, 10_000, 0), (40_000, 10_000, 0)]);
set_points(&mut e, t, &[0, 9_000, 40_000, 49_000]);
run(&mut e, "edit.clear", json!({"clips": [ids[1].0, ids[2].0], "tracks": [t.0], "start": 0}));
assert_eq!(clips(&e, t), want, "{mode}");
assert_eq!(points(&e, t), want_points, "{mode}");
}
}
#[test]
fn relative_grid_pastes_land_on_grid_lines_at_any_tempo() {
let (mut e, t, [a, _]) = engine("relative_grid");
+1
View File
@@ -13,6 +13,7 @@ mod long_renders;
mod midi_offset;
mod min_secs_with_timecode_counter;
mod mix_required_params;
mod new_track_rows;
mod random_edits;
mod recording;
mod session_transport;
+68
View File
@@ -28,3 +28,71 @@ fn midi_track_offset_moves_the_notes_like_moving_the_clip() {
e.execute("edit.move_clips", &json!({"clip": clip, "by": 24_000})).unwrap();
assert!(render(&e) == offset, "the offset must match moving the clip by 24000 samples");
}
#[test]
fn export_midi_leaves_out_what_a_trimmed_clip_no_longer_plays() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Trim", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 60, "start_ticks": 0, "length_ticks": 960, "velocity": 100})).unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 62, "start_ticks": 800, "length_ticks": 960, "velocity": 100})).unwrap();
e.execute("midi.note_add", &json!({"clip": clip, "pitch": 64, "start_ticks": 2000, "length_ticks": 960, "velocity": 100})).unwrap();
// 24000 samples is 960 ticks at 120 bpm.
let id = e.session().tracks[0].clips()[0].id;
e.session_mut().find_clip_mut(id).unwrap().trim_end_to(24_000);
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let notes = &smf.tracks[0].sequence.notes;
assert_eq!(notes.len(), 2, "only notes starting inside the clip are exported");
let late = notes.iter().find(|n| n.pitch == 62).unwrap();
assert_eq!(late.start + late.length, 960, "a note running past the clip end is cut at it");
}
#[test]
fn export_midi_releases_a_sustain_held_past_the_trim() {
use soundcraft_midi::{CtrlEvent, CtrlKind};
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Sus", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let id = e.session().tracks[0].clips()[0].id;
{
let clip = e.session_mut().find_clip_mut(id).unwrap();
if let soundcraft_model::ClipContent::Midi { sequence } = &mut clip.content {
let cc = |tick, channel, value| CtrlEvent { tick, channel, kind: CtrlKind::Cc(64), value };
sequence.ctrls = vec![cc(100, 0, 127), cc(200, 1, 127), cc(300, 1, 0), cc(2000, 0, 0)];
}
clip.trim_end_to(24_000);
}
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let ctrls = &smf.tracks[0].sequence.ctrls;
let sus: Vec<_> = ctrls.iter().filter(|c| c.kind == CtrlKind::Cc(64)).collect();
assert!(sus.iter().any(|c| c.channel == 0 && c.tick == 960 && c.value == 0), "{sus:?}");
assert!(!sus.iter().any(|c| c.channel == 1 && c.tick == 960), "released channel gets no extra event: {sus:?}");
}
#[test]
fn export_midi_keeps_the_notes_of_a_clip_trimmed_at_both_ends_in_place() {
let mut e = Engine::default();
let r = e.execute("track.new", &json!({"kind": "instrument", "name": "Both", "instrument": "subtractive_synth"})).unwrap();
let track = r["tracks"][0].as_u64().unwrap();
let r = e.execute("midi.new_clip", &json!({"track": track, "start": 0, "length": 96_000})).unwrap();
let clip = r["clip"].as_u64().unwrap();
for (pitch, start) in [(60, 0), (62, 1000), (64, 2000)] {
e.execute("midi.note_add", &json!({"clip": clip, "pitch": pitch, "start_ticks": start, "length_ticks": 960, "velocity": 100})).unwrap();
}
// 24000 samples is 960 ticks and 72000 samples is 2880 ticks at 120 bpm.
e.execute("edit.trim_start_to_insertion", &json!({"tracks": [track], "at": 24_000})).unwrap();
e.execute("edit.trim_end_to_insertion", &json!({"tracks": [track], "at": 72_000})).unwrap();
let bytes = soundcraft_engine::io::export_midi(&e, None).unwrap();
let smf = soundcraft_midi::read_smf(&bytes).unwrap();
let mut notes: Vec<_> = smf.tracks[0].sequence.notes.iter().map(|n| (n.pitch, n.start, n.length)).collect();
notes.sort_unstable();
// The note cut by the start trim is gone; the others keep their place and the last stops at the end.
assert_eq!(notes, vec![(62, 1000, 960), (64, 2000, 880)]);
}
+82
View File
@@ -0,0 +1,82 @@
use serde_json::json;
use soundcraft_engine::Engine;
use soundcraft_model::{ChannelFormat, TrackKind};
/// Mixed rows preserve their order and share one selection and history step.
#[test]
fn mixed_rows_create_in_order_select_every_track_and_undo_in_one_step() {
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Existing"})).unwrap();
let before = e.session().to_json().unwrap();
let result = e
.execute(
"track.new",
&json!({"rows": [
{"count": 2, "kind": "audio", "format": "mono", "name": "Dialogue", "timebase": "samples"},
{"kind": "aux", "format": "stereo", "name": "Reverb", "timebase": "samples"},
{"kind": "instrument", "format": "stereo", "name": "Keys", "timebase": "ticks", "instrument": "subtractive_synth"},
{"kind": "folder", "format": "stereo", "name": "Music"}
]}),
)
.unwrap();
let s = e.session();
let created = &s.tracks[1..];
assert_eq!(created.len(), 5);
assert_eq!(
created.iter().map(|t| t.kind).collect::<Vec<_>>(),
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux, TrackKind::Instrument, TrackKind::Folder]
);
assert_eq!(
created.iter().map(|t| t.format).collect::<Vec<_>>(),
[ChannelFormat::Mono, ChannelFormat::Mono, ChannelFormat::Stereo, ChannelFormat::Stereo, ChannelFormat::Stereo]
);
assert!(!created[0].ticks_timebase);
assert!(created[3].ticks_timebase && created[3].instrument.is_some());
assert_eq!(s.busses.len(), 1);
let ids = created.iter().map(|t| t.id).collect::<Vec<_>>();
assert_eq!(s.edit.selected_tracks, ids);
assert_eq!(result["tracks"], json!(ids));
let after = s.to_json().unwrap();
assert!(e.undo());
assert_eq!(e.session().to_json().unwrap(), before);
assert!(e.redo());
assert_eq!(e.session().to_json().unwrap(), after);
}
/// A failure in a later row rolls the complete command back atomically.
#[test]
fn invalid_later_row_leaves_no_partial_creation_or_undo_step() {
for field in [json!({"kind": "unknown"}), json!({"format": "unknown"})] {
let mut e = Engine::default();
let before = e.session().to_json().unwrap();
assert!(e.execute("track.new", &json!({"rows": [{"kind": "folder"}, field]})).is_err());
assert_eq!(e.session().to_json().unwrap(), before);
assert!(!e.can_undo());
assert!(e.journal.is_empty());
}
}
/// Row structure and aggregate track count reject malformed or oversized requests.
#[test]
fn row_shape_and_total_size_are_bounded() {
for rows in [json!(null), json!({}), json!([]), json!([true]), json!([{"rows": []}]), json!([{"count": 128}, {}]), json!(vec![json!({}); 129])] {
let mut e = Engine::default();
assert!(e.execute("track.new", &json!({"rows": rows})).is_err(), "{rows}");
assert!(e.session().tracks.is_empty());
assert!(!e.can_undo());
}
let mut e = Engine::default();
e.execute("track.new", &json!({"rows": [{"count": 64}, {"count": 64}]})).unwrap();
assert_eq!(e.session().tracks.len(), 128);
}
/// Calls without `rows` retain the original defaults and per-row count clamp.
#[test]
fn legacy_single_row_parameters_keep_their_defaults_and_count_clamp() {
let mut e = Engine::default();
e.execute("track.new", &json!({"kind": "aux", "count": -1})).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Stereo);
e.execute("track.new", &json!({"count": 200})).unwrap();
assert_eq!(e.session().tracks.len(), 129);
}
+1 -1
View File
@@ -65,7 +65,7 @@ pub(crate) fn send_level(t: &Track, i: usize, base: f32, pos: Samples) -> f32 {
&& let Some(l) = t.lane(&AutoParam::SendLevel(u8::try_from(i).unwrap_or(0)))
&& !l.points.is_empty()
{
return l.value_at(pos, base);
return l.value_at(pos, base).clamp(soundcraft_dsp::MIN_DB, crate::SEND_LEVEL_MAX_DB);
}
base
}
+1 -1
View File
@@ -101,7 +101,7 @@ impl ClipFx {
c.set_param(p, v);
}
} else if k == "gain" {
self.gain_db = v;
self.gain_db = if v.is_nan() { 0.0 } else { v.clamp(soundcraft_dsp::MIN_DB, crate::CLIP_FX_MAX_GAIN_DB) };
}
}
}
+5
View File
@@ -89,6 +89,11 @@ use strip::{DelayLine, MAX_LATENCY};
use threads::run_strips;
pub use threads::{mark_audio_thread, on_audio_thread};
/// Top of the clip effects gain slider; a stored or wired gain is clamped to it (#884).
pub const CLIP_FX_MAX_GAIN_DB: f32 = 24.0;
/// Top of a send fader, the same +12 dB `AutoParam::SendLevel` ranges up to (#884).
pub const SEND_LEVEL_MAX_DB: f32 = 12.0;
/// Default (stereo) main mix width. The actual width is [`main_channels`].
pub const MAIN_CHANNELS: usize = 2;
+9
View File
@@ -224,6 +224,15 @@ fn sends_feed_aux_via_bus() {
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
}
#[test]
fn send_level_automation_is_capped_at_the_fader_top() {
// #884: a send lane point past +12 dB (pushed without `set_point`'s clamp) reads as +12.
let (mut s, t) = session_with_dc(0.5, 4800);
let tr = s.track_mut(t).unwrap();
tr.lane_mut(&AutoParam::SendLevel(0)).points.push(soundcraft_model::AutomationPoint { at: 0, value: 500.0 });
assert_eq!(send_level(s.track(t).unwrap(), 0, 0.0, 100), SEND_LEVEL_MAX_DB);
}
#[test]
fn render_bus_is_the_bus_not_the_main_mix() {
let (mut s, t) = session_with_dc(0.5, 4800);
+28 -2
View File
@@ -80,7 +80,8 @@ impl AutoParam {
}
/// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.
pub fn parse(s: &str) -> Option<AutoParam> {
let s = s.trim().to_ascii_lowercase();
let orig = s.trim();
let s = orig.to_ascii_lowercase();
match s.as_str() {
"volume" | "vol" => return Some(AutoParam::Volume),
"pan" | "pan1" => return Some(AutoParam::Pan(0)),
@@ -100,6 +101,8 @@ impl AutoParam {
}
if let Some(rest) = s.strip_prefix("plugin:") {
let (slot, param) = rest.split_once(':')?;
// ASCII lowercasing keeps byte offsets, so take the param from the original.
let param = orig.get(orig.len() - param.len()..)?;
return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });
}
None
@@ -158,7 +161,10 @@ impl AutomationLane {
if matches!(self.param, AutoParam::Mute | AutoParam::SendMute(_)) {
return a.value;
}
a.value + (b.value - a.value) * ((at - a.at) as f32 / (b.at - a.at) as f32)
// Widen so far-apart breakpoints cannot overflow i64.
let span = i128::from(b.at) - i128::from(a.at);
let off = i128::from(at) - i128::from(a.at);
a.value + (b.value - a.value) * (off as f64 / span as f64) as f32
}
(Some(a), _) => a.value,
_ => default,
@@ -255,6 +261,26 @@ impl AutomationLane {
mod tests {
use super::*;
#[test]
fn parse_keeps_plugin_param_case() {
let p = AutoParam::Plugin { slot: 0, param: "Cutoff".into() };
assert_eq!(AutoParam::parse("plugin:a:Cutoff"), Some(p));
assert_eq!(AutoParam::parse("Plugin:B:Mix"), Some(AutoParam::Plugin { slot: 1, param: "Mix".into() }));
assert_eq!(AutoParam::parse("Volume"), Some(AutoParam::Volume));
assert_eq!(AutoParam::parse("SEND_B_LEVEL"), Some(AutoParam::SendLevel(1)));
}
#[test]
fn value_at_far_apart_points_does_not_overflow() {
let mut l = AutomationLane::new(AutoParam::Volume);
l.set_point(-1, 0.0);
l.set_point(i64::MAX, -10.0);
let v = l.value_at(5, 0.0);
assert!(v.is_finite() && (-10.0..=0.0).contains(&v));
let w = l.value_at(i64::MAX - 1, 0.0);
assert!(w.is_finite() && (-10.0..=0.0).contains(&w));
}
#[test]
fn pan_label_max_does_not_overflow() {
assert_eq!(AutoParam::Pan(255).label(), "pan 256");
+18
View File
@@ -626,6 +626,8 @@ impl Session {
}
for l in &mut t.automation {
l.points.retain(|p| p.value.is_finite());
let (lo, hi, _) = l.param.range();
l.points.iter_mut().for_each(|p| p.value = p.value.clamp(lo, hi));
l.points.iter_mut().for_each(|p| bound(&mut p.at));
l.points.sort_by_key(|p| p.at);
}
@@ -905,6 +907,22 @@ mod tests {
assert!(s.next_id > t.0);
}
#[test]
fn loaded_automation_is_clamped_to_its_range() {
// #884: a session file can carry points outside a lane's range; load clamps them.
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tr = s.track_mut(t).unwrap();
for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] {
tr.lane_mut(&p).points.push(crate::AutomationPoint { at: 0, value: 500.0 });
}
let s = Session::from_json(&serde_json::to_string(&s).unwrap()).unwrap();
let tr = s.track(t).unwrap();
for p in [crate::AutoParam::Volume, crate::AutoParam::SendLevel(0)] {
assert_eq!(tr.lane(&p).unwrap().points[0].value, 12.0, "{p:?}");
}
}
#[test]
fn a_loaded_auto_name_scheme_leaves_room_for_its_number() {
let mut scheme = crate::AutoName { name: "n".repeat(300), suffix: "s".repeat(300), ..Default::default() };
+20
View File
@@ -120,6 +120,12 @@ pub fn parse_position(
if tc.minutes >= 60 || tc.seconds >= 60 || tc.frames >= rate.nominal().max(1) {
return Err(err());
}
if rate.is_drop() && tc.seconds == 0 && tc.minutes % 10 != 0 {
let dropped = if rate.nominal() >= 60 { 4 } else { 2 };
if tc.frames < dropped {
return Err(err());
}
}
Ok(tc.to_samples(rate, sr).saturating_sub(tc_start))
}
TimeFormat::FeetFrames => {
@@ -213,6 +219,20 @@ mod tests {
assert!(parse_position("", TimeFormat::BarsBeats, sr, &map, rate, 0, 0).is_err());
}
#[test]
fn drop_frame_skipped_labels_are_rejected() {
let (sr, map, _) = ctx();
for (rate, skipped) in [
(FrameRate::Fps29_97Drop, &["00:01:00;00", "00:01:00;01"][..]),
(FrameRate::Fps59_94Drop, &["00:01:00;00", "00:01:00;01", "00:01:00;02", "00:01:00;03"][..]),
] {
for text in skipped {
assert!(parse_position(text, TimeFormat::Timecode, sr, &map, rate, 0, 0).is_err(), "accepted skipped label {text} at {rate:?}");
}
assert!(parse_position("00:10:00;00", TimeFormat::Timecode, sr, &map, rate, 0, 0).is_ok());
}
}
#[test]
fn bar_lengths() {
let (sr, map, rate) = ctx();
+8 -1
View File
@@ -122,7 +122,7 @@ impl GridValue {
GridValue::Note { value, dotted, triplet } => {
let t0 = map.samples_to_ticks(at, sr);
let dt = Self::note_ticks(value, dotted, triplet, map, t0);
(map.tick_to_samples(t0 + dt, sr) - map.tick_to_samples(t0, sr)).max(1)
(map.tick_to_samples(t0.saturating_add(dt), sr).saturating_sub(map.tick_to_samples(t0, sr))).max(1)
}
GridValue::Seconds(s) => sr.samples(s.clamp(0.000_01, 3600.0)).max(1),
GridValue::Frames(f) => to_samples(rate.samples_per_frame(sr) * f.clamp(1, 1_000_000) as f64).max(1),
@@ -320,4 +320,11 @@ mod tests {
assert_eq!(GridValue::Samples(100).stepped(false), GridValue::Samples(10));
assert_eq!(GridValue::Samples(1).stepped(false), GridValue::Samples(1));
}
#[test]
fn snap_at_extreme_position_does_not_overflow() {
let sr = SampleRate::new(8000).unwrap();
let map = TempoMap::new(1000.0, 4, 4).unwrap();
let _ = GridValue::default().snap(i64::MAX, sr, &map, FrameRate::Fps30);
}
}
+19 -4
View File
@@ -251,10 +251,10 @@ impl TempoMap {
for (i, m) in self.meters.iter().enumerate() {
let next_tick = self.meters.get(i + 1).map(|n| n.tick);
let tpb = m.ticks_per_bar();
let bars_in_seg = next_tick.map(|nt| ((nt - cur_tick) + tpb - 1) / tpb);
let bars_in_seg = next_tick.map(|nt| ceil_div(nt.saturating_sub(cur_tick), tpb));
match bars_in_seg {
Some(n) if bar >= cur_bar + n => {
cur_bar += n;
Some(n) if bar >= cur_bar.saturating_add(n) => {
cur_bar = cur_bar.saturating_add(n);
cur_tick = next_tick.unwrap_or(cur_tick);
}
_ => return cur_tick.saturating_add((bar - cur_bar).saturating_mul(tpb)),
@@ -284,7 +284,7 @@ impl TempoMap {
if let Some(n) = self.meters.get(i + 1)
&& tick >= n.tick
{
bar += ((n.tick - seg_tick) + tpb - 1) / tpb;
bar = bar.saturating_add(ceil_div(n.tick.saturating_sub(seg_tick), tpb));
seg_tick = n.tick;
continue;
}
@@ -323,6 +323,11 @@ impl TempoMap {
}
}
/// Ceiling division for a non-negative `num` and positive `den`, without overflow.
fn ceil_div(num: i64, den: i64) -> i64 {
num / den + i64::from(num % den != 0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -337,6 +342,16 @@ mod tests {
assert_eq!(m.bar_beat_at(24_000, sr), BarBeat { bar: 1, beat: 2, tick: 0 });
}
#[test]
fn meter_near_max_tick_does_not_overflow() {
let mut m = TempoMap::default();
m.set_meter(i64::MAX, 7, 8).unwrap();
let bb = m.bar_beat_at_tick(i64::MAX);
assert!(bb.bar > 1);
assert!(m.bar_start_tick(bb.bar) > 0);
assert!(m.bar_start_tick(i64::MAX) > 0);
}
#[test]
fn minimum_bar_saturates_instead_of_overflowing() {
let m = TempoMap::default();
+3 -4
View File
@@ -1,8 +1,7 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules
//! standards/contributors.md). Nothing is read at run time.
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
//!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose people.toml
//! entry is verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`).
//!
+3
View File
@@ -30,6 +30,9 @@ impl SoundApp {
// Re-selecting a scrolling mode resumes the playhead follow.
self.edit_layout.follow_hold = false;
}
if id == "view.zoom_toggle_cancel" && v["canceled"] == true {
self.edit_layout.forget_zoom_back();
}
if id == "track.clear_clip_indicators" {
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
}
+15
View File
@@ -343,6 +343,21 @@ impl SoundApp {
self.play_from_selection();
}
}
TransportRequest::PlayFrom(at) => {
if self.is_playing() {
// Playing on from `at` drops the end and loop of the earlier selection.
if let Some(p) = &self.player {
p.play(at, None, None);
}
if let Some(sim) = &mut self.sim {
*sim = (at, None, None);
}
self.last_write_at = i64::MIN;
self.engine.transport.position = at;
} else {
self.start_play(at, None, None);
}
}
TransportRequest::Stop | TransportRequest::Pause => self.stop_play(),
TransportRequest::TogglePlay => {
if self.is_playing() {
+1 -1
View File
@@ -1,6 +1,6 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
//! build.rs from contributors/contributors.json; nothing is
//! read at run time.
use std::cmp::Ordering;
+259 -67
View File
@@ -23,6 +23,7 @@ use files::{OPEN_PENDING, default_dir, open_pending_session, path_dialog};
use strip_silence::{AUTO_NAME_LABELS, STRIP_NAME_LABELS, StripRectangles, auto_rename_action, strip_name_fields, strip_silence_action};
pub use strip_silence::{StripRects, strip_silence_rectangles};
pub mod new_tracks;
pub mod ops_windows;
pub(super) mod recent;
pub mod recovery;
@@ -30,7 +31,7 @@ pub mod spot;
#[derive(Debug, Clone)]
pub enum Dialog {
NewTracks { count: u32, format: String, kind: String, timebase: String, name: String },
NewTracks(new_tracks::NewTracks),
NewSession { name: String, sample_rate: u32, demo: bool },
Bounce { path: String, format: FileFormat, bit_depth: String, normalize: bool, dither: bool },
RenameTrack { id: TrackId, name: String },
@@ -82,9 +83,10 @@ pub struct Dialogs {
}
impl Dialogs {
/// Returns the stable UI identifier of the currently open dialog.
pub fn open_name(&self) -> Option<&'static str> {
self.open.as_ref().map(|d| match d {
Dialog::NewTracks { .. } => "new_tracks",
Dialog::NewTracks(_) => "new_tracks",
Dialog::NewSession { .. } => "new_session",
Dialog::Bounce { .. } => "bounce",
Dialog::RenameTrack { .. } => "rename_track",
@@ -166,9 +168,7 @@ impl Dialogs {
let ed = &e.session().edit;
return self.open_clear(e, ed.selected_clips.clone(), ed.selected_sources.clone());
}
"track.new" => {
Dialog::NewTracks { count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
"track.new" => Dialog::NewTracks(new_tracks::NewTracks::default()),
"session.new" => Dialog::NewSession { name: "Untitled".into(), sample_rate: 48_000, demo: false },
"file.bounce_mix" => {
let dir = e
@@ -281,54 +281,6 @@ fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<
Some(text)
}
/// The New Tracks form; the command to run once Create is pressed.
fn new_tracks_form(
ui: &mut egui::Ui,
count: &mut u32,
format: &mut String,
kind: &mut String,
timebase: &mut String,
name: &mut String,
enter: bool,
) -> Option<(String, Value)> {
ui.horizontal(|ui| {
let create = ui.label("Create");
ui.add(egui::DragValue::new(count).range(1..=128)).labelled_by(create.id);
ui.label("new");
let fmt = craft_ui::ComboBox::from_id_salt("nt_fmt").selected_text(format.as_str()).show_ui(ui, |ui| {
for f in soundcraft_model::ChannelFormat::ALL {
ui.selectable_value(format, f.label().to_string(), f.label());
}
});
fmt.response.accessible_name("Channel format");
let kind_combo = craft_ui::ComboBox::from_id_salt("nt_kind")
.selected_text(soundcraft_model::TrackKind::from_id(kind).map_or("Audio Track", |k| k.label()))
.show_ui(ui, |ui| {
for k in soundcraft_model::TrackKind::ALL {
if ui.selectable_label(kind == k.id(), k.label()).clicked() {
*kind = k.id().to_string();
*name = k.default_name().to_string();
}
}
});
kind_combo.response.accessible_name("Track kind");
ui.label("in");
let tb = craft_ui::ComboBox::from_id_salt("nt_tb").selected_text(if timebase == "ticks" { "Ticks" } else { "Samples" }).show_ui(ui, |ui| {
ui.selectable_value(timebase, "samples".to_string(), "Samples");
ui.selectable_value(timebase, "ticks".to_string(), "Ticks");
});
tb.response.accessible_name("Timebase");
});
ui.horizontal(|ui| {
ui.label("Name:");
crate::widgets::named_text_field(ui.add(egui::TextEdit::singleline(name).desired_width(240.0)), "Name");
});
if buttons(ui, "Create", enter) {
return Some(("track.new".into(), json!({"count": count, "format": format, "kind": kind, "timebase": timebase, "name": name})));
}
None
}
/// The Bounce form's file row: a path field and a Browse button that also picks the format.
fn bounce_file_row(app: &mut SoundApp, ui: &mut egui::Ui, path: &mut String, format: &mut FileFormat) {
ui.horizontal(|ui| {
@@ -462,6 +414,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let Some(mut d) = app.dialogs.open.take() else { return };
let mut keep = true;
let mut action: Option<(String, Value)> = None;
let mut bounce_params: Option<Value> = None;
let mut save_first: Option<(String, Value)> = None;
let confirmed = matches!(d, Dialog::SaveChanges { .. });
// Strip Silence's Rename button, and the naming scheme its dialog's OK keeps.
@@ -469,7 +422,7 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
let mut renamed: Option<StripNames> = None;
let strip_names = app.dialogs.strip_names.clone();
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewTracks(_) => "New Tracks",
Dialog::NewSession { .. } => "New Session",
Dialog::Bounce { .. } => "Bounce Mix",
Dialog::RenameTrack { .. } => "Rename Track",
@@ -498,11 +451,15 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -80.0))
.show(ctx, |ui| {
// Escape owns the whole frame: no focused button or other control may act first.
if esc {
return;
}
ui.spacing_mut().item_spacing = vec2(8.0, 8.0);
match &mut d {
Dialog::NewTracks { count, format, kind, timebase, name } => {
if let Some(cmd) = new_tracks_form(ui, count, format, kind, timebase, name, enter) {
action = Some(cmd);
Dialog::NewTracks(tracks) => {
if let Some(params) = tracks.show(ui, enter) {
action = Some(("track.new".into(), params));
}
}
Dialog::NewSession { name, sample_rate, demo } => {
@@ -550,11 +507,9 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
);
if buttons(ui, "Bounce", enter) {
let p = std::path::Path::new(path.as_str()).with_extension(format.default_extension());
crate::bounce::start(
app,
&json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
bounce_params = Some(
json!({"path": p.to_string_lossy(), "format": format.default_extension(), "bit_depth": bit_depth, "normalize": normalize, "dither": *dither && bit_depth.as_str() != "32f"}),
);
keep = false;
}
}
Dialog::RenameTrack { id, name } => {
@@ -759,14 +714,13 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::unique("dlg_cancel")).unwrap_or(false)) {
keep = false;
action = None;
bounce_params = None;
save_first = None;
app.dialogs.after_save = None;
app.dialogs.pending_session = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::unique("dlg_cancel")));
}
// Enter on a focused Cancel also presses OK. In Auto Rename, the dialog then closes without running the command.
if !keep && matches!(d, Dialog::AutoRename { .. }) {
action = None;
}
if let Some(strip) = rename_from
&& keep
{
@@ -794,6 +748,10 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
app.dialogs.after_save = Some(then);
}
if let Some(params) = bounce_params {
keep = false;
crate::bounce::start(app, &params);
}
if let Some((cmd, params)) = &action
&& !confirmed
&& ask_to_save(app, cmd, params)
@@ -844,11 +802,13 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
}
/// `buttons`, but a greyed-out OK when the dialog's values can't be submitted yet. Cancel always works.
fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
pub(crate) fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> bool {
let mut pressed = enter && ready;
let mut canceled = false;
ui.separator();
ui.horizontal(|ui| {
if ui.button("Cancel").clicked() {
canceled = true;
ui.ctx().memory_mut(|m| m.data.insert_temp(egui::Id::unique("dlg_cancel"), true));
}
let b = egui::Button::new(egui::RichText::new(ok).strong()).fill(Tokens::current().accent);
@@ -856,7 +816,7 @@ fn buttons_enabled(ui: &mut egui::Ui, ok: &str, enter: bool, ready: bool) -> boo
pressed = true;
}
});
pressed
pressed && !canceled
}
/// A name field that takes the focus with its text selected, so typing replaces the old name.
@@ -915,6 +875,238 @@ fn loop_length(s: &Session, text: &str, format: soundcraft_time::TimeFormat, sta
#[cfg(test)]
mod tests {
use super::*;
use crate::dialogs::new_tracks::set_new_track_kind;
/// Opens and settles the dialog for a chosen desktop operating system.
fn tracks_dialog(os: egui::os::OperatingSystem) -> (egui::Context, SoundApp) {
let ctx = egui::Context::default();
ctx.set_os(os);
crate::fonts::install(&ctx, true);
let mut app = SoundApp::new(Engine::default(), None, crate::Services::default());
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
for _ in 0..3 {
dialog_frame(&ctx, &mut app, vec![]);
}
(ctx, app)
}
/// Runs one dialog frame with synthetic input events.
fn dialog_frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec<egui::Event>) {
let raw =
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() };
let mut output = ctx.run_ui(raw, |ui| {
crate::shortcuts::handle(app, ui.ctx());
show(app, ui.ctx());
});
output.textures_delta.clear();
}
/// Constructs one non-repeating key-press event for dialog tests.
fn key(key: egui::Key, modifiers: egui::Modifiers) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }
}
/// Returns every editable value from the active New Tracks row.
fn track_fields(app: &SoundApp) -> (u32, &str, &str, &str, &str) {
match app.dialogs.open.as_ref().unwrap() {
Dialog::NewTracks(tracks) => {
let row = tracks.active_row().unwrap();
(row.count, &row.format, &row.kind, &row.timebase, &row.name)
}
other => panic!("unexpected dialog: {other:?}"),
}
}
/// Opening selects the count, and bare arrows adjust it within its bounds.
#[test]
fn new_tracks_focus_selects_count_and_arrows_clamp_it() {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("7".into())]);
assert_eq!(track_fields(&app).0, 7, "typing must replace the selected initial value");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 8);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 7);
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
tracks.rows[0].count = 128;
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 128);
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
tracks.rows[0].count = 1;
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, egui::Modifiers::NONE)]);
assert_eq!(track_fields(&app).0, 1);
}
/// macOS command-arrow families edit format, type and timebase without changing count.
#[test]
fn new_tracks_mac_chords_change_settings_without_changing_count() {
for ctrl in [false, true] {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
let cmd = egui::Modifiers { command: true, mac_cmd: true, ctrl, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
assert_eq!(track_fields(&app), (1, "Stereo", "audio", "samples", "Audio"));
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
assert_eq!(track_fields(&app).1, "Mono");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, cmd)]);
assert_eq!(track_fields(&app).1, soundcraft_model::ChannelFormat::ALL.last().unwrap().label());
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, cmd)]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
assert_eq!(track_fields(&app), (1, "Mono", "aux", "samples", "Aux"));
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
assert_eq!(track_fields(&app).2, "audio");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, cmd)]);
assert_eq!(track_fields(&app).2, "video");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
assert_eq!(track_fields(&app).4, "Audio");
assert!(app.engine.session().tracks.is_empty());
}
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
let cmd_alt = egui::Modifiers { command: true, mac_cmd: true, alt: true, ..Default::default() };
for (arrow, expected) in [(egui::Key::ArrowDown, "ticks"), (egui::Key::ArrowUp, "samples")] {
dialog_frame(&ctx, &mut app, vec![key(arrow, cmd_alt)]);
assert_eq!(track_fields(&app).3, expected);
assert_eq!(track_fields(&app).0, 1);
assert_eq!(track_fields(&app).2, "audio");
}
}
/// Type changes update default names while preserving names entered by the user.
#[test]
fn new_tracks_type_changes_preserve_custom_names() {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
if let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open {
tracks.rows[0].name = "Dialogue cinema".into();
}
let cmd = egui::Modifiers { command: true, mac_cmd: true, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, cmd)]);
assert_eq!(track_fields(&app).4, "Dialogue cinema");
let mut kind = "audio".to_string();
let mut name = "Audio".to_string();
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Instrument);
assert_eq!(name, "Inst");
name = "Foley".to_string();
set_new_track_kind(&mut kind, &mut name, soundcraft_model::TrackKind::Aux);
assert_eq!(name, "Foley");
}
/// Enter creates, while Escape cancels even when both keys arrive in one frame.
#[test]
fn new_tracks_enter_creates_and_escape_cancels_on_each_platform() {
for os in [egui::os::OperatingSystem::Mac, egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
let (ctx, mut app) = tracks_dialog(os);
dialog_frame(&ctx, &mut app, vec![egui::Event::Text("3".into())]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert_eq!(app.engine.session().tracks.len(), 3);
assert!(app.engine.session().markers.is_empty(), "Enter must not add a background marker");
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
dialog_frame(&ctx, &mut app, vec![]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert_eq!(app.engine.session().tracks.len(), 3);
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
dialog_frame(&ctx, &mut app, vec![]);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert_eq!(app.engine.session().tracks.len(), 3, "Escape must cancel a simultaneous Enter");
}
}
/// Row shortcuts change the form without submitting when Enter arrives in the same frame.
#[test]
fn new_tracks_row_shortcuts_take_precedence_over_simultaneous_enter() {
let (ctx, mut app) = tracks_dialog(egui::os::OperatingSystem::Mac);
let rows = egui::Modifiers { command: true, mac_cmd: true, shift: true, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
assert_eq!(tracks.rows.len(), 1, "the last row cannot be removed");
assert!(app.engine.session().tracks.is_empty());
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
assert_eq!(tracks.rows.len(), 2);
assert!(app.engine.session().tracks.is_empty());
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows), key(egui::Key::Enter, egui::Modifiers::NONE)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing New Tracks dialog") };
assert_eq!(tracks.rows.len(), 1);
assert!(app.engine.session().tracks.is_empty());
}
/// Escape prevents a simultaneous Enter from starting a background bounce.
#[test]
fn simultaneous_enter_and_escape_does_not_start_a_bounce() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx, true);
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
assert!(app.dialogs.open_for_command(&app.engine, "file.bounce_mix"));
for _ in 0..3 {
dialog_frame(&ctx, &mut app, vec![]);
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert!(app.bounce.is_none());
}
/// Escape prevents a simultaneous Save from scheduling the deferred session action.
#[test]
fn simultaneous_save_and_escape_does_not_continue_after_saving() {
let ctx = egui::Context::default();
crate::fonts::install(&ctx, true);
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, crate::Services::default());
let tracks = app.engine.session().tracks.len();
app.dialogs.open = Some(Dialog::SaveChanges { then: ("session.close".into(), json!({})) });
for _ in 0..3 {
dialog_frame(&ctx, &mut app, vec![]);
}
dialog_frame(&ctx, &mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE), key(egui::Key::Escape, egui::Modifiers::NONE)]);
assert!(app.dialogs.open.is_none());
assert!(app.dialogs.after_save.is_none());
assert_eq!(app.engine.session().tracks.len(), tracks);
}
/// Windows and Linux chords cover every editable field and row action.
#[test]
fn new_tracks_windows_and_linux_chords_cover_every_field_and_row_action() {
for os in [egui::os::OperatingSystem::Windows, egui::os::OperatingSystem::Nix] {
let (ctx, mut app) = tracks_dialog(os);
let ctrl = egui::Modifiers { command: true, ctrl: true, ..Default::default() };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl)]);
assert_eq!(track_fields(&app).1, "Stereo");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowLeft, ctrl)]);
assert_eq!(track_fields(&app).1, "Mono");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl)]);
assert_eq!(track_fields(&app).2, "aux");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl)]);
assert_eq!(track_fields(&app).2, "audio");
let ctrl_alt = egui::Modifiers { alt: true, ..ctrl };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, ctrl_alt)]);
assert_eq!(track_fields(&app).3, "ticks");
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, ctrl_alt)]);
assert_eq!(track_fields(&app).3, "samples");
for alt in [false, true] {
let rows = egui::Modifiers { shift: true, alt, ..ctrl };
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowDown, rows)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
assert_eq!(tracks.rows.len(), 2);
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowUp, rows)]);
let Some(Dialog::NewTracks(tracks)) = &app.dialogs.open else { panic!("missing dialog") };
assert_eq!(tracks.rows.len(), 1);
}
let Some(Dialog::NewTracks(tracks)) = &mut app.dialogs.open else { panic!("missing dialog") };
tracks.rows[0].kind = "video".into();
tracks.rows[0].timebase = "ticks".into();
dialog_frame(&ctx, &mut app, vec![key(egui::Key::ArrowRight, ctrl), key(egui::Key::ArrowUp, ctrl_alt)]);
assert_eq!(track_fields(&app).1, "Mono", "Video has no format control");
assert_eq!(track_fields(&app).3, "ticks", "Video has no timebase control");
}
}
/// A name that is not valid Unicode is not opened under a lossy rendering (another file): the
/// status line names its bytes.
+613
View File
@@ -0,0 +1,613 @@
//! One or more independent New Tracks rows, submitted as a single engine command.
use egui::{Event, Key, TextStyle, Ui, vec2};
use serde_json::{Value, json};
use soundcraft_model::{ChannelFormat, TrackKind};
const MAX_TRACKS: usize = 128;
const NAME_WIDTH: f32 = 260.0;
const ROW_BUTTON_WIDTH: f32 = 22.0;
#[derive(Debug, Clone)]
/// One independently configured row in the New Tracks dialog.
pub struct NewTrackRow {
key: u64,
pub count: u32,
pub format: String,
pub kind: String,
pub timebase: String,
pub name: String,
}
impl Default for NewTrackRow {
/// Starts one mono audio row with the program defaults.
fn default() -> Self {
Self { key: 0, count: 1, format: "Mono".into(), kind: "audio".into(), timebase: "samples".into(), name: "Audio".into() }
}
}
impl NewTrackRow {
/// Builds one `track.new` row without values belonging to hidden controls.
fn params(&self) -> Value {
let mut params = json!({"count": self.count, "kind": self.kind, "name": self.name});
if has_format(&self.kind) {
params["format"] = json!(self.format);
}
if has_timebase(&self.kind) {
params["timebase"] = json!(self.timebase);
}
params
}
}
#[derive(Debug, Clone)]
/// State for the multi-row New Tracks dialog.
pub struct NewTracks {
pub rows: Vec<NewTrackRow>,
active: usize,
next_key: u64,
focus_count: bool,
}
impl Default for NewTracks {
/// Starts the dialog with one row whose count field receives focus.
fn default() -> Self {
Self { rows: vec![NewTrackRow::default()], active: 0, next_key: 1, focus_count: true }
}
}
impl NewTracks {
/// Returns the row currently targeted by keyboard shortcuts.
pub fn active_row(&self) -> Option<&NewTrackRow> {
self.rows.get(self.active)
}
/// Inserts a default row after `index` and moves focus to its count.
fn insert_after(&mut self, index: usize) {
if self.rows.len() >= MAX_TRACKS || self.rows.get(index).is_none() {
return;
}
let Some(next) = self.next_key.checked_add(1) else { return };
self.rows.insert(index + 1, NewTrackRow { key: self.next_key, ..NewTrackRow::default() });
self.next_key = next;
self.active = index + 1;
self.focus_count = true;
}
/// Removes `index` while retaining at least one row and a valid active row.
fn remove(&mut self, index: usize) {
if self.rows.len() <= 1 || self.rows.get(index).is_none() {
return;
}
self.rows.remove(index);
if index == self.active {
self.active = index.min(self.rows.len().saturating_sub(1));
self.focus_count = true;
} else if index < self.active {
self.active = self.active.saturating_sub(1);
}
}
/// Handles the row, format, type and timebase shortcuts and reports a row action.
fn keys(&mut self, ctx: &egui::Context) -> bool {
let mac = ctx.os() == egui::os::OperatingSystem::Mac;
let mut row_action = false;
ctx.input_mut(|i| {
i.events.retain(|event| {
let Event::Key { key, pressed: true, modifiers: m, .. } = event else { return true };
if !m.command {
return true;
}
if m.shift {
match key {
Key::ArrowDown => self.insert_after(self.active),
Key::ArrowUp => self.remove(self.active),
_ => return true,
}
row_action = true;
return false;
}
if m.alt && m.ctrl && mac {
return true;
}
let Some(row) = self.rows.get_mut(self.active) else { return true };
let forward = matches!(key, Key::ArrowRight | Key::ArrowDown);
match key {
Key::ArrowLeft | Key::ArrowRight if !m.alt => {
if has_format(&row.kind) {
let index = ChannelFormat::ALL.iter().position(|f| f.label() == row.format).unwrap_or(0);
if let Some(next) = ChannelFormat::ALL.get(cycle_index(index, ChannelFormat::ALL.len(), forward)) {
row.format = next.label().to_string();
}
}
}
Key::ArrowUp | Key::ArrowDown if m.alt => {
if has_timebase(&row.kind) {
row.timebase = if row.timebase == "ticks" { "samples" } else { "ticks" }.to_string();
}
}
Key::ArrowUp | Key::ArrowDown => {
let index = TrackKind::ALL.iter().position(|k| k.id() == row.kind).unwrap_or(0);
if let Some(next) = TrackKind::ALL.get(cycle_index(index, TrackKind::ALL.len(), forward)) {
set_new_track_kind(&mut row.kind, &mut row.name, *next);
}
}
_ => return true,
}
false
});
});
row_action
}
/// Draws every creation row and returns one atomic `track.new` request when submitted.
pub(crate) fn show(&mut self, ui: &mut Ui, enter: bool) -> Option<Value> {
let shortcut_row_action = self.keys(ui.ctx());
let widths = RowWidths::new(ui);
ui.set_min_width(widths.total(ui));
// The old row must not consume text on the first frame after adding a new one.
if self.focus_count && ui.is_enabled() && !ui.is_sizing_pass() {
ui.memory_mut(|m| {
if let Some(id) = m.focused() {
m.surrender_focus(id);
}
});
}
let mut insert = None;
let mut remove = None;
let len = self.rows.len();
egui::ScrollArea::vertical().id_salt("new_tracks_rows").max_height(300.0).auto_shrink([false, true]).show(ui, |ui| {
for (index, row) in self.rows.iter_mut().enumerate() {
// Explicit row IDs keep editing state with the row when rows above it are removed.
let id = egui::Id::unique(("new_tracks_row", row.key));
let rect = egui::Rect::from_min_size(ui.available_rect_before_wrap().min, vec2(ui.available_width(), ui.spacing().interact_size.y));
ui.scope_builder(egui::UiBuilder::new().scope_id(id).max_rect(rect).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
let label = ui.label("Create");
let counter = ui
.scope_builder(egui::UiBuilder::new().scope_id(id.with("count")), |ui| {
ui.spacing_mut().interact_size.x = widths.count;
if self.focus_count && index == self.active && ui.is_enabled() && !ui.is_sizing_pass() {
ui.memory_mut(|m| m.request_focus(ui.next_auto_id()));
self.focus_count = false;
}
ui.add(egui::DragValue::new(&mut row.count).speed(1.0).range(1..=128)).labelled_by(label.id)
})
.inner;
row.count = row.count.clamp(1, 128);
if counter.has_focus() || counter.clicked() {
self.active = index;
}
if counter.gained_focus() {
counter.scroll_to_me(Some(egui::Align::Center));
}
ui.label("new");
let format = if has_format(&row.kind) {
Some(
craft_ui::ComboBox::from_id_salt("nt_fmt")
.width(widths.format)
.selected_text(&row.format)
.show_ui(ui, |ui| {
for f in ChannelFormat::ALL {
ui.selectable_value(&mut row.format, f.label().to_string(), f.label());
}
})
.response
.accessible_name("Channel format"),
)
} else {
ui.allocate_space(vec2(widths.format, ui.spacing().interact_size.y));
None
};
let kind = craft_ui::ComboBox::from_id_salt("nt_kind")
.width(widths.kind)
.selected_text(TrackKind::from_id(&row.kind).map_or("Audio Track", |k| k.label()))
.show_ui(ui, |ui| {
for k in TrackKind::ALL {
if ui.selectable_label(row.kind == k.id(), k.label()).clicked() {
set_new_track_kind(&mut row.kind, &mut row.name, k);
}
}
})
.response
.accessible_name("Track kind");
let timebase = if has_timebase(&row.kind) {
ui.label("in");
Some(
craft_ui::ComboBox::from_id_salt("nt_tb")
.width(widths.timebase)
.selected_text(if row.timebase == "ticks" { "Ticks" } else { "Samples" })
.show_ui(ui, |ui| {
ui.selectable_value(&mut row.timebase, "samples".to_string(), "Samples");
ui.selectable_value(&mut row.timebase, "ticks".to_string(), "Ticks");
})
.response
.accessible_name("Timebase"),
)
} else {
ui.allocate_space(vec2(text_width(ui, "in"), ui.spacing().interact_size.y));
ui.allocate_space(vec2(widths.timebase, ui.spacing().interact_size.y));
None
};
let label = ui.label("Name:");
let name = crate::widgets::named_text_field(
ui.add(egui::TextEdit::singleline(&mut row.name).id(id.with("name")).desired_width(NAME_WIDTH)).labelled_by(label.id),
"Name:",
);
if format.iter().chain(timebase.iter()).chain([&kind, &name]).any(|r| r.clicked() || r.has_focus()) {
self.active = index;
}
let size = vec2(ROW_BUTTON_WIDTH, ui.spacing().interact_size.y);
if ui.add_enabled(len > 1, egui::Button::new("−").min_size(size)).on_hover_text("Remove this row").clicked() {
remove = Some(index);
}
if ui.add_enabled(len < MAX_TRACKS, egui::Button::new("+").min_size(size)).on_hover_text("Add a row below").clicked() {
insert = Some(index);
}
});
}
});
let row_action = shortcut_row_action || remove.is_some() || insert.is_some();
if let Some(index) = remove {
self.active = index;
self.remove(index);
ui.ctx().request_repaint();
} else if let Some(index) = insert {
self.insert_after(index);
ui.ctx().request_repaint();
}
let total = self.rows.iter().fold(0_u32, |total, row| total.saturating_add(row.count));
let can_create = !self.rows.is_empty() && total <= MAX_TRACKS as u32;
if !can_create {
ui.colored_label(ui.visuals().error_fg_color, "Create up to 128 tracks at a time.");
}
// Enter on a focused row button changes that row only; it must not also submit the dialog.
if crate::dialogs::buttons_enabled(ui, "Create", enter && !row_action, can_create) && !row_action {
Some(json!({"rows": self.rows.iter().map(NewTrackRow::params).collect::<Vec<_>>()}))
} else {
None
}
}
}
/// Measure all choices with the current UI font, rather than the selected label.
struct RowWidths {
count: f32,
format: f32,
kind: f32,
timebase: f32,
}
impl RowWidths {
/// Measures every possible choice so row columns stay fixed while values change.
fn new(ui: &Ui) -> Self {
Self {
count: (styled_text_width(ui, "128", &ui.style().drag_value_text_style) + 2.0 * ui.spacing().button_padding.x)
.ceil()
.max(ui.spacing().interact_size.x),
format: combo_width(ui, ChannelFormat::ALL.iter().map(|f| f.label())),
kind: combo_width(ui, TrackKind::ALL.iter().map(|k| k.label())),
timebase: combo_width(ui, ["Samples", "Ticks"].into_iter()),
}
}
/// Returns the complete width required by one row, including labels and buttons.
fn total(&self, ui: &Ui) -> f32 {
let labels: f32 = ["Create", "new", "in", "Name:"].iter().map(|s| text_width(ui, s)).sum();
labels + self.count + self.format + self.kind + self.timebase + NAME_WIDTH + 2.0 * ROW_BUTTON_WIDTH + 10.0 * ui.spacing().item_spacing.x + 8.0
}
}
/// Measures ordinary button text with the current theme and font scale.
fn text_width(ui: &Ui, text: &str) -> f32 {
styled_text_width(ui, text, &TextStyle::Button)
}
/// Measures one string using the supplied egui text style.
fn styled_text_width(ui: &Ui, text: &str, style: &TextStyle) -> f32 {
let font = style.resolve(ui.style());
ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font, ui.visuals().text_color()).size().x)
}
/// Computes a stable ComboBox width from all labels it may display.
fn combo_width<'a>(ui: &Ui, labels: impl Iterator<Item = &'a str>) -> f32 {
let longest = labels.map(|s| text_width(ui, s)).fold(0.0, f32::max);
(longest + ui.spacing().icon_width + ui.spacing().icon_spacing + 2.0 * ui.spacing().button_padding.x + 2.0).ceil()
}
/// Changes kind and updates the default name without replacing a custom name.
pub(crate) fn set_new_track_kind(kind: &mut String, name: &mut String, next: TrackKind) {
if TrackKind::from_id(kind).is_some_and(|old| name == old.default_name()) {
*name = next.default_name().to_string();
}
*kind = next.id().to_string();
}
/// Reports whether a track kind exposes a channel-format control.
fn has_format(kind: &str) -> bool {
!matches!(TrackKind::from_id(kind), Some(TrackKind::Midi | TrackKind::Vca | TrackKind::Video))
}
/// Reports whether a track kind exposes a samples/ticks control.
fn has_timebase(kind: &str) -> bool {
!matches!(TrackKind::from_id(kind), Some(TrackKind::Video))
}
/// Moves one position through a circular choice list in either direction.
fn cycle_index(index: usize, len: usize, forward: bool) -> usize {
if forward {
if index.saturating_add(1) < len { index.saturating_add(1) } else { 0 }
} else {
index.checked_sub(1).unwrap_or(len.saturating_sub(1))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Services, SoundApp, dialogs::Dialog};
use egui::{Modifiers, accesskit::Role};
use egui_kittest::{Harness, kittest::Queryable};
/// Opens a settled New Tracks dialog in a macOS UI harness.
fn harness() -> Harness<'static, SoundApp> {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
assert!(app.dialogs.open_for_command(&app.engine, "track.new"));
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
h.ctx.set_os(egui::os::OperatingSystem::Mac);
h.run_steps(4);
h
}
/// Borrows the dialog model from a test harness.
fn tracks<'a>(h: &'a Harness<'_, SoundApp>) -> &'a NewTracks {
let Some(Dialog::NewTracks(tracks)) = &h.state().dialogs.open else { panic!("missing New Tracks dialog") };
tracks
}
/// Mutably borrows the dialog model from a test harness.
fn tracks_mut<'a>(h: &'a mut Harness<'_, SoundApp>) -> &'a mut NewTracks {
let Some(Dialog::NewTracks(tracks)) = &mut h.state_mut().dialogs.open else { panic!("missing New Tracks dialog") };
tracks
}
/// Sends one keyboard chord and lets a resized dialog settle.
fn chord(h: &mut Harness<SoundApp>, m: Modifiers, key: Key) {
h.key_press_modifiers(m, key);
// A centered window needs another frame to settle after its row count changes.
h.run_steps(4);
}
/// Every selectable label retains its column's maximum measured width.
#[test]
fn every_choice_keeps_the_same_field_widths_and_name_position() {
let mut h = harness();
let format = h.get_by_value("Mono").rect();
let kind = h.get_by_value("Audio Track").rect();
let timebase = h.get_by_value("Samples").rect();
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert!(name.width() >= NAME_WIDTH);
for f in ChannelFormat::ALL {
tracks_mut(&mut h).rows[0].format = f.label().to_string();
h.run_steps(2);
assert_eq!(h.get_by_value(f.label()).rect().width(), format.width(), "{}", f.label());
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
}
for k in TrackKind::ALL {
tracks_mut(&mut h).rows[0].kind = k.id().to_string();
h.run_steps(2);
assert_eq!(h.get_by_value(k.label()).rect().width(), kind.width(), "{}", k.label());
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
}
tracks_mut(&mut h).rows[0].kind = "audio".to_string();
tracks_mut(&mut h).rows[0].timebase = "ticks".to_string();
tracks_mut(&mut h).rows[0].count = 128;
h.run_steps(2);
assert_eq!(h.get_by_value("Ticks").rect().width(), timebase.width());
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), name);
h.ctx.global_style_mut(|s| s.drag_value_text_style = TextStyle::Heading);
tracks_mut(&mut h).rows[0].count = 1;
h.run_steps(4);
let larger_font_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
tracks_mut(&mut h).rows[0].count = 128;
h.run_steps(2);
assert_eq!(h.get_by_role_and_label(Role::TextInput, "Name:").rect(), larger_font_name);
}
/// Hidden controls retain their layout space for kinds that do not use them.
#[test]
fn inapplicable_fields_are_hidden_without_moving_the_other_columns() {
let mut h = harness();
let name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
let all_fields = h.get_all_by_role(Role::ComboBox).count();
for kind in [TrackKind::Midi, TrackKind::Vca] {
tracks_mut(&mut h).rows[0].kind = kind.id().to_string();
h.run_steps(2);
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 1, "{}", kind.label());
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
}
tracks_mut(&mut h).rows[0].kind = TrackKind::Video.id().to_string();
h.run_steps(2);
assert_eq!(h.get_all_by_role(Role::ComboBox).count(), all_fields - 2);
let current_name = h.get_by_role_and_label(Role::TextInput, "Name:").rect();
assert_eq!((current_name.min.x, current_name.width()), (name.min.x, name.width()));
}
/// Submitted rows omit controls that are hidden for their selected track kind.
#[test]
fn inapplicable_fields_are_omitted_from_row_parameters() {
let row = NewTrackRow {
count: 2,
format: "Stereo".into(),
kind: TrackKind::Midi.id().into(),
timebase: "ticks".into(),
name: "MIDI".into(),
..NewTrackRow::default()
};
assert_eq!(row.params(), json!({"count": 2, "kind": "midi", "timebase": "ticks", "name": "MIDI"}));
let row = NewTrackRow { kind: TrackKind::Vca.id().into(), name: "VCA".into(), ..row };
assert_eq!(row.params(), json!({"count": 2, "kind": "vca", "timebase": "ticks", "name": "VCA"}));
let row = NewTrackRow { kind: TrackKind::Video.id().into(), name: "Video".into(), ..row };
assert_eq!(row.params(), json!({"count": 2, "kind": "video", "name": "Video"}));
}
/// Both supported add/remove shortcut variants preserve independent row state.
#[test]
fn both_requested_row_chords_focus_the_new_count_and_preserve_other_rows() {
for alt in [false, true] {
let mut h = harness();
let row_chord = Modifiers { command: true, mac_cmd: true, ctrl: true, shift: true, alt };
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
chord(&mut h, row_chord, Key::ArrowDown);
assert_eq!(tracks(&h).rows.len(), 2);
assert_eq!(tracks(&h).active, 1);
h.event(Event::Text("7".into()));
h.run_steps(2);
assert_eq!(tracks(&h).rows[1].count, 7);
assert_eq!(tracks(&h).rows[0].count, 1);
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
chord(&mut h, Modifiers { command: true, mac_cmd: true, ..Default::default() }, Key::ArrowDown);
assert_eq!(tracks(&h).rows[1].kind, "aux");
assert_eq!(tracks(&h).rows[0].kind, "audio");
chord(&mut h, row_chord, Key::ArrowUp);
assert_eq!(tracks(&h).rows.len(), 1);
assert_eq!(tracks(&h).rows[0].name, "Dialogue");
chord(&mut h, row_chord, Key::ArrowUp);
assert_eq!(tracks(&h).rows.len(), 1, "the last row cannot be removed");
assert!(h.state().engine.session().tracks.is_empty());
}
}
/// Each row's add and remove buttons act at that row's position.
#[test]
fn plus_and_minus_buttons_operate_on_their_own_row() {
let mut h = harness();
h.get_by_role_and_label(Role::Button, "+").click();
h.run_steps(4);
let names: Vec<_> = h.get_all_by_role_and_label(Role::TextInput, "Name:").map(|n| n.rect()).collect();
assert_eq!(names.len(), 2);
assert_eq!(names[0].min.x, names[1].min.x);
assert_eq!(names[0].width(), names[1].width());
assert!(names[1].min.y - names[0].min.y <= 32.0, "rows must stay compact: {names:?}");
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
tracks_mut(&mut h).rows[1].name = "Effects".into();
h.get_all_by_role_and_label(Role::Button, "+").next().unwrap().click();
h.run_steps(4);
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Audio", "Effects"]);
h.get_all_by_role_and_label(Role::Button, "−").nth(1).unwrap().click();
h.run_steps(4);
assert_eq!(tracks(&h).rows.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(), ["Dialogue", "Effects"]);
}
/// Enter on a focused row button changes the rows without also creating tracks.
#[test]
fn enter_on_row_buttons_does_not_submit_the_dialog() {
let mut h = harness();
h.get_by_role_and_label(Role::Button, "+").focus();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert_eq!(tracks(&h).rows.len(), 2);
assert!(h.state().engine.session().tracks.is_empty());
h.get_all_by_role_and_label(Role::Button, "−").next().unwrap().focus();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert_eq!(tracks(&h).rows.len(), 1);
assert!(h.state().engine.session().tracks.is_empty());
}
/// Enter on the focused Cancel button closes the dialog without creating tracks.
#[test]
fn enter_on_cancel_does_not_submit_the_dialog() {
let mut h = harness();
h.get_by_role_and_label(Role::Button, "Cancel").focus();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().dialogs.open.is_none());
assert!(h.state().engine.session().tracks.is_empty());
}
/// Removing a preceding row does not transfer widget state to another row.
#[test]
fn removing_an_earlier_row_keeps_editing_state_with_the_surviving_row() {
let mut h = harness();
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
h.event(Event::Text("42".into()));
h.run_steps(2);
let focused = h.ctx.memory(|m| m.focused());
let key = tracks(&h).rows[1].key;
tracks_mut(&mut h).remove(0);
h.run_steps(2);
assert_eq!(tracks(&h).rows[0].key, key);
assert_eq!(tracks(&h).rows[0].count, 42);
assert_eq!(h.ctx.memory(|m| m.focused()), focused);
chord(&mut h, Modifiers::NONE, Key::ArrowUp);
assert_eq!(tracks(&h).rows[0].count, 43);
}
/// A clicked row receives later shortcuts without altering its custom name.
#[test]
fn clicking_a_different_row_targets_its_chords_and_keeps_its_custom_name() {
let mut h = harness();
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
tracks_mut(&mut h).rows[0].name = "Dialogue".into();
h.get_all_by_role_and_label(Role::TextInput, "Name:").next().unwrap().click();
h.run_steps(2);
let cmd = Modifiers { command: true, mac_cmd: true, ..Default::default() };
chord(&mut h, cmd, Key::ArrowDown);
chord(&mut h, cmd, Key::ArrowRight);
chord(&mut h, Modifiers { alt: true, ..cmd }, Key::ArrowUp);
let rows = &tracks(&h).rows;
assert_eq!(
(&rows[0].kind, &rows[0].format, &rows[0].timebase, &rows[0].name),
(&"aux".to_string(), &"Stereo".to_string(), &"ticks".to_string(), &"Dialogue".to_string())
);
assert_eq!((&rows[1].kind, &rows[1].format, &rows[1].timebase), (&"audio".to_string(), &"Mono".to_string(), &"samples".to_string()));
assert_eq!((rows[0].count, rows[1].count), (1, 1));
}
/// Submitting several rows creates and undoes them as one engine action.
#[test]
fn create_submits_every_row_and_undo_removes_the_whole_creation() {
let mut h = harness();
tracks_mut(&mut h).rows[0].count = 2;
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
let row = &mut tracks_mut(&mut h).rows[1];
row.kind = "aux".into();
row.format = "Stereo".into();
row.name = "Reverb".into();
chord(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().dialogs.open.is_none());
assert_eq!(
h.state().engine.session().tracks.iter().map(|t| t.kind).collect::<Vec<_>>(),
[TrackKind::Audio, TrackKind::Audio, TrackKind::Aux]
);
assert_eq!(h.state().engine.session().tracks[2].name, "Reverb");
assert!(h.state().engine.session().markers.is_empty());
assert!(h.state_mut().engine.undo());
assert!(h.state().engine.session().tracks.is_empty());
assert!(!h.state().engine.can_undo());
}
/// The aggregate 128-track limit disables creation while leaving cancellation usable.
#[test]
fn too_many_tracks_disable_create_but_leave_cancel_available() {
let mut h = harness();
tracks_mut(&mut h).rows[0].count = 128;
chord(&mut h, Modifiers { command: true, mac_cmd: true, shift: true, ctrl: true, ..Default::default() }, Key::ArrowDown);
chord(&mut h, Modifiers::NONE, Key::Enter);
assert!(h.state().dialogs.open.is_some());
assert!(h.state().engine.session().tracks.is_empty());
h.get_by_role_and_label(Role::Button, "Cancel").click();
h.run_steps(2);
assert!(h.state().dialogs.open.is_none());
}
}
+27 -4
View File
@@ -511,7 +511,9 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
}
return;
};
let ids = json!([c.0]);
// A clip of the edit selection acts on the whole selection (the commands default to it); any other clip acts alone.
let in_selection = clip_in_selection(app.engine.session(), c);
let target = if in_selection { json!({}) } else { json!({"clips": [c.0]}) };
for (label, cmd) in [
("Mute/Unmute", "edit.mute_clips"),
("Edit Lock/Unlock", "clip.edit_lock"),
@@ -522,7 +524,7 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
("Send to Back", "clip.send_to_back"),
] {
if ui.button(label).clicked() {
let _ = app.run(cmd, json!({"clips": ids}));
let _ = app.run(cmd, target.clone());
}
}
ui.separator();
@@ -531,7 +533,28 @@ fn clip_context_menu(app: &mut SoundApp, ui: &mut Ui, clip: Option<ClipId>) {
app.dialogs.open_rename_clip(c, &name);
}
if ui.button("Delete").clicked() {
let _ = app.run("edit.select", json!({"clips": ids}));
let _ = app.run("edit.clear", json!({}));
let s = app.engine.session();
let selected: Vec<u64> = s.edit.selected_clips.iter().map(|id| id.0).collect();
let track: Vec<u64> = s.find_clip(c).map(|(t, _)| t.0).into_iter().collect();
if !in_selection {
let _ = app.run("edit.select", json!({"clips": [c.0]}));
let _ = app.run("edit.clear", json!({}));
} else if selected.is_empty() {
let _ = app.run("edit.clear", json!({}));
} else {
// Selected clips go by id (an empty range), so clips between them that the span covers stay. The clicked
// clip's track satisfies Clear's track check even when the track selection was emptied meanwhile.
let _ = app.run("edit.clear", json!({"clips": selected, "tracks": track, "start": 0}));
}
}
}
/// Whether `c` is part of the edit selection: one of the selected clips or, when no clip is selected, a clip on a
/// selected track that the selected range overlaps. An insertion point selects nothing.
fn clip_in_selection(s: &Session, c: ClipId) -> bool {
let ed = &s.edit;
if !ed.selected_clips.is_empty() {
return ed.selected_clips.contains(&c);
}
!ed.selection.is_empty() && s.find_clip(c).is_some_and(|(t, clip)| ed.selected_tracks.contains(&t) && clip.range().overlaps(&ed.selection))
}
+5
View File
@@ -70,6 +70,11 @@ impl EditLayout {
pub fn scroll_by(&mut self, dy: f32, view_h: f32) {
self.scroll_y = (self.scroll_y + dy).clamp(0.0, (self.content_h - view_h + 40.0).max(0.0));
}
/// Clears the vertical scroll position Zoom Toggle would restore, so Cancel Zoom Toggle keeps the current scroll.
pub(crate) fn forget_zoom_back(&mut self) {
self.zoom_back = None;
}
}
pub fn rect([x0, y0, x1, y1]: [f32; 4]) -> Rect {
+6 -1
View File
@@ -75,8 +75,13 @@ pub(super) fn hscrollbar(app: &mut SoundApp, ui: &mut Ui, sb: Rect, tl: Rect) {
}
}
/// Zoom Toggle sized to the track area on screen.
/// Zoom Toggle sized to the track area on screen. With Alt held (an Alt-click on the button) it cancels Zoom Toggle
/// instead and keeps the current view.
pub fn zoom_toggle(app: &mut SoundApp) {
if app.docking_context.as_ref().is_some_and(|c| c.input(|i| i.modifiers.alt)) {
let _ = app.run("view.zoom_toggle_cancel", json!({}));
return;
}
let [x0, y0, x1, y1] = app.edit_layout.timeline;
let _ = app.run("view.zoom_toggle", json!({"width_px": x1 - x0, "height_px": y1 - y0}));
}
+10
View File
@@ -246,6 +246,16 @@ fn hold_clears_when_playhead_returns_or_transport_stops() {
);
}
/// Cancel Zoom Toggle with Zoom Toggle already off keeps the scroll position a pending toggle-off restores.
#[test]
fn a_cancel_with_zoom_toggle_off_keeps_the_scroll_to_restore() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, crate::Services::default());
app.edit_layout.zoom_back = Some(120.0);
let v = app.run("view.zoom_toggle_cancel", json!({})).unwrap();
assert_eq!(v["canceled"], json!(false));
assert_eq!(app.edit_layout.zoom_back, Some(120.0));
}
fn harness(n: usize) -> Harness<'static, SoundApp> {
let mut engine = soundcraft_engine::Engine::default();
engine.execute("track.new", &json!({"count": n})).unwrap();
+30
View File
@@ -62,6 +62,36 @@ fn new_session_cancels_play_queued_before_the_next_frame() {
assert_eq!(app.engine.transport.position, 0);
}
#[test]
fn play_from_starts_at_from_regardless_of_the_selection() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("edit.select", json!({"tracks": ["Kick"], "start": 7_632_000, "end": 7_680_000})).unwrap();
app.run("transport.play", json!({"from": 4_560_000})).unwrap();
app.handle_transport(0.25);
assert!(app.is_playing());
assert_eq!(app.engine.transport.position, 4_560_000 + 12_000);
app.run("transport.play", json!({"from": 96_000})).unwrap();
app.handle_transport(0.25);
assert!(app.is_playing());
assert_eq!(app.engine.transport.position, 96_000 + 12_000);
}
#[test]
fn play_from_while_looping_drops_the_selection_loop() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.run("options.loop_playback", json!({"value": true})).unwrap();
app.run("edit.select", json!({"tracks": ["Kick"], "start": 96_000, "end": 144_000})).unwrap();
app.run("transport.play", json!({})).unwrap();
app.handle_transport(0.25);
assert!(app.is_playing());
app.run("transport.play", json!({"from": 480_000})).unwrap();
app.handle_transport(0.25);
app.handle_transport(0.25);
assert!(app.is_playing());
assert_eq!(app.engine.transport.position, 480_000 + 24_000);
}
#[test]
fn record_with_nothing_armed_says_what_to_do() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+28 -33
View File
@@ -2,6 +2,8 @@
use crate::SoundApp;
#[cfg(not(target_arch = "wasm32"))]
use craft_update::dialog::{CheckResultDialog, CheckResultResponse};
#[cfg(not(target_arch = "wasm32"))]
use craft_update::updater::{Action, Status, Updater};
@@ -13,8 +15,10 @@ pub(crate) struct Runtime {
startup_check: bool,
#[cfg(not(target_arch = "wasm32"))]
check_requested: bool,
pub(crate) manual_window_open: bool,
pub(crate) status_message: Option<String>,
/// The check-result dialog (native) or the web build's notice is showing.
manual_window_open: bool,
#[cfg(not(target_arch = "wasm32"))]
result_dialog: CheckResultDialog,
}
impl Default for Runtime {
@@ -27,7 +31,8 @@ impl Default for Runtime {
#[cfg(not(target_arch = "wasm32"))]
check_requested: false,
manual_window_open: false,
status_message: None,
#[cfg(not(target_arch = "wasm32"))]
result_dialog: CheckResultDialog::new(&crate::UPDATE_APP, env!("CARGO_PKG_VERSION")),
}
}
}
@@ -48,7 +53,6 @@ pub(crate) fn check(app: &mut SoundApp) {
#[cfg(target_arch = "wasm32")]
{
app.updates_rt.manual_window_open = true;
app.updates_rt.status_message = Some("Update check is not supported in the web browser.".into());
}
}
@@ -58,6 +62,7 @@ pub(crate) fn tick(app: &mut SoundApp, ctx: &egui::Context) {
let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.check_requested) {
rt.updater.check_now(ctx);
rt.manual_window_open = true;
} else if rt.startup_check {
rt.updater.check_at_start(ctx);
}
@@ -77,46 +82,36 @@ pub(crate) fn show(app: &mut SoundApp, ctx: &egui::Context) {
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {}
}
// The window answers Help ▸ Check for Updates, so it follows the updater's status.
// Help ▸ Check for Updates shows its outcome in the shared result dialog until the user closes it.
let rt = &mut app.updates_rt;
(rt.manual_window_open, rt.status_message) = match rt.updater.status() {
Status::Idle => (false, None),
Status::Checking => (true, None),
Status::UpToDate => (true, Some(format!("SoundCraft {} is currently the newest version available.", env!("CARGO_PKG_VERSION")))),
Status::Failed(e) => (true, Some(format!("Could not check for updates:\n{e}"))),
};
if rt.manual_window_open {
match rt.result_dialog.show(ctx, rt.updater.status()) {
CheckResultResponse::Close => rt.manual_window_open = false,
CheckResultResponse::CheckAgain => rt.updater.check_now(ctx),
CheckResultResponse::None => {}
}
// A found update goes to the startup prompt, which takes over from the dialog.
if matches!(rt.updater.status(), Status::Idle) {
rt.manual_window_open = false;
}
}
// Closing the dialog dismisses the outcome it showed.
if !rt.manual_window_open {
rt.updater.dismiss_status();
}
}
#[cfg(target_arch = "wasm32")]
if app.updates_rt.manual_window_open {
let mut open = true;
let mut close = false;
egui::Window::new("Check for Updates")
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
.show(ctx, |ui| {
if let Some(msg) = &app.updates_rt.status_message {
ui.label(msg);
} else {
ui.label("Checking for updates…");
}
ui.add_space(8.0);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("Close").clicked() {
close = true;
}
});
ui.label("Update check is not supported in the web browser.");
});
if !open || close {
app.updates_rt.manual_window_open = false;
app.updates_rt.status_message = None;
}
}
// Closing the window dismisses the outcome it showed.
#[cfg(not(target_arch = "wasm32"))]
if !app.updates_rt.manual_window_open {
app.updates_rt.updater.dismiss_status();
app.updates_rt.manual_window_open = open;
}
}
+123 -24
View File
@@ -1,6 +1,7 @@
//! Regression: the clip context menu must stay open while the pointer moves onto it, so its
//! Delete item can be used. It used to vanish once the pointer left the lane, because the lane
//! returned early (no hover position) before the menu was drawn.
//! returned early (no hover position) before the menu was drawn. A right-click on a clip of the
//! edit selection acts on the whole selection (#1191).
use egui::{Event, Modifiers, PointerButton, pos2, vec2};
use egui_kittest::Harness;
@@ -24,15 +25,16 @@ fn press(h: &mut Harness<'_, SoundApp>, x: f32, y: f32, button: PointerButton) {
s.push(Event::PointerButton { pos, button, pressed: false, modifiers: Modifiers::NONE });
}
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
fn clip_count(h: &Harness<'_, SoundApp>, track: &str) -> usize {
let s = h.state().engine.session();
s.tracks.iter().find(|t| t.name == "Kick").map_or(0, |t| t.clips().len())
s.tracks.iter().find(|t| t.name == track).map_or(0, |t| t.clips().len())
}
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
/// removes one Kick clip.
#[test]
fn clip_menu_stays_open_while_moving_onto_delete() {
fn kick_clip_count(h: &Harness<'_, SoundApp>) -> usize {
clip_count(h, "Kick")
}
fn demo() -> Harness<'static, SoundApp> {
let engine = soundcraft_engine::demo::demo_engine();
let app = SoundApp::new(engine, None, Services::default());
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0).build_ui_state(
@@ -46,26 +48,123 @@ fn clip_menu_stays_open_while_moving_onto_delete() {
for _ in 0..6 {
step(&mut h);
}
h
}
/// Selects the whole session on `tracks`, as a drag across their clips does.
fn select_all_of(h: &mut Harness<'_, SoundApp>, tracks: &[&str]) {
h.state_mut().engine.execute("edit.select", &serde_json::json!({"tracks": tracks, "start": 0, "end": 48_000 * 600})).unwrap();
}
/// Right-clicks the Kick clip and clicks `item` in its menu, moving onto the item first as a real pointer would.
fn kick_menu(h: &mut Harness<'_, SoundApp>, item: &str) {
press(h, 880.0, 240.0, PointerButton::Secondary);
for _ in 0..10 {
step(h);
}
// Ask the newly laid-out menu where the item is instead of pinning coordinates that change with egui's item metrics.
let at = h.get_by_label(item).rect().center();
h.state_mut().synthetic.push(Event::PointerMoved(at));
for _ in 0..10 {
step(h);
}
press(h, at.x, at.y, PointerButton::Primary);
for _ in 0..15 {
step(h);
}
}
/// Moving the pointer from a right-clicked clip onto the menu's Delete item keeps the menu open; clicking Delete
/// removes one Kick clip.
#[test]
fn clip_menu_stays_open_while_moving_onto_delete() {
let mut h = demo();
let before = kick_clip_count(&h);
assert!(before > 0, "the demo session should have a recorded Kick clip");
// Right-click the Kick clip, then move the pointer into the menu and down to Delete.
press(&mut h, 880.0, 240.0, PointerButton::Secondary);
for _ in 0..10 {
step(&mut h);
}
// Move (no click) onto the menu's Delete item, as a real pointer would, so the lane loses
// its hover. Ask the newly laid-out menu where the item is instead of pinning coordinates
// that change with egui's item metrics.
let delete = h.get_by_label("Delete").rect().center();
h.state_mut().synthetic.push(Event::PointerMoved(delete));
for _ in 0..10 {
step(&mut h);
}
press(&mut h, delete.x, delete.y, PointerButton::Primary);
for _ in 0..15 {
step(&mut h);
}
kick_menu(&mut h, "Delete");
assert_eq!(kick_clip_count(&h), before - 1, "clicking Delete in the clip menu should remove the clip");
}
/// Delete on a clip of a drag selection clears the whole selection, as Edit > Clear does, in one undo step (#1191).
#[test]
fn delete_on_a_selected_clip_clears_the_whole_selection() {
let mut h = demo();
let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare"));
assert!(snare > 1, "the demo session should have several Snare clips");
select_all_of(&mut h, &["Kick", "Snare"]);
kick_menu(&mut h, "Delete");
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (0, 0), "every selected clip should be cleared");
h.state_mut().engine.execute("edit.undo", &serde_json::json!({})).unwrap();
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick, snare), "one undo should bring them all back");
}
/// A clip outside the selection is still deleted on its own, and the selection's clips stay.
#[test]
fn delete_on_a_clip_outside_the_selection_removes_only_that_clip() {
let mut h = demo();
let (kick, snare) = (clip_count(&h, "Kick"), clip_count(&h, "Snare"));
select_all_of(&mut h, &["Snare"]);
kick_menu(&mut h, "Delete");
assert_eq!((clip_count(&h, "Kick"), clip_count(&h, "Snare")), (kick - 1, snare));
}
/// With clips selected one by one (Grabber shift-click), Delete removes exactly those clips, not the other clips
/// their span covers.
#[test]
fn delete_on_selected_clips_keeps_the_unselected_clips_between_them() {
let mut h = demo();
let ids = |h: &Harness<'_, SoundApp>, track: &str| -> Vec<u64> {
let s = h.state().engine.session();
s.tracks.iter().filter(|t| t.name == track).flat_map(|t| t.clips()).map(|c| c.id.0).collect()
};
let (kick, snare) = (ids(&h, "Kick"), ids(&h, "Snare"));
let last_snare = *snare.last().unwrap();
let clips: Vec<u64> = kick.iter().copied().chain([last_snare]).collect();
h.state_mut().engine.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap();
kick_menu(&mut h, "Delete");
assert!(ids(&h, "Kick").is_empty(), "the selected Kick clips should be removed");
let left: Vec<u64> = snare.iter().copied().filter(|id| *id != last_snare).collect();
assert_eq!(ids(&h, "Snare"), left, "only the selected Snare clip should be removed");
}
/// Selected clips are deleted even when the track selection was emptied after selecting them (Edit > Selection >
/// Remove Edit from Top keeps the clip selection).
#[test]
fn delete_on_selected_clips_works_without_a_track_selection() {
let mut h = demo();
let kick = clip_count(&h, "Kick");
let clips: Vec<u64> = {
let s = h.state().engine.session();
s.tracks.iter().filter(|t| t.name == "Kick").flat_map(|t| t.clips()).map(|c| c.id.0).collect()
};
let e = &mut h.state_mut().engine;
e.execute("edit.select", &serde_json::json!({ "clips": clips })).unwrap();
e.execute("edit.remove_selection_top", &serde_json::json!({})).unwrap();
assert!(e.session().edit.selected_tracks.is_empty() && !e.session().edit.selected_clips.is_empty());
kick_menu(&mut h, "Delete");
assert_eq!(clip_count(&h, "Kick"), kick - clips.len(), "the selected Kick clips should be removed");
}
/// The menu's other entries act on the whole selection too.
#[test]
fn mute_on_a_selected_clip_mutes_every_selected_clip() {
let mut h = demo();
select_all_of(&mut h, &["Kick", "Snare"]);
kick_menu(&mut h, "Mute/Unmute");
let s = h.state().engine.session();
let clips: Vec<_> = s.tracks.iter().filter(|t| t.name == "Kick" || t.name == "Snare").flat_map(|t| t.clips()).collect();
assert!(clips.len() > 2);
assert!(clips.iter().all(|c| c.muted), "every selected clip should be muted");
}
+44 -1
View File
@@ -1,5 +1,5 @@
use egui::os::OperatingSystem;
use egui::{Key, vec2};
use egui::{Key, Modifiers, vec2};
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp};
@@ -41,3 +41,46 @@ fn e_fits_the_selection_to_the_edit_window_and_the_button_goes_back() {
assert_eq!(view(h.state()), before, "{os:?}");
}
}
#[test]
fn alt_shift_e_and_an_alt_click_cancel_zoom_toggle_and_keep_the_zoomed_view() {
for os in [OperatingSystem::Mac, OperatingSystem::Windows, OperatingSystem::Nix] {
for alt_click in [false, true] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let bass = app.engine.session().track_by_name("Bass").unwrap().id.0;
app.engine.execute("edit.select", &json!({"tracks": [bass], "start": 96_000, "end": 192_000})).unwrap();
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).with_os(os).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(5);
let view = |a: &SoundApp| (a.engine.session().edit.zoom, a.edit_layout.rows.clone(), a.edit_layout.scroll_y);
let before = view(h.state());
h.get_by_label("Zoom Toggle (E)").click();
h.run_steps(1);
let zoomed = view(h.state());
assert_ne!(zoomed, before, "{os:?}");
if alt_click {
h.get_by_label("Zoom Toggle (E)").click_modifiers(Modifiers::ALT);
} else {
h.key_press_modifiers(Modifiers::ALT | Modifiers::SHIFT, Key::E);
}
h.run_steps(2);
let how = if alt_click { "Alt-click" } else { "Alt+Shift+E" };
assert_eq!(h.state().engine.session().edit.zoom_toggle, None, "{os:?} {how}");
assert_eq!(view(h.state()), zoomed, "{os:?} {how} keeps the zoomed view");
// The next toggle zooms in from here and goes back here, not to the view before the cancel.
h.get_by_label("Zoom Toggle (E)").click();
h.run_steps(1);
assert!(h.state().engine.session().edit.zoom_toggle.is_some(), "{os:?} {how}");
h.get_by_label("Zoom Toggle (E)").click();
h.run_steps(1);
assert_eq!(view(h.state()), zoomed, "{os:?} {how}");
}
}
}
+69 -4
View File
@@ -198,6 +198,7 @@ impl Dpb {
let mut ref_idx = 0usize;
for m in &sh.ref_pic_list_mod[l] {
ensure!(ref_idx < n, "too many ref_pic_list_modification entries");
ensure!(m.idc >= 2 || i64::from(m.value) < i64::from(max_pic_num), "abs_diff_pic_num_minus1 out of range");
list.push(None); // temporarily one longer
let (target, is_long): (Option<&DpbEntry>, bool) = match m.idc {
0 | 1 => {
@@ -262,9 +263,9 @@ impl Dpb {
for m in mmcos {
match m.op {
1 => {
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
for e in &mut self.entries {
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn {
e.mark = RefMark::Unused;
}
}
@@ -277,14 +278,14 @@ impl Dpb {
}
}
3 => {
let pn = curr_pic_num - (m.difference_of_pic_nums_minus1 as i32 + 1);
let pn = i64::from(curr_pic_num) - (i64::from(m.difference_of_pic_nums_minus1) + 1);
for e in &mut self.entries {
if e.mark == RefMark::Long && e.long_term_frame_idx == m.long_term_frame_idx {
e.mark = RefMark::Unused;
}
}
for e in &mut self.entries {
if e.mark == RefMark::Short && Self::frame_num_wrap(e, cur_frame_num, max_frame_num) == pn {
if e.mark == RefMark::Short && i64::from(Self::frame_num_wrap(e, cur_frame_num, max_frame_num)) == pn {
e.mark = RefMark::Long;
e.long_term_frame_idx = m.long_term_frame_idx;
}
@@ -418,3 +419,67 @@ impl Default for Dpb {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::h264::slice::RefPicListMod;
fn mmco(op: u32, diff: u32) -> Mmco {
Mmco { op, difference_of_pic_nums_minus1: diff, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 }
}
#[test]
fn mmco_huge_difference_does_not_panic() {
for op in [1, 3] {
for diff in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
let mut dpb = Dpb::new();
dpb.apply_mmcos(&[mmco(op, diff)], 5, 16, &mut None);
}
}
}
#[test]
fn ref_list_mod_huge_value_does_not_panic() {
for idc in [0, 1] {
for value in [0x7FFF_FFFF, 0x8000_0000, u32::MAX] {
let sh = SliceHeader {
nal_ref_idc: 1,
nal_unit_type: 1,
idr: false,
first_mb_in_slice: 0,
slice_type_raw: 0,
slice_type: SliceType::P,
pps_id: 0,
colour_plane_id: 0,
frame_num: 5,
field_pic: false,
bottom_field: false,
idr_pic_id: 0,
pic_order_cnt_lsb: 0,
delta_pic_order_cnt_bottom: 0,
delta_pic_order_cnt: [0; 2],
redundant_pic_cnt: 0,
direct_spatial_mv_pred: false,
num_ref_idx_active: [1, 0],
ref_pic_list_mod: [vec![RefPicListMod { idc, value }], Vec::new()],
pred_weight_table: None,
no_output_of_prior_pics: false,
long_term_reference: false,
adaptive_ref_pic_marking: false,
mmcos: Vec::new(),
cabac_init_idc: 0,
slice_qp_delta: 0,
sp_for_switch: false,
slice_qs_delta: 0,
disable_deblocking_filter_idc: 0,
slice_alpha_c0_offset_div2: 0,
slice_beta_offset_div2: 0,
slice_group_change_cycle: 0,
header_bits: 0,
};
assert!(Dpb::new().build_ref_lists(&sh, 0, 16).is_err());
}
}
}
}
+17 -4
View File
@@ -467,14 +467,14 @@ impl PocState {
let max_lsb = 1i32 << sps.log2_max_poc_lsb;
let lsb = sh.pic_order_cnt_lsb as i32;
let msb = if lsb < prev_lsb && prev_lsb - lsb >= max_lsb / 2 {
prev_msb + max_lsb
prev_msb.saturating_add(max_lsb)
} else if lsb > prev_lsb && lsb - prev_lsb > max_lsb / 2 {
prev_msb - max_lsb
prev_msb.saturating_sub(max_lsb)
} else {
prev_msb
};
let top = msb + lsb;
let bottom = top + sh.delta_pic_order_cnt_bottom;
let top = msb.saturating_add(lsb);
let bottom = top.saturating_add(sh.delta_pic_order_cnt_bottom);
Poc { top, bottom, msb, frame_num_offset: 0 }
}
1 => {
@@ -638,4 +638,17 @@ mod tests {
fn huge_slice_qp_delta_is_invalid() {
assert!(matches!(parse_override(0, 0, 0, i32::MAX), Err(crate::h264::Error::Invalid(_))));
}
#[test]
fn poc0_huge_bottom_delta_does_not_overflow() {
let (mut sps, _) = sps_pps();
sps.pic_order_cnt_type = 0;
sps.log2_max_poc_lsb = 4;
let mut sh = parse_override(0, 0, 0, 0).unwrap();
sh.pic_order_cnt_lsb = 1;
sh.delta_pic_order_cnt_bottom = i32::MAX;
let poc = PocState::default().compute(&sh, &sps);
assert_eq!(poc.top, 1);
assert_eq!(poc.bottom, i32::MAX);
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ harness = false
name = "module_exit_shutdown"
harness = false
# An isolated unsafe helper crate (craftrules never-crash), like soundcraft-clap-host: this crate
# An isolated unsafe helper crate, like soundcraft-clap-host: this crate
# opts out of the workspace `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere
# and allows it solely on the FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
[lints.rust]
+3 -2
View File
@@ -51,8 +51,9 @@ pub(crate) use process::{EventList, MemoryStream, ParamChanges};
const MAX_CLASSES: i32 = 1024;
/// Most parameters read from one controller.
pub(crate) const MAX_PARAMS: i32 = 4096;
/// Most audio buses per direction we provide buffers for.
const MAX_BUSES: i32 = 16;
/// Most audio buses per direction we provide buffers for. Multi-out instruments declare dozens
/// of (mostly inactive) aux buses, e.g. 32 for a drum sampler; every one still gets buffers.
const MAX_BUSES: i32 = 64;
/// Most channels per audio bus.
const MAX_BUS_CHANNELS: u32 = 64;
/// Capacity of the per-block event lists (preallocated: `process` never allocates).
+1 -1
View File
@@ -8,7 +8,7 @@
//! [`PluginInfo::params`]. Values are plain (display) values; the host converts to and from the
//! VST3 normalized 0..1 values. Stepped parameters become toggles or choices.
//!
//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate (craftrules never-crash):
//! Like `soundcraft-clap-host`, this is an isolated `unsafe` crate:
//! `unsafe` is denied crate-wide and allowed only in the private `ffi` module. The public API is
//! safe, returns `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that
//! itself crashes (segfault) takes the process down with it; we cannot sandbox native code.
+1 -1
View File
@@ -35,7 +35,7 @@ pub fn fixture_dir() -> &'static Path {
let bin = dir.join("vendor/Fixture.vst3/Contents").join(folder);
std::fs::create_dir_all(&bin).unwrap();
std::fs::copy(&built, bin.join(exe)).unwrap();
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 5);
assert_eq!(soundcraft_vst3_host::add_search_dir(&dir), 6);
dir
})
}
+12 -9
View File
@@ -1,4 +1,4 @@
//! Test-only VST3 plugin for soundcraft-vst3-host (five audio module classes):
//! Test-only VST3 plugin for soundcraft-vst3-host (six audio module classes):
//!
//! - "Fixture Gain" (`Fx`): stereo or mono in → out × gain, with a separate edit controller
//! (connected through IConnectionPoint, synced through the component state). Params: 7 "Gain"
@@ -11,9 +11,9 @@
//! its edit controller). No audio input, one stereo output, one event input; every output
//! sample is the velocity of the held note (sample-accurate), 0 when none. Poly pressure, and
//! MIDI CC 7 through `IMidiMapping` (hidden parameter 1), set it to their value too.
//! - "Fixture Wide 16x64", "Fixture Wide 17 buses", "Fixture Wide 65 channels" (`Fx`): effects
//! advertising wide layouts (16 buses of 64 channels, 17 buses of 2 channels, one bus of 65
//! channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
//! - "Fixture Wide 16x64", "Fixture Wide 32 buses", "Fixture Wide 65 buses", "Fixture Wide 65
//! channels" (`Fx`): effects advertising wide layouts (16 buses of 64 channels, 32 and 65 buses
//! of 2 channels, one bus of 65 channels per direction), through `getBusInfo` only. `process` writes 1.0 into every channel
//! of output bus 0 when the host's buffers match the advertised layout, -1.0 when they do not.
//! Report 3 samples of latency.
//!
@@ -34,8 +34,9 @@ const GAIN_CID: TUID = uid(0x5C0A1D00, 0x11112222, 0x33334444, 0x55556666);
const GAIN_CTRL_CID: TUID = uid(0x5C0A1D01, 0x11112222, 0x33334444, 0x55556666);
const SYNTH_CID: TUID = uid(0x5C0A1D02, 0x11112222, 0x33334444, 0x55556666);
const WIDE16_CID: TUID = uid(0x5C0A1D03, 0x11112222, 0x33334444, 0x55556666);
const WIDE17_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
const WIDE65B_CID: TUID = uid(0x5C0A1D04, 0x11112222, 0x33334444, 0x55556666);
const WIDE65_CID: TUID = uid(0x5C0A1D05, 0x11112222, 0x33334444, 0x55556666);
const WIDE32_CID: TUID = uid(0x5C0A1D06, 0x11112222, 0x33334444, 0x55556666);
fn copy_c8(src: &str, dst: &mut [c_char]) {
for (d, s) in dst.iter_mut().zip(src.bytes().chain(std::iter::once(0))) {
@@ -736,12 +737,13 @@ impl Class for Factory {
type Interfaces = (IPluginFactory2,);
}
const CLASSES: [(TUID, &str, &str, &str); 6] = [
const CLASSES: [(TUID, &str, &str, &str); 7] = [
(GAIN_CID, "Audio Module Class", "Fixture Gain", "Fx"),
(GAIN_CTRL_CID, "Component Controller Class", "Fixture Gain", ""),
(SYNTH_CID, "Audio Module Class", "Fixture Synth", "Instrument|Synth"),
(WIDE16_CID, "Audio Module Class", "Fixture Wide 16x64", "Fx"),
(WIDE17_CID, "Audio Module Class", "Fixture Wide 17 buses", "Fx"),
(WIDE32_CID, "Audio Module Class", "Fixture Wide 32 buses", "Fx"),
(WIDE65B_CID, "Audio Module Class", "Fixture Wide 65 buses", "Fx"),
(WIDE65_CID, "Audio Module Class", "Fixture Wide 65 channels", "Fx"),
];
@@ -785,8 +787,9 @@ impl IPluginFactoryTrait for Factory {
.to_com_ptr::<FUnknown>()
} else if cid == SYNTH_CID {
ComWrapper::new(Synth { level: AtomicU32::new(0), _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else if let Some((buses, channels)) =
[(WIDE16_CID, (16, 64)), (WIDE17_CID, (17, 2)), (WIDE65_CID, (1, 65))].into_iter().find_map(|(c, layout)| (c == cid).then_some(layout))
} else if let Some((buses, channels)) = [(WIDE16_CID, (16, 64)), (WIDE32_CID, (32, 2)), (WIDE65B_CID, (65, 2)), (WIDE65_CID, (1, 65))]
.into_iter()
.find_map(|(c, layout)| (c == cid).then_some(layout))
{
ComWrapper::new(Wide { buses, channels, _live: Live::new(&OBJECTS) }).to_com_ptr::<FUnknown>()
} else {
+15 -3
View File
@@ -15,7 +15,7 @@ fn id_of(name: &str) -> String {
fn scan_lists_both_audio_modules_but_not_the_controller_class() {
let dir = fixture_dir();
let found = soundcraft_vst3_host::scan_paths(&[dir.to_path_buf()]);
assert_eq!(found.len(), 5, "{found:?}");
assert_eq!(found.len(), 6, "{found:?}");
let g = found.iter().find(|d| d.name == "Fixture Gain").unwrap();
// `uid()` lays out the first 8 bytes differently on Windows (COM GUID order).
let want = if cfg!(windows) { "vst3:001D0A5C111122223333444455556666" } else { "vst3:5C0A1D00111122223333444455556666" };
@@ -320,8 +320,8 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "16x64 layout delivered as advertised: {:?}", &io[0][..4]);
// 17 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
for name in ["Fixture Wide 17 buses", "Fixture Wide 65 channels"] {
// 65 buses, or a 65-channel bus: never activated, never processed with a cut-down layout.
for name in ["Fixture Wide 65 buses", "Fixture Wide 65 channels"] {
let mut p = soundcraft_vst3_host::create(&id_of(name)).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 0, "{name}: not activated");
@@ -331,6 +331,18 @@ fn layouts_beyond_the_host_limits_are_rejected_not_truncated() {
}
}
#[test]
fn many_buses_like_multi_out_instruments_are_activated() {
// Multi-out instruments declare dozens of buses (SSD Sampler 5: 32); only the main one is
// active, but the plugin must still load, get buffers for every bus and play (#1247).
let mut p = soundcraft_vst3_host::create(&id_of("Fixture Wide 32 buses")).unwrap();
p.prepare(44_100.0, 64, 2);
assert_eq!(p.latency(), 3, "32-bus plugin activated");
let mut io = vec![vec![0.5f32; 64]; 2];
p.process(&mut io, 64);
assert!(io.iter().flatten().all(|&x| x == 1.0), "32-bus layout delivered as advertised: {:?}", &io[0][..4]);
}
#[test]
fn editor_view_is_probed_and_edits_come_back_as_plain_values() {
let id = id_of("Fixture Gain");
+4 -21
View File
@@ -1,16 +1,14 @@
# Contributors in the About window
**About ▸ Contributors** credits everyone who contributed to SoundCraft, and **About ▸ Models** credits
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
this page is the local copy of the decision.
the AI models named in `Co-Authored-By` trailers.
## Decision
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
username. Never hand-edit.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
@@ -36,20 +34,5 @@ display_name = "Nickname"
sync_github_name = true # use the name on your public GitHub profile as display name
```
or, with craftrules checked out next to `craft-apps/`:
```sh
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
```
Names show after the next refresh: the script checks that each entry was committed by its own
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
## Refreshing (maintainers)
```sh
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
```
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
Delete your entry to remove your names. Git author names and emails are never recorded.
+1 -1
View File
@@ -77,7 +77,7 @@ When you close a gap, delete its entry (or shrink it) in the same PR, and bump t
- **Missing:** opening or saving `.ptx`/`.ptf`, AAF and OMF import and export (#40), Media
Composer–compatible sessions. Our session format is `.scraft` JSON.
- **Impact:** the standard's main-format bar for beta. A Pro Tools user cannot bring a project in
- **Impact:** a main-format requirement for beta. A Pro Tools user cannot bring a project in
or hand one back; post-production (picture editors deliver AAF) is blocked.
- **Estimate:** AAF/OMF import + export ≈ 50–90 h (FilmCraft already writes AAF/OMF; we can learn
from it but not share code). `.ptx` is proprietary and undocumented: owner decision on whether
-9
View File
@@ -1,9 +0,0 @@
# Release playbook
The release playbook shared by every crafting app lives in craftrules:
[`../craftrules/release/playbook.md`](../../craftrules/release/playbook.md)
(`storytold/craftrules`). It is the canonical copy; don't duplicate it here.
SoundCraft's implementation (`.github/workflows/release.yml`, `packaging/`,
`cargo xtask version`) was copied from DesignCraft/PhotoCraft and renamed. SoundCraft-specific
details are in [releasing.md](releasing.md).
+2 -3
View File
@@ -5,7 +5,7 @@ signed installers for macOS, Windows, Linux, FreeBSD and the web, then creates o
**draft** GitHub Release named `SoundCraft v<version>`. Nobody sees a draft until a maintainer
publishes it.
This is SoundCraft's implementation of the shared [release playbook](release-playbook.md).
This file describes how SoundCraft is released.
User-facing names say **SoundCraft**. Files, binaries and ids stay lowercase
(`soundcraft-<version>-<platform>-<arch>.<ext>`, `ai.storyteller.soundcraft`).
@@ -208,8 +208,7 @@ are written with `umask 077` and deleted in `always()`/`finally` steps.
| `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | service principal for Azure Trusted Signing |
| `AZURE_SIGNING_ENDPOINT`, `AZURE_SIGNING_ACCOUNT`, `AZURE_CERT_PROFILE` | Trusted Signing endpoint, account and profile |
`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`. Provisioning the
environment, ruleset and secrets for a new repo: craftrules `release/signing-setup.md`.
`GITHUB_TOKEN` creates the release. Only the final job gets `contents: write`.
## Icons
+1 -2
View File
@@ -18,8 +18,7 @@ Hours are Opus 5.5 agent-hours.
## Alpha gate
The core workflows a Pro Tools professional does every day, checked end to end on macOS (the
main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist
(craftrules `standards/progress-docs.md`, Stages).
main platform). Any **no** keeps SoundCraft **pre-alpha**, and those rows are the alpha checklist.
| Workflow | Works end to end? | Evidence | Hours to pass |
|---|---|---|---:|
+2 -2
View File
@@ -96,7 +96,7 @@ every command is reachable from the CLI, the JSON control channel and MCP.
## Mainstream practitioner and essentials user
Both use the method in craftrules `standards/progress-docs.md`. Each multiplies a weighted depth
Both use one method. Each multiplies a weighted depth
average by written-down discounts. Depths come from the [Feature areas](#feature-areas) table and
the evidence there; issue numbers are `storytold/soundcraft` GitHub issues.
@@ -125,7 +125,7 @@ immersive delivery, video, notation, and languages other than English.
| File exchange with Pro Tools users | ×0.85 | Engineers trade sessions, not stems. No `.ptx`, AAF or OMF (#40); WAV/BWF stems and MIDI files do work, so the factor isn't lower |
| **Result** | 50.6 × 0.90 × 0.92 × 0.85 = **35.6 % ≈ 36 %** | |
This comes out *below* the full number (~40 %), which the standard says is unusual. The
This comes out *below* the full number (~40 %), which is unusual. The
mainstream engineer's weekly core is interface recording and MIDI overdubs, exactly the
alpha-gate failures. The full number gets partial credit from areas this user doesn't weight:
editing breadth, five platforms built, agent control.
+3 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Tracks and Groups and Clips in dock columns) · **Target:** Avid Pro Tools Ultimate 2026.4.1
> **Last reviewed:** 2026-10-25 · **Last updated:** 2026-10-25 · **Change:** minor (Cancel Zoom Toggle) · **Target:** Avid Pro Tools Ultimate 2026.4.1
How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts,
windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command
@@ -21,6 +21,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks |
| Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present |
| Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples |
| Zoom Toggle | 🟡 | E (Commands focus) and the button zoom to the edit selection with Fit To Window heights and back; Alt+Shift+E and Alt-click cancel it and keep the view. The Zoom Toggle preferences (Vertical Zoom, Horizontal Zoom, Track Height, Track View, Separate Grid Settings When Zoomed In, Zoom Toggle Follows Edit Selection, Remove Range Selection After Zooming In) are not present |
| Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial |
| Link timeline/edit, link track/edit, Tab to transient, insertion follows playback | ✅ | |
| Mirrored MIDI, MIDI merge, layered editing | 🟡 | Stored and partly honoured |
@@ -69,6 +70,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-25 | minor | Zoom Toggle row: Alt+Shift+E and Alt-click cancel Zoom Toggle and keep the view. Readiness estimate unchanged |
| 2026-10-25 | minor | Edit and Mix windows: Tracks and Groups and Clips in full-height dock columns. Readiness estimate unchanged |
| 2026-10-16 | minor | Track name menu: Scroll Into View brings the track to the top of the Edit window and the left of the Mix. Readiness estimate unchanged |
| 2026-10-13 | minor | A plugin opens when it's inserted (#486) |
+12 -4
View File
@@ -20,10 +20,12 @@ APP_ID=ai.storyteller.soundcraft
FLATHUB=https://dl.flathub.org/repo/flathub.flatpakrepo
TEST=1
OCI=0
TARBALL=""
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case "$1" in
--no-test) TEST=0; shift ;;
--oci) OCI=1; shift ;;
-h | --help) sed -n '2,14p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
@@ -38,7 +40,7 @@ case "$ARCH" in
esac
BASENAME="soundcraft-$VERSION-linux-$ARCH"
TARBALL="${TARBALL:-$DIST/$BASENAME.tar.gz}"
[[ -f "$TARBALL" ]] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
[ -f "$TARBALL" ] || { echo "error: $TARBALL not found (run packaging/linux/package.sh --formats tar first)" >&2; exit 1; }
for tool in flatpak flatpak-builder; do
command -v "$tool" >/dev/null || { echo "error: $tool not found (apt install flatpak flatpak-builder librsvg2-common)" >&2; exit 1; }
done
@@ -49,7 +51,7 @@ WORK="$CARGO_TARGET_DIR/flatpak-bundle"
rm -rf "$WORK"
mkdir -p "$WORK/stage"
tar -xzf "$TARBALL" -C "$WORK/stage" --strip-components=1
[[ -x "$WORK/stage/bin/soundcraft" ]] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
[ -x "$WORK/stage/bin/soundcraft" ] || { echo "error: $TARBALL has no bin/soundcraft" >&2; exit 1; }
cp "$HERE/flatpak/$APP_ID.bundle.yml" "$WORK/$APP_ID.yml"
flatpak remote-add --user --if-not-exists flathub "$FLATHUB"
@@ -61,8 +63,14 @@ OUT="$DIST/$BASENAME.flatpak"
# --runtime-repo lets `flatpak install` fetch the freedesktop runtime from Flathub if missing.
flatpak build-bundle --runtime-repo="$FLATHUB" "$WORK/repo" "$OUT" "$APP_ID" stable
echo "wrote $OUT"
if [[ "$OCI" = 1 ]]; then
OUT_OCI="$DIST/$BASENAME-oci"
rm -rf "$OUT_OCI"
flatpak build-bundle --oci "$WORK/repo" "$OUT_OCI" "$APP_ID" stable
echo "wrote OCI layout to $OUT_OCI"
fi
if [[ "$TEST" = 1 ]]; then
if [ "$TEST" = 1 ]; then
flatpak install --user -y --noninteractive --reinstall "$OUT"
flatpak info --user "$APP_ID"
flatpak run --command=soundcraft-cli "$APP_ID" --version
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Build soundcraft as an OCI container layout and push it to GitHub Container Registry (GHCR)
# for consumption by Flatpak OCI clients (flatpak-oci-specs).
#
# Usage:
# packaging/linux/flatpak-oci.sh [--push] [TARBALL]
#
# Environment variables:
# REGISTRY Target registry image (default: ghcr.io/storytold/soundcraft)
# TAG Target image tag (default: $VERSION-$ARCH, also tags stable-$ARCH)
# GITHUB_TOKEN Token for GHCR authentication when --push is used
set -euo pipefail
# shellcheck source=../env.sh
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
HERE="$ROOT/packaging/linux"
PUSH=0
TARBALL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--push) PUSH=1; shift ;;
-h | --help) sed -n '2,12p' "$0"; exit 0 ;;
-*) echo "unknown argument: $1" >&2; exit 2 ;;
*) TARBALL="$1"; shift ;;
esac
done
ARCH="$(uname -m)"
case "$ARCH" in
x86_64) ;;
aarch64 | arm64) ARCH=aarch64 ;;
*) echo "unsupported architecture $ARCH" >&2; exit 2 ;;
esac
BASENAME="soundcraft-$VERSION-linux-$ARCH"
OUT_OCI="$DIST/$BASENAME-oci"
echo "==> Building Flatpak OCI layout for $BASENAME"
"$HERE/flatpak-bundle.sh" --oci --no-test "${TARBALL:-}"
[[ -d "$OUT_OCI" ]] || { echo "error: $OUT_OCI layout not generated" >&2; exit 1; }
REGISTRY="${REGISTRY:-ghcr.io/storytold/soundcraft}"
TAG="${TAG:-$VERSION-$ARCH}"
echo "==> OCI layout ready at $OUT_OCI (Target: $REGISTRY:$TAG)"
if [[ "$PUSH" = 1 ]]; then
command -v skopeo >/dev/null || { echo "error: skopeo is required to push OCI images (apt install skopeo)" >&2; exit 1; }
AUTH_ARGS=()
if [[ -n "${GITHUB_TOKEN:-}" ]]; then
AUTH_ARGS=(--dest-creds "${GITHUB_ACTOR:-storytold}:$GITHUB_TOKEN")
fi
echo "==> Pushing to $REGISTRY:$TAG"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:$TAG"
# Also update architecture-specific stable tag
echo "==> Pushing to $REGISTRY:stable-$ARCH"
skopeo copy --format oci "${AUTH_ARGS[@]}" "oci:$OUT_OCI" "docker://$REGISTRY:stable-$ARCH"
echo "==> Push complete"
fi