132 Commits

Author SHA1 Message Date
Brandon Thomas 6cf896e76c Release 0.6.1: rebuild with the fixed shared release workflow
Packaging lint / lint (push) Has been cancelled
The previous release build failed in craft-common-actions, not in this
repo: riscv64 packaging got --formats deb,rpm,tar (fixed in
craft-common-actions#3), Linux jobs lacked the ALSA headers
(craft-common-actions#5), and Windows signing needed the AZURE_* secrets
listed in the caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:11:17 -04:00
Brandon Thomas df0c4790b7 Release prep: 0.6.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:44:38 -04:00
Brandon Thomas acb08ff5f0 Merge pull request #108 from scottcampbelldata/sampler-instrument
Add a built-in Sampler instrument
2026-10-11 21:06:08 -04:00
Brandon Thomas b9dec547df Merge remote-tracking branch 'origin/main' into sampler 2026-10-11 21:02:27 -04:00
Brandon Thomas 525f00b719 Merge pull request #157 from grovebit/unsaved-changes
Ask before discarding unsaved changes; Save opens Save As for new sessions
2026-10-11 20:59:57 -04:00
Brandon Thomas 48c3f65e12 Merge pull request #160 from grovebit/menu-fixes
Clip > Rename opens its dialog; Undo and Redo name their step; the Window menu's keys work
2026-10-11 20:59:52 -04:00
Brandon Thomas 6754f04a05 Merge pull request #192 from grovebit/idle-repaint
Let the desktop app sleep when idle or covered
2026-10-11 20:59:48 -04:00
Brandon Thomas 7f2a6977b5 Merge pull request #56 from grovebit/trackpad-scrolling
Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom
2026-10-11 20:59:45 -04:00
Brandon Thomas c5a8fd2f52 Merge pull request #49 from grovebit/drag-selection
Fix drag selection and the insertion point in the Edit window
2026-10-11 20:59:41 -04:00
Brandon Thomas 7c7e8deaaa Merge pull request #158 from natocool2/fix/web-files
Web: open, import, save and export files in the browser (fixes #63, fixes #64)
2026-10-11 20:59:37 -04:00
Brandon Thomas 6347aad211 Rebuild on the amended drag-selection (#49)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:57:17 -04:00
Brandon Thomas c5efedd740 Merge drag-selection (#49) into trackpad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:57:06 -04:00
Brandon Thomas ef0ce90b66 Merge origin/main into drag-selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:43 -04:00
Brandon Thomas 2ec2698331 Grid moves snap the grabbed clip, not whatever is under the pointer now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:56:37 -04:00
Brandon Thomas f855836af1 Idle test: playing repaints at the adaptive meter rate from #207
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:51:00 -04:00
Brandon Thomas 7a1035a3d5 Merge origin/main into sampler-instrument
#181 already pushes instrument params each block, so drop the duplicate
apply_instrument_params. Import Session Data now brings a Sampler's sample
along and remaps its source id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:45:12 -04:00
Brandon Thomas 480b0b2dd7 Merge pull request #211 from storytold/fmt-send-doc
rustfmt the mix.send spec
2026-10-11 20:43:32 -04:00
Brandon Thomas b01abf6dd7 rustfmt the mix.send spec
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:43:27 -04:00
Brandon Thomas d37386721a Merge origin/main into unsaved-changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:42 -04:00
Brandon Thomas 1c52ad1c6e Merge origin/main into idle-repaint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:34 -04:00
Brandon Thomas e7e4a19cfa Merge origin/main into fix/web-files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:42:26 -04:00
Brandon Thomas 18f81b037b Merge branch 'edit-drag' into trackpad 2026-10-11 20:41:55 -04:00
Brandon Thomas ad953ed82d Merge origin/main into drag-selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:41:55 -04:00
Brandon Thomas 03bebb572d Merge remote-tracking branch 'origin/main' into menu-fixes 2026-10-11 20:41:39 -04:00
Brandon Thomas 5a34f4118c Merge pull request #200 from Jafz2001/fix/mixer-routing-qol
Improve mixer routing menus and numeric controls
2026-10-11 20:41:06 -04:00
Brandon Thomas 59476adfd3 Merge pull request #202 from Jafz2001/fix/macos-plugin-editor-windows
Keep macOS plugin editors out of the Dock
2026-10-11 20:41:02 -04:00
Brandon Thomas 2d7311197d Merge pull request #188 from grovebit/window-fixes
Scroll long plugin windows; reopen the main window at its last size and scale
2026-10-11 20:40:58 -04:00
Brandon Thomas 4e846b8f02 Merge pull request #75 from grovebit/clip-list-delete
Clear clips and audio files from the Clip List (#4)
2026-10-11 20:40:53 -04:00
Brandon Thomas 3e97e49bfb Merge pull request #70 from grovebit/export-clips-shortcut
Shift+Cmd+K exports clips as files
2026-10-11 20:40:48 -04:00
Brandon Thomas ef04444f69 Merge pull request #84 from grovebit/record-to-disk
Record takes straight to disk
2026-10-11 20:40:44 -04:00
Brandon Thomas 4e1c18f949 Merge pull request #181 from rollroyces/fix/soundcraft-152-instrument-level-bounce
fix(mix): push an instrument's stored params to its plugin before each block (#152)
2026-10-11 20:40:40 -04:00
Brandon Thomas 19fb808b2b Merge pull request #195 from grovebit/save-safety
Save safety: never overwrite audio or sessions, sync saves and keep backups
2026-10-11 20:40:36 -04:00
Brandon Thomas 3055cbb0c0 Measure the #152 regression test past parameter smoothing
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:37:56 -04:00
Brandon Thomas e27a7e8ea4 Merge origin/main into idle-repaint
Keep the adaptive repaint rates from #207 and meters_live() from #187 in ui().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:34:51 -04:00
Brandon Thomas b25a49ee5a Merge origin/main into fix/mixer-routing-qol
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:34:16 -04:00
grovebit 157e2a9547 Fix drag selection and the insertion point in the Edit window
Fixes #166: dragging a selection up from the last track deselected it and
selected every track above instead. Fixed, along with related Edit-window drag
problems:

- Drag selection spans exactly the tracks between the press and the pointer,
  in either direction, and the whole window updates in the same frame.
- Shift+click in another track adds it to the selection with the same range,
  as in Pro Tools, so the selected tracks no longer have to be adjacent. A
  click outside the range extends it.
- Track names follow Pro Tools' rules in the Edit window, the Mix window and
  the Tracks list: Shift+click selects every track between the nearest
  selected one and the clicked one, and Cmd+click (Ctrl+click on Windows and
  Linux) adds or removes one. A click on a name used to replace the selection;
  only the Edit header's background added, and it treated Shift like Cmd.
- Dropping a track's name plate lower down no longer sends the track to the end.
- Drags act where you pressed: a quick drag from a clip edge now trims instead
  of moving the clip.
- Dragging past the edge of the track area scrolls, and a flick past the last
  track selects down to it.
- The insertion point moves on press, shows immediately, and only on the
  selected tracks.
- edit.select no longer makes a new document revision when nothing changed
  (each one is resent to the audio engine).
- Fixes a panic when a fade drag's clip shrinks to zero length.
2026-10-12 01:32:39 +01:00
Brandon Thomas 638f32d705 Merge drag-selection (#49) into trackpad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:22:45 -04:00
Brandon Thomas deea994688 Grid moves snap the grabbed clip, not whatever is under the pointer now
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:22:24 -04:00
Brandon Thomas 050bfbd140 Merge remote-tracking branch 'origin/main' into edit-drag 2026-10-11 20:22:04 -04:00
Brandon Thomas 7c26ea4f44 Merge remote-tracking branch 'origin/main' into clear-clips 2026-10-11 20:21:45 -04:00
Brandon Thomas 3b947b94bd Deselect All also clears the Clip List's file selection
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:21:45 -04:00
Brandon Thomas 7b2f1ac200 Merge origin/main into export-clips-shortcut
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:20:34 -04:00
Brandon Thomas 69f76ca0a5 Merge remote-tracking branch 'origin/main' into rec-disk 2026-10-11 20:19:50 -04:00
Brandon Thomas d87d0373ee Record to disk: keep a partial frame between pops so channels never shift
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:19:50 -04:00
Brandon Thomas 6a35fcd3a9 Re-targeting a send keeps its level, pan, mute and pre/post
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:19:19 -04:00
Brandon Thomas df49660bb5 Merge origin/main into window-fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:18:16 -04:00
Brandon Thomas df70f6ad69 Ignore a corrupt saved window size
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:18:11 -04:00
Brandon Thomas ec920cd975 Instrument params: never read insert slot A's automation; regression tests for #152
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:17:53 -04:00
Brandon Thomas 8f432ee0c1 Merge origin/main into fix/web-files
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:27 -04:00
Brandon Thomas 69570e5b54 Cap session bundles at 1 GiB: 4 GiB is all of wasm32's address space
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:16:15 -04:00
Brandon Thomas 66bf162aa9 Drop UtilityWindow: AppKit documents it only for NSPanel
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:15:57 -04:00
Brandon Thomas 8d294c08f8 Merge origin/main into save-safety
Keep the never-overwrite writer, and also give sources that share a path
their own files (#123). Save Copy In checks replace, then uses
save_session_copy (#124).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:15:56 -04:00
Brandon Thomas cc36a3eba6 Merge remote-tracking branch 'origin/main' into menu-fixes 2026-10-11 20:14:54 -04:00
Brandon Thomas a10eb47032 Merge pull request #187 from grovebit/meter-fixes
Keep armed tracks' meters live while stopped; make Clear All Clip Indicators work
2026-10-11 20:13:31 -04:00
Brandon Thomas fc095e7fe5 Merge origin/main into meter-fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:05:57 -04:00
Brandon Thomas 902220e4d4 Merge pull request #148 from grovebit/heal-shortcut
Cmd+H heals instead of hiding SoundCraft on macOS (#140)
2026-10-11 20:05:09 -04:00
Brandon Thomas a77c351da3 Merge pull request #147 from grovebit/mix-edit-toggle-zoom
Cmd+= only switches Mix/Edit, and the Interface scale slider drags smoothly
2026-10-11 20:05:05 -04:00
Brandon Thomas 5243f9a2a8 Merge pull request #208 from Jafz2001/perf/reuse-mix-buffers
perf: reuse realtime mix buffers
2026-10-11 20:05:01 -04:00
Brandon Thomas ce8eda7575 Merge pull request #207 from Jafz2001/perf/adaptive-meters
perf: adapt meter polling and repaint rate
2026-10-11 20:04:56 -04:00
Brandon Thomas bb1048e0df Merge pull request #203 from storytold/release-pass-azure-secrets
Release workflow: pass the Azure signing secrets
2026-10-11 20:04:52 -04:00
grovebit 5f122fb30d Fix drag selection and the insertion point in the Edit window
Fixes #166: dragging a selection up from the last track deselected it and
selected every track above instead. Fixed, along with related Edit-window drag
problems:

- Drag selection spans exactly the tracks between the press and the pointer,
  in either direction, and the whole window updates in the same frame.
- Shift+click in another track adds it to the selection with the same range,
  as in Pro Tools, so the selected tracks no longer have to be adjacent. A
  click outside the range extends it.
- Dropping a track's name plate lower down no longer sends the track to the end.
- Drags act where you pressed: a quick drag from a clip edge now trims instead
  of moving the clip.
- Dragging past the edge of the track area scrolls, and a flick past the last
  track selects down to it.
- The insertion point moves on press, shows immediately, and only on the
  selected tracks.
- edit.select no longer makes a new document revision when nothing changed
  (each one is resent to the audio engine).
- Fixes a panic when a fade drag's clip shrinks to zero length.
2026-10-12 01:04:47 +01:00
Brandon Thomas f68e6c438a Merge pull request #198 from itrycodinganditsbad/fxc
Windows: compile DX12 shaders with FXC
2026-10-11 20:04:47 -04:00
Brandon Thomas 6122dac051 Merge pull request #197 from itrycodinganditsbad/dmg-srcfolder
macOS: build the DMG with hdiutil create so the app passes codesign --strict
2026-10-11 20:04:43 -04:00
Brandon Thomas 1cf2dfd781 Merge pull request #178 from storytold/ci-speedup-nightly-platform-checks
CI: FreeBSD and Windows ARM64 nightly and on release, not per PR
2026-10-11 20:04:40 -04:00
Brandon Thomas 3f44d4ed43 Merge pull request #177 from coygeek/fix/soundcraft-light-instrument-contrast-20261010
fix(theme): restore Light Mix Instrument label contrast
2026-10-11 20:04:35 -04:00
Brandon Thomas 9bd609a572 Merge pull request #146 from salignatmoandal/fix/mcp-batch-keep-going-iserror
MCP batch: surface failures when keep_going is set (#145)
2026-10-11 20:04:31 -04:00
Brandon Thomas 99b3ffe48a Merge pull request #135 from vbasky/credit/vbasky-name
Credit Vikram Bhaskaran by real name and display name.
2026-10-11 19:51:00 -04:00
Brandon Thomas 3731299851 Merge pull request #134 from vbasky/fix/104-volume-to-clip-gain
Convert only the selected volume automation into clip gain.
2026-10-11 19:50:56 -04:00
Brandon Thomas 7442bc9bb1 Merge pull request #133 from vbasky/fix/103-aux-latency
Compensate for an aux insert's latency on the same block that measures it.
2026-10-11 19:50:52 -04:00
Brandon Thomas f1ff34f533 Merge pull request #132 from vbasky/fix/92-clip-gain-stretch
Bake varispeed into rendered clip gain, then stop stretching it again.
2026-10-11 19:50:49 -04:00
Brandon Thomas 0f32d4d4cc Merge pull request #131 from vbasky/fix/59-bounce-bus
Render a named bus when bouncing, and reject an unknown one.
2026-10-11 19:50:45 -04:00
Brandon Thomas c716584e19 Merge pull request #130 from vbasky/fix/88-audiosuite-gain
Keep clip gain when AudioSuite inverts or duplicates.
2026-10-11 19:50:42 -04:00
Brandon Thomas a8452b908c Merge pull request #129 from vbasky/fix/101-playback-speed
Restore full playback speed when transport stops.
2026-10-11 19:50:38 -04:00
Brandon Thomas f935d6ee79 Merge pull request #127 from vbasky/fix/95-cli-command-ids
Treat dotted command ids as engine.execute in the CLI app shorthand.
2026-10-11 19:50:34 -04:00
Brandon Thomas ceae137642 Merge pull request #126 from vbasky/fix/96-closed-stdout
Keep going when the CLI's stdout is closed.
2026-10-11 19:50:30 -04:00
Brandon Thomas 776b771f59 Merge pull request #128 from vbasky/fix/89-surround-import
Import multichannel audio onto a track of the same width.
2026-10-11 19:49:37 -04:00
Brandon Thomas b76161b698 Merge pull request #125 from vbasky/fix/87-midi-export
Export MIDI controllers and honour the tracks filter.
2026-10-11 19:49:34 -04:00
Brandon Thomas cc0eb1caf5 Merge pull request #124 from vbasky/fix/91-save-copy
Save a session copy without retargeting the open document.
2026-10-11 19:49:31 -04:00
Brandon Thomas adb14703e4 Merge origin/main into fix/89-surround-import
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:11 -04:00
Brandon Thomas cb655d9ec4 Merge origin/main into fix/87-midi-export
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:07 -04:00
Brandon Thomas 6a12573e4f Merge origin/main into fix/91-save-copy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:49:02 -04:00
Brandon Thomas cffc932e57 Merge pull request #123 from vbasky/fix/86-consolidation-files
Keep each consolidation in its own audio file.
2026-10-11 19:48:51 -04:00
Brandon Thomas e7c31321f3 Volume to clip gain: keep the level outside the selection; keep clip gain points; ramps reach their end
- When every lane point lay inside the selection the lane was cleared,
  so the held level outside fell back to the fader (6 dB louder in the
  repro). Clear only when all clips lie inside the selection.
- The clip's own gain points inside the selection are kept, offset by
  the lane, instead of being dropped.
- Volume/clip-gain ramps now end on the block's last sample, so
  interpolate over n-1 steps and actually reach the end value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:44:08 -04:00
Brandon Thomas 55dfcc95d1 Close the export-clips test the rebase left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:43:41 -04:00
Brandon Thomas 75430d2472 Close the export-clips test the rebase left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:43:41 -04:00
Brandon Thomas 113292cc1a Close the export-clips test the rebase left open
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:43:40 -04:00
Jafz2001 9850a41f29 perf: reuse realtime mix buffers 2026-10-11 19:36:39 -03:00
Jafz2001 eddc846f51 perf: adapt meter polling and repaint rate 2026-10-11 19:33:37 -03:00
Jafz2001 d552da531f fix: keep macOS plugin editors out of the Dock 2026-10-11 19:03:38 -03:00
Brandon Thomas e60c41c2c8 Release workflow: pass the Azure signing secrets so Windows builds are signed
Since the release workflow moved to storytold/craft-common-actions, it has
passed only the Apple and WINDOWS_CERTIFICATE secrets. The signing job reads
values from the release environment, but only for names the caller lists, so
the AZURE_* names that Trusted Signing uses came through empty and Windows
builds would ship unsigned. Same fix as storytold/craft-launcher#174.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 18:03:21 -04:00
Jafz2001 b9345a3576 feat: improve mixer routing and numeric controls 2026-10-11 18:29:02 -03:00
Fabio Lussi e102ed3656 Windows: compile DX12 shaders with FXC
wgpu's default DX12 compiler (Auto) loads whichever dxcompiler.dll the DLL
search path finds. SoundCraft ships none, and without a matching dxil.dll the
unsigned-DXIL warning fails wgpu's indirect-validation pipeline, the device
is lost and the app exits before the first frame. Pin the compiler to FXC,
which is part of Windows; WGPU_DX12_COMPILER still overrides it.

Fixes #185

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:37:56 +02:00
Fabio Lussi 27d934e726 macOS: build the DMG with hdiutil create so the app passes codesign --strict
hdiutil makehybrid -hfs put a com.apple.FinderInfo on every file in the
image, so the signed app inside the DMG (and any ditto copy of it) failed
codesign --verify --deep --strict. Build the image with
hdiutil create -srcfolder (retried, since create can hit "Resource busy" on
CI), clear build-time xattrs before signing, and verify the app inside the
finished DMG and a ditto copy of it with --strict.

Ported from storytold/pdfcraft#823 and storytold/lightcraft#959.

Fixes #176

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:09:39 +02:00
grovebit 18a2558104 Ask to save changes before closing a modified session
Close Session, New, Open, Revert, Quit, the window's close button,
dropping a session file on the window and opening one from the Workspace
window all replaced or closed the session without asking, so unsaved
edits were lost (#136). They now ask first: Save, Don't Save or Cancel.
Save on a session that has never been saved goes through Save As, then
carries on. Revert asks for confirmation instead, and Revert to Saved is
unavailable until the session has a file. A take being recorded is
stopped first, so it counts as a change. The web build has nowhere to
save, so it offers only Don't Save and Cancel. Calls from the control
channel, MCP and the CLI never ask.

Selecting, locating, zooming and scrolling no longer mark the session
modified, so the question only comes after real edits. Undoing back to
the saved state makes the session clean again. The engine compares the
document with the one last opened or saved, ignoring the selection, the
playhead and the zoom, once per revision.

Fixes #136
2026-10-11 20:13:48 +01:00
grovebit 54e3761d62 Open sessions whose plugin parameters were saved as null
JSON has no NaN or infinity, so serde writes them as null, and loading
null into a plugin parameter failed the whole session with "not a
SoundCraft session: invalid type: null, expected f32". One plugin
reporting a non-finite value, or a value like 1e39 that overflows f32
in an edited file (it loads as infinity), made the session and every
later save and autosave of it unopenable.

Insert and instrument parameters that are null or overflow f32 are now
dropped on load, so they keep the plugin's default and the rest of the
session opens.
2026-10-11 20:03:47 +01:00
grovebit 153381e03b Flush saves to disk and keep backups of the session file
Nothing a save wrote was synced to disk, so a power cut or crash of the
system soon after Cmd+S could leave an empty or truncated session file
(the rename can reach the disk before the data), or audio files that
the session points at but that never reached the disk. No earlier
version of the session file was kept either.

New audio files, the Audio Files folder and the session's temporary
file are now synced before the session file is renamed into place, and
the session folder after the rename; templates are written the same
way. A failed write or rename removes its temporary file instead of
leaving it on a full disk. Before a save replaces the session file with
different contents, the previous version is copied to
Backups/<name> backup <n>.scraft beside it, and the newest ten are
kept. Autosaves make no backups.
2026-10-11 20:03:47 +01:00
grovebit 62a0b683e5 Ask before Save As replaces another session
Save As, Save Copy In, Save As Template and Export Selected Tracks as
New Session wrote over any existing file without asking. The Save As
prompt proposes ~/<name>/<name>.scraft, so every new session suggests
~/Untitled/Untitled.scraft, and saving a second new session with the
suggested path replaced the first one.

These commands now refuse to write over an existing file unless
`replace` is true; Save, Save As and Save Copy In may still write the
open session's own file. Scripts, MCP agents and the control channel
can't replace a session by accident. While the path in the prompt names
an existing file, the prompt asks whether to replace it and its OK
button becomes Replace; Enter alone doesn't replace. Saving also
creates the session's folder, so a new session without audio can be
saved to the suggested path. The CLI's --save names its output
explicitly and keeps replacing it, like --bounce.
2026-10-11 20:03:47 +01:00
grovebit 86824b592a Never overwrite audio files when saving
Audio that exists only in memory (takes, consolidations, AudioSuite
renders, commits) gets the default path `Audio Files/<name>.wav`, and
Save treated an existing file at that path as the source's own and
rewrote it. Names repeat easily: two sessions in one folder both call
their first take on Audio 1 "Audio 1_02", and consolidating twice on
Kick makes two "Kick_consolidated" sources. Saving silently replaced
the earlier audio, so the other session or the earlier clip played the
wrong audio once reopened.

Save As also trusted a same-named file in the target folder for audio
saved elsewhere, so saving into another session's folder could point
the new session at that session's audio.

New audio now goes to the first free name (`<name>.wav`,
`<name>_01.wav`, ...), created with create_new so no existing file is
ever truncated, or to a file that already holds exactly the same bytes,
such as a take saved again after undoing past a save. Saved audio is
reused only when saving into the folder it was saved in, which the
session now remembers, so undoing past a Save As or saving after Save
Copy In can't pick up another session's files either. Audio that
failed to load keeps pointing at its old folder when the session is
saved elsewhere.
2026-10-11 19:59:43 +01:00
grovebit d7c3818859 Let the desktop app sleep when idle or covered
The app sent the window title to egui on every frame. Each viewport
command asks for another frame, so the window redrew at the display's
refresh rate with nothing happening: 35-43% of a CPU core and 13-20% of
the GPU per window on a 120 Hz Mac. While the window was covered or
minimized, eframe runs only App::logic, with no vsync to pace it, and
the same request kept one core at 100%.

The title is now sent only when it changes. The last title sent is kept
in egui's context data rather than compared with the window's, because
winit can't read a title back on X11 or Wayland.

Some work only ran because another frame always came, so an idle window
still wakes once a second, and every 30 ms while a plugin editor is
open. Autosave and the macOS menu bar's throttled refresh run on
frames, and plugin editors are native windows egui never hears from:
their edits (VST3 performEdit, CLAP output events) and main-thread
requests are serviced on frames. egui takes its predicted frame time
off every delay, so it is added back. The menu bar can now trail a
shortcut by up to a second instead of 250 ms.

Playback, falling meters, pending screenshots and scripted input ask
for the next frame from ui() instead of logic(). eframe skips ui() for
a covered or minimized window, so a hidden window runs on the heartbeat
instead of spinning, and egui redraws it as soon as it shows again.

Autosave was timed by egui's frame clock, which stands still while the
window is hidden, so a modified session wasn't autosaved until the
window showed again. It is timed by the wall clock now. Only the
desktop app sets an autosave folder, so the web build never reads it.

Tests check that an unchanged title sends no command, that only a shown
window animates while a hidden one wakes once a second, and that a
hidden window still autosaves.

Measured on a 120 Hz MacBook with a release build, the demo session and
audio off: an idle Edit window went from 35-43% CPU and 13-20% GPU to
1.5% and 0.4% (the insertion point blinks), the Mix window to 0.2%, and
a covered window, idle or playing, from ~100% CPU to 0%.
2026-10-11 17:31:26 +01:00
grovebit 534b44d430 Reopen the window at its last size and interface scale
The window opened at 1600x1000 on every launch, and the interface scale
set in UI Customization reset to 100%. The app now keeps the window's
size, whether it was maximized and the scale in its preferences
(ui.json, saved on quit) and restores them at launch. The size is kept
in unscaled points, so a scaled interface doesn't change the window's
size from one launch to the next. The position is left to the system,
so a disconnected monitor can't leave the window off screen. A window
too big for the screen it opens on (sized on a larger monitor) opens
maximized instead and keeps its saved size for that monitor. Runs with
SOUNDCRAFT_NO_PREFS still start at the defaults.
2026-10-11 15:57:39 +01:00
grovebit 992f60c79c Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom
- Pinch and Cmd+scroll zoom the timeline around the pointer. egui 0.36 turns
  both into zoom_delta, so the old Cmd+scroll branch never ran; Shift+wheel
  pans sideways again for the same reason.
- Two-finger panning keeps the sub-sample remainder, so slow motion still
  moves at the closest zoom; the scrollbar drag pans the same way (its range
  now takes in the view, so the thumb stays in the bar), and the rulers pan
  too. Panning stays open-ended to the right, as before.
- A swipe keeps to the axis it starts along, so panning doesn't drift between
  tracks and scrolling doesn't drift in time. The larger share of its first
  6 points decides until egui reports the scroll over: the fingers lift, or
  150 ms pass without wheel events.
- The MIDI editor scrolls by the point instead of rounding each frame's delta
  to whole rows (slow swipes did nothing), and its range fits the panel
  (pitch 0 could not be reached; a taller panel showed blank rows).
- Zoom and scroll write through `Engine::zoom_mut` (`view` commands): no
  playback resync (a session copy sent to the audio thread every frame), no
  copy of the whole session per frame, no journal entry per frame, and no
  end to an undo gesture (scrolling during an automation pass made each
  write its own undo step). New `view.zoom_by {factor, x?}`;
  `view.scroll` saturates; Fill Window and Zoom to Selection stay within the
  zoom range (zooming out after them on a long session zoomed in).
- Control channel: `ui.scroll` (trackpad swipe or wheel notches) and
  `ui.zoom` (pinch); `session.inspect` reports zoom.
2026-10-11 15:21:46 +01:00
grovebit 7208c610cd Make Clear All Clip Indicators clear them
Track > Clear All Clip Indicators (Alt+C) was a stub: the red clip
indicators on the meters stayed lit until another session was opened.
The command now clears them on every track's meter and the main meter.
2026-10-11 14:10:10 +01:00
grovebit b2a3b0f861 Keep armed tracks' meters live while the transport is stopped
Record-enabled and input-monitored tracks were heard while stopped, but
their meters froze (#42). The audio thread returned before copying the
meters whenever it wasn't playing. The UI faded every meter to zero while
stopped and stopped redrawing once they reached zero. After a selection
had played to its end, the stale end point also silenced input
monitoring until the next Play.

The audio thread now copies the meters for every block it renders and
forgets the end point and loop when playback stops. While a track is
armed or monitoring input, the UI keeps feeding and redrawing its
meters, and the Mix window's per-channel meters include armed tracks.
2026-10-11 14:10:10 +01:00
grovebit 16b7c38b6b Scroll a plugin window's parameters when they don't fit
A plugin window listed every parameter in one grid. VST3 and CLAP
plugins expose all of theirs, so a plugin with more parameters than fit
on the screen ran off the bottom edge, and the ones below it couldn't be
reached (#6). The parameters now scroll inside the window, below the
header and the EQ curve, and the window stays on the screen.
2026-10-11 14:09:03 +01:00
Royce 99992fe78c fix(mix): push an instrument's stored params to its plugin before each block (#152)
When a session is reopened with a saved instrument-level change such as
'level = -12 dB', the value sat in t.instrument.params but was never
written to the live plugin instance, so the bounce produced the same
audio as the default (-6 dB). The inserts loop already calls
apply_params per block; the instrument branch did not.

Call apply_params(slot, ins, t, INSTRUMENT_SLOT, pos) before each
instrument's process() so a saved level (or any other param set via
'mix.insert_state') reaches the plugin exactly the way insert params do.

Refs #152.
2026-10-11 15:35:02 +08:00
Brandon Thomas 75d3901ab4 CI: run FreeBSD and Windows ARM64 checks nightly and on release, not per PR
They ran on every push to main and on any PR touching Cargo.toml/Cargo.lock,
which is nearly every PR. See craftrules release/playbook.md §2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:03:08 -04:00
Coy Geek 592991789e fix(theme): restore Light Mix Instrument label contrast
Use the current populated insert fill and slot outline for Instrument
slots. This restores readable Light and System-Light labels while
preserving the original Dark colors, hover behavior and picker route.

Add a root-UI regression that checks actual emitted colors in four
palettes, preserves session/audio data, replaces the built-in instrument,
checks parameter defaults and restores the session through undo.

Include the authorized SMF match-guard prerequisite and a contract test
for first-name-wins and ignored metadata. All 16 focused SMF tests and
the complete populated Instrument regression passed.

Full CI passed formatting and cleared the SMF lint, then stopped on
unchanged metronome and MP3 Clippy expressions. Later CI stages did not
run. Full local CI before publication was explicitly waived.

Closes #161
2026-10-10 20:46:49 -07:00
Vikram Bhaskaran 849cc79675 Keep each consolidation in its own audio file.
A second consolidate of the same track reused Audio Files/{name}.wav, and saving then skipped the rename because that path already belonged to the earlier source. New sources now take a free name, and a save splits sources that already share a path instead of overwriting one file.
2026-10-11 08:49:20 +11:00
Vikram Bhaskaran 2afbfd9477 Save a session copy without retargeting the open document.
Save Session Copy In wrote through the normal save, which renamed the session, marked it clean, and pointed it at the copy. The open session now stays dirty, named, and path-ed as it was, while the copy is a separate folder.
2026-10-11 08:49:18 +11:00
Vikram Bhaskaran 09d64ab2ec Export MIDI controllers and honour the tracks filter.
file.export_midi copied notes only, so controller, pressure, and pitch-bend events were dropped, and the tracks parameter was ignored. The file now includes those events, and a tracks list limits the export. Naming only non-MIDI tracks is an error instead of exporting every other MIDI track.
2026-10-11 08:49:08 +11:00
Vikram Bhaskaran 404940b40d Import multichannel audio onto a track of the same width.
A new track for an imported file was clamped to stereo, so a 5.1 file lost every channel past the left and right. The track now follows the file's channel count. Importing onto an existing track is unchanged.
2026-10-11 08:48:52 +11:00
grovebit 6b98c21fed Make the Window menu's shortcuts work
The Window menu's keys never fired: Cmd+1 Transport, Cmd+3 Big Counter,
Cmd+4 Automation, Cmd+5 Memory Locations, Cmd+9 Video, Cmd+Alt+M Narrow
Mix, Cmd+W Close Window, Cmd+Ctrl+W Hide All Floating Windows and
Cmd+Ctrl+= Score Editor. The shortcut handler only looked at engine
commands, and these are window commands. It now falls back to them, and
the menus and the Keyboard Shortcuts window show their keys.

On a Mac, Cmd+Ctrl+= toggled Mix/Edit instead of opening the Score
Editor. The Mix/Edit toggle now needs Cmd+= without Control there.

The two Control chords still have no key on Windows and Linux.
2026-10-10 19:27:16 +01:00
grovebit bd00c3855c Name the step in the Edit menu's Undo and Redo
The Edit menu always read "Can't Undo" and "Can't Redo", the incumbent's
labels for an empty history, even when there was something to undo. The
items now name the step they would take ("Undo Mute", "Redo Mute"), in
the in-window menu bar and the macOS system menu alike, and read "Can't
Undo" / "Can't Redo" only when there is nothing.
2026-10-10 19:27:16 +01:00
grovebit aa4f038f1a Open the Rename Clip dialog from Clip > Rename
Clip > Rename... and Cmd+Alt+Shift+R ran clip.rename without a name, so
they only put "`name` required" in the status line. They now open the
Rename Clip dialog, which only the clip's right-click menu opened so
far, for the selected clip (or the first clip the edit selection
touches).

The dialog's name field, and Rename Track's, now opens with the old
name selected, so typing replaces it instead of adding to it.
2026-10-10 19:27:16 +01:00
natocool2 6964c50d00 Web: open, import, save and export files in the browser (fixes #63, fixes #64)
The web build could not reach any file: File > Open Session…, Import > Audio… and Import > MIDI…
showed a text box for a path the browser can't read, and every save or export failed with
«operation not supported on this platform», so work was lost on close.

On the web now:
- Open Session…, Import Audio… and Import MIDI… show the browser's file picker; files dropped on
  the page open (sessions) or import (audio, MIDI) by extension. Files travel as bytes; imports are
  one undo step each, like the import commands.
- A session is saved as ONE file, a bundle `<name>.scraftz`: a zip (stored entries) holding the
  `.scraft` and its `Audio Files/` as 32-bit float WAV — the same layout as a session folder, since
  the browser has no folders. `io::open_session_bytes` opens a bundle or a bare `.scraft` (its audio
  is then reported missing). Bundles are hostile input: entry count and size are capped, and
  declared sizes are never read past.
- Every single-file output (save, Save As Template, bounce, MIDI/score/text export, track presets)
  goes through `io::write_file`: a file natively, a download in the browser via a delivery hook
  (`io::set_deliver`) that only the web app installs.
- In the browser Save As asks for the bundle's name (`<name>.scraftz`) instead of a folder path.
Native builds keep writing files and session folders as before. Folder outputs (stems, clip
exports) are still not available on the web.

Pairs with #157 (Save on a never-saved session opens Save As), which the browser needs too: the
web app always starts on the unsaved demo.

New dependency: `zip` (MIT), default features off (stored entries only).

Checked: engine tests for the bundle round trip (every sample identical), same-named sources,
bare sessions and hostile bundles; `cargo xtask ci`. In Chromium on the packaged web build:
⌘⇧I picks and imports a WAV; WAV, MP3, FLAC, OGG, M4A, AIFF and MIDI import by drop; Save As →
`Midnight Groove.scraftz` downloads with the imported audio inside; that bundle reopens in a fresh
page with all tracks and no missing media, and saves again with the same content.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:17:07 +01:00
grovebit 718bb5c7fb Open Save As when saving a session that has never been saved
Cmd+S and File > Save Session on an untitled session only left "no path
yet: pass `path` (Save As)" in the status line. From a menu, the command
palette or the keyboard, Save now opens the Save Session As prompt until
the session has a file. Calls from the control channel, MCP and the CLI
still need a `path`.

The prompt suggests ~/<name>/<name>.scraft, but saving a session without
audio there failed with "No such file or directory", because only
writing audio created the session folder. Saving now creates the folder.
2026-10-10 14:20:55 +01:00
grovebit 4aa837e933 Make Cmd+H heal and Cmd+Alt+H copy to send on macOS
On macOS, Heal Separation (Cmd+H) and Copy to Send (Cmd+Alt+H) never
ran (#140). The system menu bar used muda's predefined Hide and Hide
Others items, which carry those keys, and macOS offers a key press to
the menu bar's key equivalents before the window, so egui never saw
them: Cmd+H hid SoundCraft and Cmd+Alt+H hid the other apps.

Pro Tools uses the same keys for Heal Separation and Copy Automation to
Send, so the keys stay and Hide loses its shortcut instead. Hide
SoundCraft and Hide Others are now plain menu items that call
NSApplication's hide: and hideOtherApplications:. These are safe
objc2-app-kit calls, and both crates were already in the build through
muda. Show All keeps muda's item, which has no shortcut. Windows and
Linux have no such menu, so nothing changes there.
2026-10-10 13:38:23 +01:00
grovebit 840939faf7 Cmd+= only switches Mix/Edit; the scale slider drags smoothly
Fixes #144. Unless Options::zoom_with_keyboard is off, egui zooms the
whole interface on Cmd+= (or Cmd+Plus), Cmd+- and Cmd+0. It checks for
those keys at the end of every frame, after SoundCraft's shortcuts have
read them without consuming them. So every Cmd+= that switched between
the Mix and Edit windows also zoomed in by another 10 %, and Cmd+0
(Quantize to Grid) reset the interface scale. Ctrl did the same on
Windows and Linux.

Turn egui's keyboard zoom off. The Interface scale slider in Setup >
Appearance is now the only thing that sizes the interface, so it always
shows the real scale. Before, keyboard zoom changed the scale without
moving the slider, and could go past its 75-150 % range.

Dragging that slider rescaled the whole interface on every frame of the
drag. Each rescale moved the slider relative to the pointer, which
stays put on screen, so the next frame read another value under the
pointer. The scale jumped back and forth (105, 110, 105 ... 145, 120,
150, 90 % in one drag) and the slider would not stay where it was
dragged. Apply the scale once the mouse button is released. It is now
applied after the window has drawn, too, so a click, Reset or an arrow
key takes effect in the same frame instead of waiting for the next
input.
2026-10-10 13:37:19 +01:00
Mawen Salignat-Moandal 15fea1ed5d MCP batch: set isError when any call fails, even with keep_going.
Agents that only check isError were treating partial failures as success. Also reject a non-array calls value instead of silently running nothing. Fixes #145.
2026-10-10 14:23:08 +02:00
grovebit 9ed4bdd4b1 Shift+Cmd+K exports clips as files
Export Clips as Files had no shortcut and no menu entry, so Shift+Cmd+K,
its key in Pro Tools, did nothing (#7). It now opens the command's
folder prompt, and OK exports the selected clips there.

Trying it showed three more gaps:
- Browse… in a folder prompt opened an audio-file picker, so no folder
  could be chosen. Folder prompts (Export Clips, Bounce Stems) now use a
  folder picker, a new `pick_folder` host service; the web build has
  none and keeps the text field.
- A successful export said nothing. The status bar now reads "Exported
  N clip(s) to <folder>", and the command returns the folder.
- With nothing selected the export succeeded and wrote no files. It now
  fails with "no clips selected".

The shortcut lookup moves into `command_for` so a test can check what a
key resolves to.
2026-10-10 11:45:22 +01:00
Vikram Bhaskaran 7b25efd8cd Credit Vikram Bhaskaran by real name and display name.
The About window can show either name when the viewer picks that label. The GitHub profile name stays unused.
2026-10-10 17:38:55 +11:00
Vikram Bhaskaran 59721f7c87 Compensate for an aux insert's latency on the same block that measures it.
The bounce probe read aux latency before the aux had processed, so a lookahead compressor shifted the export and dropped audio at the end of the range.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran fe4f59a856 Bake varispeed into rendered clip gain, then stop stretching it again.
Clip gain and clip effects renders wrote the clip as it already sounded and left the old ratio in place, so the new file was stretched a second time and its tail went silent.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran abcec3938b Render a named bus when bouncing, and reject an unknown one.
file.bounce_mix advertised a source but always wrote the main mix, so an empty bus and a missing bus both succeeded with the whole mix.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran ec33d4011b Keep clip gain when AudioSuite inverts or duplicates.
Both processes rendered a unity-gain file and then cleared the clip's gain and envelope, so the result was quieter than the clip the user heard. Invert and Duplicate now leave that gain on the new clip. Processes that bake a new level still write a unity-gain clip.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran 35a6fbccc0 Restore full playback speed when transport stops.
Half-Speed Playback stored 0.5 on the player, and a manual Stop left that rate in place, so the next ordinary Play stayed at half speed. Stopping now resets the player to 1.0. Half-speed itself still plays at 0.5, and a pass that ends on its own still returns to full speed.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran 1aad99ff4c Convert only the selected volume automation into clip gain.
The command wrote the lane onto the whole clip and cleared points only inside the selection, so the old curve still played and the clip got quieter on both sides. The selection now holds the fader, with anchors so the surrounding curve stays put. Volume and clip-gain windows take their end value from the last sample inside the window, so a breakpoint on the next sample does not ramp the audio before it.
2026-10-10 17:38:54 +11:00
Vikram Bhaskaran 734c8e18bc Treat dotted command ids as engine.execute in the CLI app shorthand.
app used to leave every ui., engine., session., and app. method untouched, so engine.history, ui.theme, and session.new were sent as unknown control methods. Only the real control-channel methods stay methods. Every other dotted id is engine.execute.
2026-10-10 17:38:53 +11:00
Vikram Bhaskaran c4a88ee64a Keep going when the CLI's stdout is closed.
outln! treated a failed write as success and returned immediately, so run and script stopped before --bounce or --save whenever a pipe reader had gone away. A write error is now ignored and the exit code comes from the work itself.
2026-10-10 17:38:53 +11:00
Scott Campbell fa88fbb942 Add a built-in Sampler instrument
A third built-in instrument next to Subtractive Synth and Drum Synth. It plays one
sample across the keyboard: root note, transpose and fine tune, key tracking, key and
velocity ranges, amp ADSR with velocity sensitivity, sample start, forward or ping-pong
loops, gate or one-shot playback, 32 voices with cubic interpolation. The sample's own
rate is converted as it plays.

- dsp: `Plugin::set_sample` and the `SampleSource` trait hand an instrument shared
  audio without copying; the Adsr from the synth is reused.
- model: `Insert::sample` names the session source the sampler plays.
- mix: sync hands the pool audio to the instrument when it changes, and built-in
  instruments now get their parameter values applied (they always ran on defaults).
- engine: `mix.sampler_load {track?, path, params?}` decodes WAV, AIFF, FLAC, MP3, OGG
  (anything file.import_audio reads) into a session source, resampled to the session
  rate and saved in Audio Files with the session; `mix.sampler_clear`; and
  `mix.instrument_param {track?, param, value}` for any built-in instrument.

Tests generate their audio in code; no bundled assets.
2026-10-09 22:55:24 -04:00
grovebit cfe6b42d00 Record takes straight to disk
The input callback appended every block to one growing Vec: it
reallocated on the audio thread as the take grew, kept every input
channel in RAM, and the take existed only in memory until Stop, so a
crash during a take lost it.

- The input callback copies each block into a preallocated lock-free
  ring (atomic slots, no unsafe) and never locks or allocates. Samples
  that don't fit are paid back as silence before the next block, so the
  take keeps its timing; the loss is reported in the status line.
- A writer thread drains the ring every 10 ms into one 32-bit float WAV
  per armed track, holding only that track's input channels. Files go
  to the session's Audio Files folder, or to its autosave folder while
  the session has never been saved, named like the incumbent's takes
  (<track>_<NN>.wav). An existing file is never overwritten.
- The header is rewritten every second, so a file left by a crash plays
  up to the last update. Above 4 GiB it switches to RF64 in place.
- If a take file can't be created, Record doesn't start and says why;
  a write error ends that take where it failed.
- At Stop the files become the takes' sources. Loop recording keeps one
  file per track, one clip per pass (at most 1000) on its own playlist,
  in one undo step.
- Saving uses a file already in the target's Audio Files folder in
  place instead of rewriting it, so neither Save nor autosave copies a
  take. A take recorded at another rate than the session's is saved once
  at the session's rate.

soundcraft-audio-io gains wav_float_header (its length doesn't depend
on the frame count, so it can be rewritten in place); encode shares the
header code. Splitting a loop take into passes moved from the UI into
soundcraft_engine::io::add_recording, where it is tested.
2026-10-10 02:22:18 +01:00
grovebit 42d08558ab Clear clips and audio files from the Clip List
Nothing in the Clip List could be removed: neither its files nor its clips
had a delete action, so everything stayed listed.

- New `clip.clear` (Cmd+Shift+B, the Clip List Clear shortcut in Pro Tools),
  also offered as Clear… on every Clip List row's right-click menu. A clip
  leaves its track; a file leaves the session with its decoded audio and is
  never deleted from disk. One undo step brings everything back.
- As in Pro Tools, Clear asks first. The dialog names what goes, including
  the clips on any track or playlist that still play a file being removed:
  they are removed too. Programmatic calls never open it; they pass
  `with_clips: true` to remove a file still in use, and fail without
  changing anything otherwise.
- File rows can be selected now (`edit.select {sources}`, kept in
  `EditState::selected_sources`); with no ids, `clip.clear` acts on the
  Clip List selection.
- Remove Warp and re-warping ignore a stored original whose file has been
  cleared, so they never point a clip at a missing file.
2026-10-10 02:07:51 +01:00
102 changed files with 6984 additions and 1103 deletions
-12
View File
@@ -1,12 +0,0 @@
# Strip upstream (ArtCraft / Discord) branding. Run: perl tools/craft-skills/skills/debrand/debrand.pl . (--check in CI)
delete docs/brand
# MIT/Apache texts stay verbatim. The copyright holder line stays everywhere (legal requirement).
keep ^(LICENSE-(MIT|APACHE)|\.debrand)$
dropline ^ATTRIBUTION\.md$ :: \|\s*`docs/brand/
sub (?s)\nThe ArtCraft name, wordmark and logos in docs/brand/.*?LICENSE-brand\.txt\.\n =>
sub \n- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand\.txt`\. =>
sub until the owner draws SoundCraft's icon in ArtCraft like => until the owner draws SoundCraft's icon like
sub https://getartcraft\.com/apps/soundcraft => https://github.com/storytold/soundcraft
sub https://discord\.gg/artcraft => https://github.com/storytold/soundcraft/issues
forbid (?i)artcraft(?! Team and the SoundCraft contributors)
forbid (?i)discord
+8 -4
View File
@@ -3,16 +3,20 @@
# player falls back to its silent clock when the VM has no sound device.
name: FreeBSD
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every push
# or dependency change: a drifted dependency still shows up within a day, and always before a
# release ships. PRs that change the FreeBSD packaging itself still run it.
on:
schedule:
- cron: "47 3 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "crates/playback/**"
- .github/workflows/freebsd.yml
- packaging/freebsd/**
- packaging/env.sh
workflow_dispatch:
concurrency:
+6
View File
@@ -34,3 +34,9 @@ jobs:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }}
WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }}
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
+5 -7
View File
@@ -4,19 +4,17 @@
# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.)
name: Windows ARM64
# Nightly on main and on pushes to `release` (dependency and runner-image drift shows up within a
# day, and always before a release ships), and on PRs that change the Windows packaging itself.
on:
schedule:
- cron: "53 5 * * *"
push:
branches: [main]
branches: [release]
pull_request:
paths:
- Cargo.toml
- Cargo.lock
- "**/Cargo.toml"
- "**/build.rs"
- .github/workflows/windows-arm64.yml
- packaging/windows/**
schedule:
- cron: "41 4 * * 1" # weekly, so drift in the runner images shows up
workflow_dispatch:
concurrency:
-3
View File
@@ -1,3 +0,0 @@
[submodule "tools/craft-skills"]
path = tools/craft-skills
url = https://git.marcospaulo.dev.br/storytold/craft-skills.git
+1
View File
@@ -16,6 +16,7 @@ Standards and learnings shared across the crafting apps live in `../../craftrule
- **Every bundled asset gets a row in [`ATTRIBUTION.md`](ATTRIBUTION.md)** (path, author, source, license) in the same commit that adds it. `cargo xtask assets` enforces this; CI fails otherwise.
- Never copy files out of the Pro Tools bundle (`/Applications/Pro Tools.app`): no plugins, presets, icons, sounds, session templates or text. Observing the running app (screenshots kept under `plan/`, menu names) is fine; copying its resources is not.
- Demo audio is synthesised in code (`crates/engine/src/demo.rs`). Demo and README media must be ours or public domain.
- The ArtCraft trademarks in `docs/brand/` are the only exception, under `docs/brand/LICENSE-brand.txt`.
## Clean-room
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.
+8
View File
@@ -29,6 +29,14 @@ and, at runtime, the operating system's own UI fonts when available (never redis
| 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 |
| `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
+57 -19
View File
@@ -785,7 +785,7 @@ dependencies = [
[[package]]
name = "craft-ui"
version = "0.1.0"
source = "git+https://git.marcospaulo.dev.br/masi/craft-libs?rev=22544b6d619d4f16779b8bdfa3f0b642a86fc065#22544b6d619d4f16779b8bdfa3f0b642a86fc065"
source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675"
dependencies = [
"egui",
"serde",
@@ -2142,6 +2142,18 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "nohash-hasher"
version = "0.2.0"
@@ -3412,12 +3424,14 @@ dependencies = [
[[package]]
name = "soundcraft"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
"log",
"muda",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"rfd",
"serde_json",
"soundcraft-engine",
@@ -3428,7 +3442,7 @@ dependencies = [
[[package]]
name = "soundcraft-au-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"serde",
@@ -3438,7 +3452,7 @@ dependencies = [
[[package]]
name = "soundcraft-audio-io"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@@ -3450,7 +3464,7 @@ dependencies = [
[[package]]
name = "soundcraft-automation"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"serde_json",
@@ -3460,7 +3474,7 @@ dependencies = [
[[package]]
name = "soundcraft-clap-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"clap-sys",
"libloading",
@@ -3472,7 +3486,7 @@ dependencies = [
[[package]]
name = "soundcraft-cli"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde_json",
"soundcraft-audio-io",
@@ -3483,7 +3497,7 @@ dependencies = [
[[package]]
name = "soundcraft-dsp"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@@ -3494,7 +3508,7 @@ dependencies = [
[[package]]
name = "soundcraft-engine"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"proptest",
@@ -3511,11 +3525,12 @@ dependencies = [
"soundcraft-video",
"soundcraft-vst3-host",
"thiserror 2.0.21",
"zip",
]
[[package]]
name = "soundcraft-midi"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@@ -3525,7 +3540,7 @@ dependencies = [
[[package]]
name = "soundcraft-mix"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"log",
"rayon",
@@ -3541,7 +3556,7 @@ dependencies = [
[[package]]
name = "soundcraft-model"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@@ -3554,7 +3569,7 @@ dependencies = [
[[package]]
name = "soundcraft-playback"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"cpal",
"log",
@@ -3567,7 +3582,7 @@ dependencies = [
[[package]]
name = "soundcraft-time"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"proptest",
"serde",
@@ -3577,7 +3592,7 @@ dependencies = [
[[package]]
name = "soundcraft-ui-egui"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"craft-ui",
"egui",
@@ -3603,7 +3618,7 @@ dependencies = [
[[package]]
name = "soundcraft-video"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"image",
"proptest",
@@ -3614,7 +3629,7 @@ dependencies = [
[[package]]
name = "soundcraft-vst3-host"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"libloading",
"log",
@@ -3629,12 +3644,16 @@ dependencies = [
[[package]]
name = "soundcraft-web"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"eframe",
"egui",
"js-sys",
"log",
"rfd",
"serde_json",
"soundcraft-engine",
"soundcraft-model",
"soundcraft-playback",
"soundcraft-ui-egui",
"wasm-bindgen",
@@ -4094,6 +4113,12 @@ dependencies = [
"rustc-hash 2.1.3",
]
[[package]]
name = "typed-path"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e"
[[package]]
name = "uds_windows"
version = "1.2.1"
@@ -5176,7 +5201,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
name = "xtask"
version = "0.5.0"
version = "0.6.1"
dependencies = [
"serde_json",
]
@@ -5384,6 +5409,19 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "zip"
version = "9.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49ade69c4434c55dd651d36fe05f38e0578db948b773cbc01c48c4b5e560692"
dependencies = [
"crc32fast",
"indexmap",
"memchr",
"nix",
"typed-path",
]
[[package]]
name = "zlib-rs"
version = "0.6.8"
+4 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.5.0"
version = "0.6.1"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.90"
@@ -38,10 +38,12 @@ proptest = "1"
# audio
symphonia = { version = "0.5", default-features = false, features = ["mp3", "flac", "vorbis", "ogg", "wav", "pcm", "aiff", "isomp4", "aac", "alac", "adpcm", "caf", "mkv"] }
rustfft = "6"
# Session bundles (one file holding a session and its audio, for the browser): stored entries only.
zip = { version = "9", default-features = false }
cpal = "0.16"
# ui
craft-ui = { version = "0.1.0", git = "https://git.marcospaulo.dev.br/masi/craft-libs", rev = "22544b6d619d4f16779b8bdfa3f0b642a86fc065", features = ["serde"] }
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] }
egui = "0.36"
eframe = { version = "0.36", default-features = false }
egui_kittest = "0.36"
+4
View File
@@ -3,6 +3,10 @@ Copyright (c) 2026 ArtCraft Team and the SoundCraft contributors.
Licensed under the MIT License or the Apache License, Version 2.0, at your option
(LICENSE-MIT, LICENSE-APACHE).
The ArtCraft name, wordmark and logos in docs/brand/ are trademarks of the ArtCraft Team. They are
not open source and are not covered by the license above; their terms are in
docs/brand/LICENSE-brand.txt.
Third-party material
This product bundles third-party material under its own licenses, listed below. The per-file
+76 -14
View File
@@ -1,3 +1,12 @@
<p align="center">
<a href="https://getartcraft.com/">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/brand/artcraft-logo-white.svg">
<img alt="ArtCraft" src="docs/brand/artcraft-logo.svg" width="200">
</picture>
</a>
</p>
<h1 align="center">SoundCraft</h1>
<p align="center">
@@ -18,12 +27,27 @@
<img alt="Status: pre-alpha" src="https://img.shields.io/badge/status-pre--alpha-orange?style=flat-square">
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<p align="center">
<a href="https://getartcraft.com/apps/soundcraft"><b>SoundCraft on getartcraft.com</b></a> ·
<a href="https://getartcraft.com/">ArtCraft</a> ·
<a href="https://getartcraft.com/apps">All Crafting Apps</a>
</p>
<p align="center">
<img alt="SoundCraft's Edit window with a nine-track session: drums, bass, pad, keys and lead, a reverb aux and a master fader" src="docs/images/edit.png" width="100%">
<br>
<sub>The Edit window: tracks, waveforms, MIDI, rulers, markers and the transport, playing the built-in demo song (every sound in it is synthesised by SoundCraft itself).</sub>
</p>
> [!NOTE]
> **ArtCraft is a community of artists from all walks of life.** Painters, photographers,
> filmmakers, illustrators, designers, animators, hobbyists, and people who picked up a pencil
> last week. If you make things, you're one of us. **[Come say hi on Discord](https://discord.gg/artcraft).**
<p align="center">
<a href="#what-it-does">What it does</a> ·
<a href="#getting-started">Getting started</a> ·
@@ -83,7 +107,8 @@ where things are.
Scrubber tools; cut, copy, paste, duplicate, repeat, shift, insert silence, separate (at
selection, on grid, at transients), heal, consolidate, strip silence, nudge; fades and
crossfades in five shapes, drawn straight from the clip corners; playlists with comping lanes;
clip gain lines; edit groups; single-key Commands Focus shortcuts; undo for everything.
clip gain lines; edit groups; single-key Commands Focus shortcuts; trackpad scrolling that
follows your fingers and pinch-to-zoom around the pointer; undo for everything.
- **Mixing:** fader and pan with automation, pre/post sends, busses, aux inputs, master faders,
VCA masters, routing folders, solo modes, mute and solo groups, phase invert and trim, clip
effects, and automatic plugin delay compensation.
@@ -92,7 +117,8 @@ where things are.
- **Plugins:** 7-band and 1-band EQ, compressor/limiter, expander/gate, de-esser, maximizer,
channel strip, room and plate reverbs, mod delay, chorus, flanger, phaser, saturator, lo-fi,
rectifier, pitch shifter, time shift, gain, trim, invert, DC removal, signal generator, dither,
a subtractive synth and a drum synth, all original. **CLAP plugins** load too. Any processor
a subtractive synth, a drum synth and a sampler (plays any WAV, AIFF, FLAC, MP3 or OGG across the
keyboard), all original. **CLAP plugins** load too. Any processor
runs offline as AudioSuite, and user presets save per plugin.
- **MIDI:** instrument and MIDI tracks, a piano-roll MIDI editor with a velocity lane, an event
list, a score view, step input, quantize, transpose, real-time properties, Standard MIDI Files,
@@ -222,7 +248,7 @@ estimates are in [`ROADMAP.md`](ROADMAP.md); the full assessment is in
[`docs/target-app-parity.md`](docs/target-app-parity.md), the ranked list of what's missing in
[`docs/gaps.md`](docs/gaps.md), and the menu-by-menu comparison in
[`docs/parity-checklist.md`](docs/parity-checklist.md). Bug reports and wish lists are
very welcome, in the issues.
very welcome, in the issues or on Discord.
## Downloads
@@ -253,6 +279,10 @@ Remove-Item Env:WGPU_BACKEND # restore the default for later
An explicit `gl` override can bring the driver crash back on affected systems. The macOS, Linux
and web backend defaults are unchanged.
DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcompiler.dll` or
`dxil.dll` is needed ("Parent device is lost" at start, #185). `$env:WGPU_DX12_COMPILER = "dxc"`
(or `"auto"`) selects wgpu's DXC handling again for troubleshooting.
### macOS
| Build | File | Notes |
@@ -287,22 +317,44 @@ RISC-V (riscv64): `soundcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (glibc
## The Crafting Apps
SoundCraft is one of the **Crafting Apps**: free, open-source creative tools from the
[storytold](https://github.com/storytold) team, each written from scratch in Rust and each able to
[ArtCraft](https://getartcraft.com/) team, each written from scratch in Rust and each able to
stand on its own.
| | App | What it's for | Code |
|:-:|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) |
| <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) |
| <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) |
| <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) |
| <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) |
| | App | What it's for | Code | Learn more |
|:-:|---|---|---|---|
| <img src="https://raw.githubusercontent.com/storytold/photocraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.photocraft.png" alt="" width="32" height="32"> | **PhotoCraft** | Image editing: layers, masks, type and real PSD files | [GitHub](https://github.com/storytold/photocraft) | [Website](https://getartcraft.com/apps/photocraft) |
| <img src="https://raw.githubusercontent.com/storytold/vectorcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.vectorcraft.png" alt="" width="32" height="32"> | **VectorCraft** | Vector illustration | [GitHub](https://github.com/storytold/vectorcraft) | [Website](https://getartcraft.com/apps/vectorcraft) |
| <img src="https://raw.githubusercontent.com/storytold/filmcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.filmcraft.png" alt="" width="32" height="32"> | **FilmCraft** | Video editing, color and sound | [GitHub](https://github.com/storytold/filmcraft) | [Website](https://getartcraft.com/apps/filmcraft) |
| <img src="https://raw.githubusercontent.com/storytold/lightcraft/main/assets/app-icon/hicolor/64x64/apps/ai.storyteller.lightcraft.png" alt="" width="32" height="32"> | **LightCraft** | Photo library and raw development | [GitHub](https://github.com/storytold/lightcraft) | [Website](https://getartcraft.com/apps/lightcraft) |
| <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) |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
<br>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Join the ArtCraft community on Discord" src="https://img.shields.io/badge/Join%20us%20on%20Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" height="40"></a>
</p>
<h3 align="center">Come make things with us</h3>
<p align="center">
Our Discord is where artists of every kind hang out: people who paint, shoot, draw, cut film,
set type, and people still figuring out what they like to make. Share what you're working on,
ask for help, tell us what's broken, or tell us what you wish these tools could do.
Whatever your medium and however long you've been at it, you're welcome here.
</p>
<p align="center">
<a href="https://discord.gg/artcraft"><b>discord.gg/artcraft</b></a> ·
<a href="https://getartcraft.com/">getartcraft.com</a> ·
<a href="https://getartcraft.com/apps">The Crafting Apps</a> ·
<a href="https://getartcraft.com/apps/soundcraft">SoundCraft</a>
</p>
## License and credits
SoundCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APACHE), at your option.
@@ -315,8 +367,18 @@ Every icon in the interface is drawn in code, the app icon is original, and the
screenshots' audio are synthesised by SoundCraft itself, so there are no samples or artwork from
anyone else in this repository.
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
part of this repository and SoundCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
Forks and modified versions must remove them.
<sub>Avid and Pro Tools are trademarks or registered trademarks of Avid Technology, Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Avid Technology, Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<sub>Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. SoundCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
<p align="center">
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
</p>
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. Drag a panel header to a target edge or another header. Its context menu offers Float panel, Move to group, and Close panel. Existing panel toggles reopen panels at their saved location; Reset Panel Layout restores the app layout. Floating panels remain inside the current app window.
+56 -14
View File
@@ -20,11 +20,12 @@ use soundcraft_engine::Engine;
use std::io::Write;
use std::process::ExitCode;
/// Print without panicking on a closed pipe.
/// Print without panicking on a closed pipe. A write error must not skip later work
/// such as `--bounce` or `--save`, and must not by itself report success.
macro_rules! outln {
($($t:tt)*) => {{
let mut o = std::io::stdout().lock();
if writeln!(o, $($t)*).is_err() { return ExitCode::SUCCESS; }
let _ = writeln!(o, $($t)*);
}};
}
@@ -187,7 +188,7 @@ fn finish(e: &mut Engine, args: &[String]) -> ExitCode {
}
}
if let Some(path) = arg_value(args, "--save") {
match e.execute("session.save", &json!({"path": path})) {
match e.execute("session.save", &json!({"path": path, "replace": true})) {
Ok(v) => outln!("{v}"),
Err(err) => return fail(err),
}
@@ -294,6 +295,39 @@ fn describe(args: &[String]) -> ExitCode {
}
}
/// Control-channel methods. Every other dotted id is an engine command.
fn is_control_method(method: &str) -> bool {
matches!(
method,
"engine.execute"
| "command"
| "engine.commands"
| "engine.parity"
| "session.inspect"
| "document.inspect"
| "ui.inspect"
| "ui.menu.list"
| "ui.menu.invoke"
| "ui.set"
| "ui.move"
| "ui.click"
| "ui.drag"
| "ui.key"
| "ui.text"
| "ui.screenshot"
| "app.quit"
)
}
/// A command id is shorthand for `engine.execute`. Documented control methods stay methods.
fn app_request(method: &str, params: Value) -> (String, Value) {
if is_control_method(method) || !method.contains('.') {
(method.to_string(), params)
} else {
("engine.execute".to_string(), json!({"command": method, "params": params}))
}
}
fn app(args: &[String]) -> ExitCode {
let port = arg_value(args, "--port").or_else(|| std::env::var("SOUNDCRAFT_CONTROL_PORT").ok()).unwrap_or_else(|| "7979".into());
let positional: Vec<&String> = args
@@ -311,17 +345,8 @@ fn app(args: &[String]) -> ExitCode {
None => json!({}),
};
let mut r = Remote::new(&port);
// A bare command id is shorthand for engine.execute.
let (m, p) = if method.contains('.')
&& !method.starts_with("ui.")
&& !method.starts_with("engine.")
&& !method.starts_with("session.")
&& !method.starts_with("app.")
{
("engine.execute".to_string(), json!({"command": method, "params": params}))
} else {
(method.to_string(), params)
};
// A bare command id is shorthand for engine.execute. Control-channel methods stay methods.
let (m, p) = app_request(method, params);
match r.call(&m, p) {
Ok(v) => {
outln!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
@@ -380,6 +405,23 @@ fn parity(args: &[String]) -> ExitCode {
mod tests {
use super::*;
#[test]
fn command_ids_are_execute_shorthand_and_control_methods_stay_methods() {
let (m, p) = app_request("engine.history", json!({}));
assert_eq!(m, "engine.execute");
assert_eq!(p["command"], "engine.history");
let (m, p) = app_request("ui.theme", json!({"mode": "light"}));
assert_eq!(m, "engine.execute");
assert_eq!(p["params"]["mode"], "light");
let (m, _) = app_request("session.new", json!({"name": "Session A"}));
assert_eq!(m, "engine.execute");
let (m, _) = app_request("track.new", json!({}));
assert_eq!(m, "engine.execute");
for method in ["session.inspect", "engine.commands", "engine.parity", "ui.screenshot", "app.quit"] {
assert_eq!(app_request(method, json!({})).0, method);
}
}
#[test]
fn missing_media_gets_one_warning_each() {
assert!(missing_warnings(&[]).is_empty());
+124
View File
@@ -0,0 +1,124 @@
//! A closed stdout must not abort `run` or `script` before `--bounce` and `--save`.
use std::process::{Command, Stdio};
fn bin() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_soundcraft-cli"));
cmd.env("SOUNDCRAFT_NO_PREFS", "1");
cmd
}
/// Drop the read end so the child's stdout writes fail with `EPIPE`.
fn closed_stdout() -> std::process::Stdio {
let (reader, writer) = std::io::pipe().expect("pipe");
drop(reader);
Stdio::from(writer)
}
fn temp_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("sc-cli-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn run_with_closed_stdout_still_bounces_and_saves() {
let dir = temp_dir("run");
let wav = dir.join("mix.wav");
let session = dir.join("Synthetic.scraft");
let output = bin()
.arg("run")
.arg("--cmd")
.arg(r#"track.new={"name":"Synthetic","count":1,"format":"stereo"}"#)
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file(), "bounce was skipped");
assert!(session.is_file(), "save was skipped");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn script_with_closed_stdout_still_bounces_and_saves() {
let dir = temp_dir("script");
let script = dir.join("edit.scraft.txt");
std::fs::write(&script, "track.new {\"name\":\"Synthetic\",\"count\":1,\"format\":\"stereo\"}\n").unwrap();
let wav = dir.join("mix.wav");
let session = dir.join("Synthetic.scraft");
let output = bin()
.arg("script")
.arg(&script)
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file());
assert!(session.is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn bounce_then_save_with_closed_stdout_writes_the_wav() {
let dir = temp_dir("bounce");
let wav = dir.join("mix.wav");
let session = dir.join("Empty.scraft");
let output = bin()
.arg("run")
.arg("--bounce")
.arg(&wav)
.arg("--save")
.arg(&session)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(closed_stdout())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
assert!(wav.is_file());
assert!(session.is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn invalid_bounce_destination_still_fails() {
let dir = temp_dir("bad-bounce");
let missing = dir.join("no-such-dir").join("mix.wav");
let output = bin()
.arg("run")
.arg("--bounce")
.arg(&missing)
.arg("--start")
.arg("0")
.arg("--end")
.arg("0.01")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.unwrap();
assert!(!output.status.success(), "a bounce that cannot be written must fail");
assert!(!missing.exists());
let _ = std::fs::remove_dir_all(&dir);
}
+5 -1
View File
@@ -17,10 +17,14 @@ path = "src/main.rs"
soundcraft-engine = { workspace = true }
soundcraft-ui-egui = { workspace = true }
soundcraft-playback = { workspace = true }
soundcraft-model = { workspace = true }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
rfd = { workspace = true }
js-sys = "0.3"
# `wgpu` pulls egui-wgpu's defaults: WebGPU plus the WebGL2 fallback.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlCanvasElement", "Location"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "CssStyleDeclaration"] }
+4 -1
View File
@@ -7,7 +7,10 @@
//!
//! Differences from the desktop app:
//! - no TCP control channel (browsers can't listen on sockets);
//! - no native file dialogs: the demo session is loaded on start;
//! - no file system: File > Open Session…, Import > Audio…/MIDI… and files dropped on the page are
//! read as bytes through the browser's file picker; a session is saved as ONE bundle file
//! (`<name>.scraftz`: the `.scraft` and its `Audio Files/`), and every save or export arrives as a
//! download (the demo session is still loaded on start);
//! - browsers only start audio after a user gesture, so the first click may be needed before
//! playback is heard.
//!
+175 -6
View File
@@ -1,7 +1,11 @@
//! The browser shell: the eframe web runner around [`SoundApp`].
use soundcraft_engine::Engine;
use serde_json::json;
use soundcraft_engine::{Engine, io};
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
use wasm_bindgen::JsCast as _;
@@ -10,6 +14,7 @@ const LOADING_ID: &str = "soundcraft_loading";
pub fn start() {
eframe::WebLogger::init(log::LevelFilter::Info).ok();
io::set_deliver(download);
wasm_bindgen_futures::spawn_local(async {
let Some(document) = web_sys::window().and_then(|w| w.document()) else {
log::error!("no document");
@@ -36,7 +41,7 @@ pub fn start() {
let engine = if query().contains("empty") { Engine::default() } else { soundcraft_engine::demo::demo_engine() };
let player = soundcraft_playback::Player::new(Arc::new(engine.session().clone()));
let app = SoundApp::new(engine, Some(player), Services::default());
Ok(Box::new(WebShell(app)))
Ok(Box::new(WebShell { app, inbox: Rc::default() }))
}),
)
.await;
@@ -53,18 +58,182 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
struct WebShell(SoundApp);
/// What a file read from the browser is for.
#[derive(Debug, Clone, Copy)]
enum Purpose {
Session,
Audio,
Midi,
}
const SESSION_EXTS: &[&str] = &[io::BUNDLE_EXTENSION, soundcraft_model::SESSION_EXTENSION];
const AUDIO_EXTS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "aifc", "flac", "mp3", "ogg", "oga", "m4a", "aac", "mp4", "caf", "mkv", "webm"];
const MIDI_EXTS: &[&str] = &["mid", "midi", "smf"];
impl Purpose {
/// A dropped file goes where its extension says: sessions open, MIDI and audio import.
fn of(name: &str) -> Self {
let ext = name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).unwrap_or_default();
if SESSION_EXTS.contains(&ext.as_str()) {
Self::Session
} else if MIDI_EXTS.contains(&ext.as_str()) {
Self::Midi
} else {
Self::Audio
}
}
/// The command whose path prompt this replaces in the browser.
fn for_command(cmd: &str) -> Option<Self> {
match cmd {
"session.open" => Some(Self::Session),
"file.import_audio" => Some(Self::Audio),
"file.import_midi" => Some(Self::Midi),
_ => None,
}
}
fn filter(self) -> (&'static str, &'static [&'static str]) {
match self {
Self::Session => ("SoundCraft Session", SESSION_EXTS),
Self::Audio => ("Audio", AUDIO_EXTS),
Self::Midi => ("MIDI", MIDI_EXTS),
}
}
}
/// Files picked or dropped, read asynchronously (browsers can only read files that way) and
/// handled on the next frame. The browser has no paths: files travel as bytes.
type Inbox = Rc<RefCell<Vec<(Purpose, String, Vec<u8>)>>>;
struct WebShell {
app: SoundApp,
inbox: Inbox,
}
impl WebShell {
/// File > Open Session… / Import > Audio… / MIDI… would show a text box for a path, which the
/// browser can't read (#63, #64). Show the browser's file picker instead.
fn take_path_prompt(&mut self, ctx: &egui::Context) {
let purpose = match &self.app.dialogs.open {
Some(Dialog::PathPrompt { cmd, .. }) => Purpose::for_command(cmd),
_ => None,
};
let Some(purpose) = purpose else { return };
self.app.dialogs.open = None;
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let (label, exts) = purpose.filter();
let Some(file) = rfd::AsyncFileDialog::new().add_filter(label, exts).pick_file().await else { return };
let bytes = file.read().await;
inbox.borrow_mut().push((purpose, file.file_name(), bytes));
ctx.request_repaint();
});
}
fn take_drops(&mut self, ctx: &egui::Context) {
// In the browser a dropped file's `path()` is only its name: read its bytes instead.
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let name = f.path().file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "Audio.wav".into());
let (inbox, ctx) = (self.inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
match f.bytes_async().await {
Ok(bytes) => {
inbox.borrow_mut().push((Purpose::of(&name), name, bytes));
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
}
}
fn handle(&mut self, purpose: Purpose, name: &str, bytes: &[u8]) {
let e = &mut self.app.engine;
let at = e.session().edit.selection.start.max(0);
let result = match purpose {
Purpose::Session => {
let _ = self.app.run("transport.stop", json!({}));
io::open_session_bytes(&mut self.app.engine, name, bytes).map(|missing| {
if missing.is_empty() { format!("Opened {name}") } else { format!("Opened {name} ({} missing media)", missing.len()) }
})
}
// Imports are edits: one undo step each, like the import commands.
Purpose::Audio => {
let before = e.session_arc();
io::import_audio_bytes(e, name, bytes, None, None, at).map(|_| {
e.push_undo("Import Audio", before);
format!("Imported {name}")
})
}
Purpose::Midi => {
let before = e.session_arc();
io::import_midi_bytes(e, bytes, at, true).map(|_| {
e.push_undo("Import MIDI", before);
format!("Imported {name}")
})
}
};
self.app.ui.status = result.unwrap_or_else(|err| err.to_string());
}
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.0.logic(ctx);
self.take_drops(ctx);
self.take_path_prompt(ctx);
let pending = std::mem::take(&mut *self.inbox.borrow_mut());
for (purpose, name, bytes) in pending {
self.handle(purpose, &name, &bytes);
}
self.app.logic(ctx);
// A prompt opened during this frame becomes a picker on the next one.
self.take_path_prompt(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
self.0.ui(ui);
self.app.ui(ui);
}
}
/// Hand a saved or exported file to the person as a download named `name` (the engine's delivery
/// hook on the web).
fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
let js = |e: wasm_bindgen::JsValue| format!("download failed: {e:?}");
let window = web_sys::window().ok_or("no window")?;
let document = window.document().ok_or("no document")?;
let parts = js_sys::Array::of1(&js_sys::Uint8Array::from(bytes));
let opts = web_sys::BlobPropertyBag::new();
opts.set_type(match name.rsplit_once('.').map(|(_, x)| x.to_ascii_lowercase()).as_deref() {
Some("scraftz") => "application/zip",
Some("wav") => "audio/wav",
Some("aif" | "aiff") => "audio/aiff",
Some("flac") => "audio/flac",
Some("mid" | "midi") => "audio/midi",
Some("svg") => "image/svg+xml",
Some("txt") => "text/plain",
Some("json" | "scraft") => "application/json",
Some("musicxml") => "application/vnd.recordare.musicxml+xml",
_ => "application/octet-stream",
});
let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(js)?;
let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(js)?;
let a: web_sys::HtmlAnchorElement = document.create_element("a").map_err(js)?.dyn_into().map_err(|_| "not an anchor")?;
a.set_href(&url);
a.set_download(name);
a.style().set_property("display", "none").map_err(js)?;
document.body().ok_or("no body")?.append_child(&a).map_err(js)?;
a.click();
a.remove();
// Revoke after the click was dispatched; the download keeps its own reference.
let revoke = wasm_bindgen::closure::Closure::once_into_js(move || {
let _ = web_sys::Url::revoke_object_url(&url);
});
window.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000).map_err(js)?;
Ok(())
}
+2
View File
@@ -28,3 +28,5 @@ winresource = "0.1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = { version = "0.19.2", default-features = false }
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["NSApplication", "NSResponder"] }
+51 -5
View File
@@ -1,10 +1,14 @@
//! Windows graphics instance configuration, applied before wgpu can initialize any driver.
use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends};
use eframe::{
NativeOptions,
egui_wgpu::WgpuSetup,
wgpu::{Backends, Dx12Compiler},
};
/// Restrict the wgpu instance to DirectX 12 unless `backend_override` (normally
/// `Backends::from_env()`, i.e. `WGPU_BACKEND`) names other backends.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>) {
/// Default to DirectX 12 with FXC before creating the wgpu instance. Explicit
/// `WGPU_BACKEND` and `WGPU_DX12_COMPILER` choices, read by the caller, take precedence.
pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>, compiler_override: Option<Dx12Compiler>) {
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// eframe's default includes GL. Creating that backend can crash inside AMD's
// atio6axx.dll before adapter selection or any Rust error handling runs, so the
@@ -12,6 +16,16 @@ pub fn configure(options: &mut NativeOptions, backend_override: Option<Backends>
// out of the instance avoids it. This matches the verified WGPU_BACKEND=dx12
// workaround without mutating process environment variables.
create.instance_descriptor.backends = backend_override.unwrap_or(Backends::DX12);
// Auto loads whichever dxcompiler.dll the DLL search path finds; SoundCraft ships
// none, and without a matching dxil.dll the unsigned-DXIL warning fails wgpu's
// indirect-validation pipeline and loses the device before the first frame
// (issue #185). FXC uses the compiler that is part of Windows.
create.instance_descriptor.backend_options.dx12.shader_compiler = compiler_override.unwrap_or(Dx12Compiler::Fxc);
log::info!(
"Windows graphics: backends={:?}, DX12 compiler={:?}",
create.instance_descriptor.backends,
create.instance_descriptor.backend_options.dx12.shader_compiler
);
}
}
@@ -21,7 +35,7 @@ mod tests {
fn configured_backends(backend_override: Option<Backends>) -> Backends {
let mut options = NativeOptions::default();
configure(&mut options, backend_override);
configure(&mut options, backend_override, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
@@ -38,6 +52,38 @@ mod tests {
assert_eq!(configured_backends(None), Backends::DX12);
}
#[test]
fn windows_default_uses_fxc_instead_of_searching_for_dxc() {
let mut options = NativeOptions::default();
if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup {
// Auto can find an unrelated DXC without its matching DXIL runtime (issue #185).
create.instance_descriptor.backend_options.dx12.shader_compiler = Dx12Compiler::Auto;
}
configure(&mut options, None, None);
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
assert!(matches!(create.instance_descriptor.backend_options.dx12.shader_compiler, Dx12Compiler::Fxc));
}
#[test]
fn explicit_compiler_override_is_preserved() {
for compiler in [Dx12Compiler::Fxc, Dx12Compiler::Auto, Dx12Compiler::DynamicDxc { dxc_path: "custom/dxcompiler.dll".into() }] {
let mut options = NativeOptions::default();
configure(&mut options, None, Some(compiler.clone()));
let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else {
panic!("default native options must create a wgpu instance");
};
match (compiler, create.instance_descriptor.backend_options.dx12.shader_compiler) {
(Dx12Compiler::Fxc, Dx12Compiler::Fxc) | (Dx12Compiler::Auto, Dx12Compiler::Auto) => {}
(Dx12Compiler::DynamicDxc { dxc_path: expected }, Dx12Compiler::DynamicDxc { dxc_path: actual }) => {
assert_eq!(actual, expected);
}
(expected, actual) => panic!("compiler override changed from {expected:?} to {actual:?}"),
}
}
}
#[test]
fn explicit_backend_override_is_preserved() {
for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] {
+54 -9
View File
@@ -20,11 +20,20 @@ mod logging;
mod native_menu;
use soundcraft_engine::Engine;
use soundcraft_ui_egui::{Services, SoundApp, UiState};
use soundcraft_ui_egui::{Services, SoundApp, UiState, dialogs};
use std::sync::Arc;
struct App(SoundApp, Option<&'static logging::AppLogger>, #[cfg(target_os = "macos")] Option<native_menu::NativeMenu>);
/// Sends the title only when it changes, remembered here as X11 and Wayland can't read it back.
fn set_title(ctx: &egui::Context, title: String) {
let id = egui::Id::new("window_title");
if ctx.data(|d| d.get_temp::<String>(id)).as_deref() != Some(title.as_str()) {
ctx.data_mut(|d| d.insert_temp(id, title.clone()));
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
}
}
impl eframe::App for App {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
// What the realtime audio thread logged is written here, off that thread.
@@ -48,7 +57,16 @@ impl eframe::App for App {
}
if self.0.quit_requested {
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
} else if ctx.input(|i| i.viewport().close_requested()) && dialogs::ask_to_save(&mut self.0, "app.quit", &serde_json::json!({})) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
}
soundcraft_ui_egui::fit_window_to_screen(ctx);
let (inner, maximized) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().maximized.unwrap_or(false)));
self.0.ui.window_maximized = maximized;
if let Some(r) = inner.filter(|_| !maximized) {
self.0.ui.window_size = (r.size() * ctx.zoom_factor()).round().into();
}
self.0.ui.ui_scale = self.0.extra.ui_scale;
// With the system menu bar there is no egui bar showing the window and dirty state, so the
// title carries them; elsewhere the egui menu bar already does.
let title = if self.0.native_menu_bar {
@@ -61,7 +79,7 @@ impl eframe::App for App {
} else {
format!("{} — SoundCraft", self.0.engine.session().name)
};
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
set_title(ctx, title);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
self.0.raw_input_hook(raw);
@@ -84,7 +102,7 @@ impl eframe::App for App {
fn open_path(app: &mut SoundApp, p: &str) {
let lower = p.to_ascii_lowercase();
let r = if lower.ends_with(".scraft") {
app.run("session.open", serde_json::json!({"path": p}))
dialogs::run_or_ask(app, "session.open", serde_json::json!({"path": p}))
} else if lower.ends_with(".mid") || lower.ends_with(".midi") || lower.ends_with(".smf") {
app.run("file.import_midi", serde_json::json!({"path": p}))
} else {
@@ -157,6 +175,9 @@ fn services() -> Services {
}
d.save_file().map(|p| p.to_string_lossy().into_owned())
})),
pick_folder: Some(Box::new(|start: &str| {
rfd::FileDialog::new().set_directory(start).pick_folder().map(|p| p.to_string_lossy().into_owned())
})),
}
}
@@ -213,10 +234,12 @@ fn main() -> eframe::Result {
}
let engine = if demo { soundcraft_engine::demo::demo_engine() } else { Engine::default() };
let prefs = load_prefs().unwrap_or_default();
let mut options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_title("SoundCraft")
.with_inner_size([1600.0, 1000.0])
.with_inner_size(sane_window_size(prefs.window_size))
.with_maximized(prefs.window_maximized)
.with_min_inner_size([960.0, 600.0])
.with_drag_and_drop(true)
.with_app_id("ai.storyteller.soundcraft"),
@@ -225,9 +248,9 @@ fn main() -> eframe::Result {
if let Some(icon) = app_icon() {
options.viewport = options.viewport.with_icon(icon);
}
// Before eframe creates the wgpu instance: default Windows to DirectX 12 only (see graphics.rs).
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
#[cfg(target_os = "windows")]
graphics::configure(&mut options, eframe::wgpu::Backends::from_env());
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
eframe::run_native(
"SoundCraft",
options,
@@ -236,9 +259,8 @@ fn main() -> eframe::Result {
let mut app = SoundApp::new(engine, player, services());
app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave")));
app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets")));
if let Some(ui) = load_prefs() {
app.ui = ui;
}
app.extra.ui_scale = prefs.ui_scale;
app.ui = prefs;
if let Some(port) = control_port {
let rx = control_server::start(port, cc.egui_ctx.clone());
app = app.with_control(rx);
@@ -267,3 +289,26 @@ fn main() -> eframe::Result {
}),
)
}
/// A saved window size, or the default when the prefs file holds nonsense (NaN, negative, tiny).
fn sane_window_size([w, h]: [f32; 2]) -> [f32; 2] {
if w.is_finite() && h.is_finite() && w >= 960.0 && h >= 600.0 { [w.min(16_384.0), h.min(16_384.0)] } else { [1600.0, 1000.0] }
}
#[cfg(test)]
mod tests {
use super::set_title;
#[test]
fn the_title_is_sent_only_when_it_changes() {
let ctx = egui::Context::default();
let titles = |title: &str| {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| set_title(ui.ctx(), title.to_string()));
out.textures_delta.clear();
out.viewport_output.values().flat_map(|v| &v.commands).filter(|c| matches!(c, egui::ViewportCommand::Title(_))).count()
};
assert_eq!(titles("Song — SoundCraft"), 1);
assert_eq!(titles("Song — SoundCraft"), 0);
assert_eq!(titles("Song * — SoundCraft"), 1);
}
}
+17 -4
View File
@@ -1,5 +1,7 @@
//! macOS system menu bar. All actions use the same menu dispatch as the egui host.
use muda::{CheckMenuItem, Menu, MenuEvent, PredefinedMenuItem, Submenu};
use muda::{CheckMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use objc2::MainThreadMarker;
use objc2_app_kit::NSApplication;
use soundcraft_ui_egui::{SoundApp, menus};
use std::sync::mpsc::{Receiver, channel};
@@ -36,8 +38,9 @@ impl NativeMenu {
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::services(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
app_menu.append(&PredefinedMenuItem::hide(Some("Hide SoundCraft")))?;
app_menu.append(&PredefinedMenuItem::hide_others(None))?;
// muda's own Hide items take ⌘H and ⌥⌘H, which are Heal Separation and Copy to Send.
app_menu.append(&MenuItem::with_id("hide", "Hide SoundCraft", true, None))?;
app_menu.append(&MenuItem::with_id("hide_others", "Hide Others", true, None))?;
app_menu.append(&PredefinedMenuItem::show_all(None))?;
app_menu.append(&PredefinedMenuItem::separator())?;
native.add_item(app, &app_menu, "Quit SoundCraft", "", Some("app.quit".into()))?;
@@ -82,7 +85,12 @@ impl NativeMenu {
while let Ok(event) = self.events.try_recv() {
// A click changes state (and Cocoa flips the item's check mark): sync on the next pass.
self.last_sync = None;
if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
if (event.id == "hide" || event.id == "hide_others")
&& let Some(mtm) = MainThreadMarker::new()
{
let ns = NSApplication::sharedApplication(mtm);
if event.id == "hide" { ns.hide(None) } else { ns.hideOtherApplications(None) }
} else if let Some(entry) = self.entries.iter().find(|e| e.item.id() == &event.id)
&& let Some(command) = &entry.command
&& menus::item_state(app, &entry.path, Some(command)).0
{
@@ -105,6 +113,11 @@ impl NativeMenu {
self.last_sync = Some(now);
for entry in &mut self.entries {
let (enabled, checked) = menus::item_state(app, &entry.path, entry.command.as_deref());
if let Some(label) = menus::live_label(app, entry.command.as_deref())
&& entry.item.text() != label
{
entry.item.set_text(label);
}
if enabled != entry.enabled {
entry.item.set_enabled(enabled);
entry.enabled = enabled;
+1 -1
View File
@@ -5,7 +5,7 @@ owl-template framing (craftrules `standards/icon-design.md`): a full-bleed colou
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.
This is a **placeholder** until the owner draws SoundCraft's icon like the other apps'
This is a **placeholder** until the owner draws SoundCraft's icon in ArtCraft like the other apps'
icons. Replace `icon.svg` and run `packaging/icons.sh`; nothing else needs to change.
## Palette
+5
View File
@@ -35,3 +35,8 @@ sync_github_name = false
[people.salignatmoandal]
real_name = "Mawen Salignat-Moandal"
sync_github_name = true
[people.vbasky]
real_name = "Vikram Bhaskaran"
display_name = "Vikram Bhaskaran"
sync_github_name = false
+11
View File
@@ -8,6 +8,7 @@
//! MP4, CAF, Matroska, ADPCM/A-law/µ-law WAV, ...) through `symphonia`.
//! * [`encode`] writes WAV/BWF (RF64 above 4 GiB), AIFF / AIFF-C `fl32`, and FLAC (built-in
//! encoder), with optional TPDF dither.
//! * [`wav_float_header`] heads a float WAV written while it grows (recording).
//! * [`peaks`] builds multi-resolution min/max overviews for waveform drawing.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
@@ -289,3 +290,13 @@ pub fn encode_with_channel_mask(buf: &AudioBuffer, opts: &EncodeOptions, mask: u
pub fn wav_channel_mask(bytes: &[u8]) -> Option<u32> {
wav::read_channel_mask(bytes)
}
/// The header of a 32-bit float WAV holding `frames` frames (little-endian samples follow it). Its
/// length doesn't depend on `frames`, so a recorder can rewrite it in place as the file grows; it
/// switches to RF64 above 4 GiB.
pub fn wav_float_header(channels: usize, sample_rate: u32, frames: u64) -> Result<Vec<u8>> {
if channels == 0 || sample_rate == 0 {
return Err(AudioError::Encode("a WAV needs channels and a sample rate".into()));
}
wav::header(channels, sample_rate, (32, true), None, None, frames, false)
}
+69 -54
View File
@@ -276,60 +276,8 @@ pub(crate) fn encode(
BitDepth::Float32 => (32, true),
};
let bytes = usize::from(bits / 8);
let block = bytes.checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = block.checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.to_le_bytes());
fmt.extend_from_slice(&buf.sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = 4 + (8 + rf64_body_len) + meta.len() + 8 + data_len + (data_len & 1);
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&(total as u64).to_le_bytes());
ds64.extend_from_slice(&(data_len as u64).to_le_bytes());
ds64.extend_from_slice(&(frames as u64).to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
let head = header(channels, buf.sample_rate, (bits, is_float), bwf, mask, frames as u64, force_rf64)?;
let data_len = (bytes * channels).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let mut out = Vec::new();
out.try_reserve_exact(head.len().saturating_add(data_len).saturating_add(1))
@@ -352,3 +300,70 @@ pub(crate) fn encode(
}
Ok(out)
}
/// Everything before the sample data of a WAV holding `frames` frames.
pub(crate) fn header(
channels: usize,
sample_rate: u32,
(bits, is_float): (u16, bool),
bwf: Option<&BwfInfo>,
mask: Option<u32>,
frames: u64,
force_rf64: bool,
) -> Result<Vec<u8>> {
let block = usize::from(bits / 8).checked_mul(channels).ok_or_else(|| AudioError::TooLarge("block size overflow".into()))?;
let block_align = u16::try_from(block).map_err(|_| AudioError::Encode("too many channels for WAV".into()))?;
let data_len = u64::from(block_align).checked_mul(frames).ok_or_else(|| AudioError::TooLarge("data size overflow".into()))?;
let extensible = channels > 2 || bits > 16 || mask.is_some_and(|m| m != channel_mask(channels));
let fmt_tag = if is_float { WAVE_FORMAT_IEEE_FLOAT } else { WAVE_FORMAT_PCM };
let mut fmt = Vec::with_capacity(40);
fmt.extend_from_slice(&(if extensible { WAVE_FORMAT_EXTENSIBLE } else { fmt_tag }).to_le_bytes());
fmt.extend_from_slice(&(channels as u16).to_le_bytes());
fmt.extend_from_slice(&sample_rate.to_le_bytes());
fmt.extend_from_slice(&sample_rate.saturating_mul(u32::from(block_align)).to_le_bytes());
fmt.extend_from_slice(&block_align.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
if extensible {
fmt.extend_from_slice(&22u16.to_le_bytes());
fmt.extend_from_slice(&bits.to_le_bytes());
fmt.extend_from_slice(&mask.unwrap_or_else(|| channel_mask(channels)).to_le_bytes());
fmt.extend_from_slice(&fmt_tag.to_le_bytes());
fmt.extend_from_slice(&GUID_TAIL);
}
let mut head = Vec::with_capacity(1024);
let rf64_body_len = 28usize;
// Reserve room for ds64 (as JUNK when not needed, so a later writer could upgrade in place).
let mut meta = Vec::new();
if let Some(bwf) = bwf {
push_chunk(&mut meta, b"bext", &bext_chunk(bwf));
}
push_chunk(&mut meta, b"fmt ", &fmt);
if is_float {
push_chunk(&mut meta, b"fact", &u32::try_from(frames).unwrap_or(u32::MAX).to_le_bytes());
}
let total = (4 + (8 + rf64_body_len) + meta.len() + 8) as u64 + data_len + (data_len & 1);
let rf64 = force_rf64 || u32::try_from(total).is_err();
if rf64 {
head.extend_from_slice(b"RF64");
head.extend_from_slice(&u32::MAX.to_le_bytes());
head.extend_from_slice(b"WAVE");
let mut ds64 = Vec::with_capacity(rf64_body_len);
ds64.extend_from_slice(&total.to_le_bytes());
ds64.extend_from_slice(&data_len.to_le_bytes());
ds64.extend_from_slice(&frames.to_le_bytes());
ds64.extend_from_slice(&0u32.to_le_bytes());
push_chunk(&mut head, b"ds64", &ds64);
} else {
head.extend_from_slice(b"RIFF");
head.extend_from_slice(&(total as u32).to_le_bytes());
head.extend_from_slice(b"WAVE");
push_chunk(&mut head, b"JUNK", &[0u8; 28]);
}
head.extend_from_slice(&meta);
head.extend_from_slice(b"data");
head.extend_from_slice(&(if rf64 { u32::MAX } else { data_len as u32 }).to_le_bytes());
Ok(head)
}
+17 -1
View File
@@ -1,5 +1,5 @@
use soundcraft_audio_io::{
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe,
AudioBuffer, BitDepth, BwfInfo, EncodeOptions, FileFormat, SampleFormat, decode, detect_format, encode, encode_wav_rf64, probe, wav_float_header,
};
fn sine(sr: u32, channels: usize, frames: usize) -> AudioBuffer {
@@ -268,3 +268,19 @@ fn multichannel_wav_speaker_masks() {
let bytes = soundcraft_audio_io::encode_with_channel_mask(&buf(2), &o16, 0x3).unwrap();
assert_eq!(soundcraft_audio_io::wav_channel_mask(&bytes), None);
}
#[test]
fn float_wav_header_is_rewritten_in_place_as_the_file_grows() {
let take = sine(48_000, 2, 1_000);
let mut file = wav_float_header(2, 48_000, 0).unwrap();
let len = file.len();
file.extend(take.interleaved().iter().flat_map(|s| s.to_le_bytes()));
assert_eq!(decode(&file, None).unwrap().1.frames(), 1_000);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 600).unwrap());
assert_eq!(decode(&file, None).unwrap().1.frames(), 600);
file[..len].copy_from_slice(&wav_float_header(2, 48_000, 1_000).unwrap());
assert_eq!(file, encode(&take, &EncodeOptions { bit_depth: BitDepth::Float32, ..EncodeOptions::default() }).unwrap());
let big = wav_float_header(2, 48_000, 600_000_000).unwrap();
assert_eq!((&big[..4], big.len()), (&b"RF64"[..], len));
assert!(wav_float_header(0, 48_000, 0).is_err());
}
+20 -4
View File
@@ -54,7 +54,12 @@ pub fn tool_definitions() -> Vec<Value> {
&[],
),
tool("open_session", "Open a .scraft session file.", json!({"path": {"type": "string"}}), &["path"]),
tool("save_session", "Save the session (path required the first time).", json!({"path": {"type": "string"}}), &[]),
tool(
"save_session",
"Save the session (path required the first time; replace: true to save over another existing file).",
json!({"path": {"type": "string"}, "replace": {"type": "boolean"}}),
&[],
),
tool(
"import_audio",
"Import an audio file (WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF) onto a new or existing track.",
@@ -127,11 +132,20 @@ impl<B: Backend> Server<B> {
}
"batch" => {
let keep = args.get("keep_going").and_then(Value::as_bool).unwrap_or(false);
let calls = args.get("calls").and_then(Value::as_array).cloned().unwrap_or_default();
let Some(calls) = args.get("calls").and_then(Value::as_array) else {
return error_result("`calls` must be an array");
};
let mut out = Vec::new();
let mut failed = false;
for c in calls.iter().take(1000) {
let cmd = c.get("command").and_then(Value::as_str).unwrap_or("");
let Some(cmd) = c.get("command").and_then(Value::as_str).map(str::trim).filter(|s| !s.is_empty()) else {
out.push(json!({"command": "", "ok": false, "error": "`command` required"}));
failed = true;
if !keep {
break;
}
continue;
};
let r = self.exec(cmd, c.get("params").cloned().unwrap_or(json!({})));
match r {
Ok(v) => out.push(json!({"command": cmd, "ok": true, "result": v})),
@@ -144,7 +158,9 @@ impl<B: Backend> Server<B> {
}
}
}
if failed && !keep { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
// Any failure sets isError so agents that only check that flag notice;
// keep_going still runs the rest and the payload lists every call.
if failed { Err(serde_json::to_string(&out).unwrap_or_default()) } else { Ok(json!(out)) }
}
"inspect_session" => self.backend.call("session.inspect", json!({"detail": a("detail")})),
"new_session" => self.exec("session.new", args.clone()),
+36
View File
@@ -135,3 +135,39 @@ fn errors_are_reported_not_panics() {
let r = rpc(&mut s, 2, "tools/call", json!({"name": "screenshot", "arguments": {}}));
assert_eq!(r["result"]["isError"], true);
}
#[test]
fn batch_keep_going_still_sets_is_error_on_partial_failure() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(
&mut s,
1,
"tools/call",
json!({"name": "batch", "arguments": {"keep_going": true, "calls": [
{"command": "track.new", "params": {"count": 1, "name": "Kept"}},
{"command": "mix.volume", "params": {"track": "Nope", "db": -6}},
{"command": "track.new", "params": {"count": 1, "name": "After"}},
]}}),
);
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
let results: Value = serde_json::from_str(text).unwrap();
let arr = results.as_array().unwrap();
assert_eq!(arr.len(), 3, "{results}");
assert_eq!(arr[0]["ok"], true);
assert_eq!(arr[1]["ok"], false);
assert_eq!(arr[2]["ok"], true);
let sess = session(&mut s);
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "Kept"));
assert!(sess["tracks"].as_array().unwrap().iter().any(|t| t["name"] == "After"));
}
#[test]
fn batch_rejects_non_array_calls() {
let mut s = Server::new(Headless::new(Engine::default()));
let r = rpc(&mut s, 1, "tools/call", json!({"name": "batch", "arguments": {"calls": {"command": "track.new"}}}));
assert_eq!(r["result"]["isError"], true, "{r}");
let text = r["result"]["content"][0]["text"].as_str().unwrap();
assert!(text.contains("`calls` must be an array"), "{text}");
assert_eq!(session(&mut s)["tracks"].as_array().unwrap().len(), 0);
}
+61 -1
View File
@@ -1,7 +1,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
//! SoundCraft DSP.
//!
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and two
//! Realtime plugins (EQ, dynamics, reverb, delay, modulation, harmonic, pitch, utilities and three
//! instruments), metering (peak, RMS, ITU-R BS.1770 loudness), spectrum analysis and offline
//! AudioSuite-style whole-buffer processing.
//!
@@ -28,6 +28,8 @@ pub use offline::FadeShape;
pub use osc::Waveform;
pub use plugins::eq::{eq1_response, eq7_response};
use std::sync::Arc;
/// Plugin category, used to group the insert menu.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub enum Category {
@@ -197,6 +199,13 @@ pub trait Plugin: Send {
fn note_on(&mut self, _offset: usize, _note: u8, _velocity: u8) {}
fn note_off(&mut self, _offset: usize, _note: u8) {}
fn all_notes_off(&mut self) {}
/// Sample-playing instruments (the Sampler): the recorded audio to play, `None` to clear it.
/// `false` when the plugin does not play samples. Does not allocate; sounding voices stop.
/// The previous sample is dropped here, so hosts keep their own reference to it (the
/// session's source pool does) to keep deallocation off the audio thread.
fn set_sample(&mut self, _sample: Option<Arc<dyn SampleSource>>) -> bool {
false
}
/// The plugin's complete internal state as an opaque blob (third-party plugins), for saving
/// in the session. `None` when the plugin has no state beyond its parameters (built-ins) or
/// the plugin refused. May allocate: never call it in steady-state audio processing.
@@ -240,6 +249,40 @@ pub trait PluginEditor: Send {
fn set_param(&mut self, _id: &str, _value: f32) {}
}
/// Recorded audio a sample-playing instrument reads: planar `f32` at its own sample rate (the
/// instrument converts rates as it plays). Implementations must be cheap to read from the audio
/// thread: no locks, no I/O.
pub trait SampleSource: Send + Sync {
/// Sample rate of the audio in Hz.
fn sample_rate(&self) -> f32;
fn num_channels(&self) -> usize;
/// One channel's frames (`None` past the last channel).
fn channel(&self, ch: usize) -> Option<&[f32]>;
/// Frames per channel (the shortest channel, so every index below it is readable).
fn frames(&self) -> usize {
(0..self.num_channels()).filter_map(|c| self.channel(c)).map(<[f32]>::len).min().unwrap_or(0)
}
}
/// A sample held in memory (tests, generated audio).
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SampleBuffer {
pub sample_rate: f32,
pub channels: Vec<Vec<f32>>,
}
impl SampleSource for SampleBuffer {
fn sample_rate(&self) -> f32 {
self.sample_rate
}
fn num_channels(&self) -> usize {
self.channels.len()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.channels.get(ch).map(Vec::as_slice)
}
}
/// The plugin registry.
pub fn plugins() -> &'static [&'static PluginInfo] {
plugins::REGISTRY
@@ -298,4 +341,21 @@ mod trait_default_tests {
assert!(p.editor().is_none(), "{}", info.id);
}
}
#[test]
fn only_the_sampler_takes_samples() {
for info in plugins() {
let mut p = create(info.id).unwrap();
let s: Arc<dyn SampleSource> = Arc::new(SampleBuffer { sample_rate: 48_000.0, channels: vec![vec![0.0; 16]] });
assert_eq!(p.set_sample(Some(s)), info.id == "sampler", "{}", info.id);
assert_eq!(p.set_sample(None), info.id == "sampler", "{}", info.id);
}
}
#[test]
fn sample_buffer_frames_is_the_shortest_channel() {
let s = SampleBuffer { sample_rate: 44_100.0, channels: vec![vec![0.0; 10], vec![0.0; 7]] };
assert_eq!(s.frames(), 7);
assert_eq!(SampleBuffer::default().frames(), 0);
}
}
+3
View File
@@ -7,6 +7,7 @@ pub mod harmonic;
pub mod modulation;
pub mod pitch;
pub mod reverb;
pub mod sampler;
pub mod strip;
pub mod synth;
pub mod utility;
@@ -41,6 +42,7 @@ pub(crate) static REGISTRY: &[&PluginInfo] = &[
&utility::DITHER_INFO,
&synth::SYNTH_INFO,
&drums::DRUM_INFO,
&sampler::SAMPLER_INFO,
];
/// Constructs an (unprepared) plugin by id.
@@ -72,6 +74,7 @@ pub(crate) fn instantiate(id: &str) -> Option<Box<dyn Plugin>> {
"dither" => Box::new(utility::Dither::new()),
"subtractive_synth" => Box::new(synth::SubtractiveSynth::new()),
"drum_synth" => Box::new(drums::DrumSynth::new()),
"sampler" => Box::new(sampler::Sampler::new()),
_ => return None,
})
}
+817
View File
@@ -0,0 +1,817 @@
//! Sampler: plays one recorded sample across the keyboard, 32 voices.
//!
//! Each note plays the sample pitched from its root note (an octave up plays twice as fast) with
//! 4-point cubic interpolation, which also converts the sample's own rate to the host's. Key and
//! velocity ranges, an amp ADSR with velocity sensitivity, a sample start point, forward or
//! ping-pong loops, and gate or one-shot playback. The host hands the audio over with
//! [`Plugin::set_sample`]; without one the Sampler is silent.
use std::sync::Arc;
use super::synth::{Adsr, AdsrRates, Event, EventKind, EventQueue, MAX_EVENTS, Stage};
use crate::params::{Params, choice, lin, log, param_plumbing, toggle};
use crate::util::{Smoother, frames_in, ms_to_samples, sane_channels, sane_sr};
use crate::{Category, ParamInfo, Plugin, PluginInfo, SampleSource, Unit, db_to_gain};
const PLAY_MODES: &[&str] = &["Gate", "One Shot"];
const LOOP_MODES: &[&str] = &["Off", "Forward", "Ping-Pong"];
const ROOT: usize = 0;
const TRANSPOSE: usize = 1;
const FINE: usize = 2;
const KEY_TRACK: usize = 3;
const KEY_LOW: usize = 4;
const KEY_HIGH: usize = 5;
const VEL_LOW: usize = 6;
const VEL_HIGH: usize = 7;
const VEL_SENS: usize = 8;
const ATTACK: usize = 9;
const DECAY: usize = 10;
const SUSTAIN: usize = 11;
const RELEASE: usize = 12;
const PLAY_MODE: usize = 13;
const START: usize = 14;
const LOOP_MODE: usize = 15;
const LOOP_START: usize = 16;
const LOOP_END: usize = 17;
const LEVEL: usize = 18;
static SAMPLER_PARAMS: [ParamInfo; 19] = [
lin("root_note", "Root Note", 0.0, 127.0, 60.0, Unit::None),
lin("transpose", "Transpose", -48.0, 48.0, 0.0, Unit::Semitones),
lin("fine_tune", "Fine Tune", -100.0, 100.0, 0.0, Unit::Cents),
toggle("key_track", "Key Tracking", true),
lin("key_low", "Lowest Key", 0.0, 127.0, 0.0, Unit::None),
lin("key_high", "Highest Key", 0.0, 127.0, 127.0, Unit::None),
lin("vel_low", "Lowest Velocity", 1.0, 127.0, 1.0, Unit::None),
lin("vel_high", "Highest Velocity", 1.0, 127.0, 127.0, Unit::None),
lin("velocity", "Velocity Sens", 0.0, 100.0, 70.0, Unit::Percent),
log("attack", "Attack", 0.1, 5000.0, 1.0, Unit::Ms),
log("decay", "Decay", 1.0, 10000.0, 300.0, Unit::Ms),
lin("sustain", "Sustain", 0.0, 100.0, 100.0, Unit::Percent),
log("release", "Release", 1.0, 10000.0, 150.0, Unit::Ms),
choice("play_mode", "Play Mode", PLAY_MODES, 0),
lin("start", "Sample Start", 0.0, 100.0, 0.0, Unit::Percent),
choice("loop_mode", "Loop", LOOP_MODES, 0),
lin("loop_start", "Loop Start", 0.0, 100.0, 0.0, Unit::Percent),
lin("loop_end", "Loop End", 0.0, 100.0, 100.0, Unit::Percent),
lin("level", "Level", -48.0, 12.0, 0.0, Unit::Db),
];
pub static SAMPLER_INFO: PluginInfo = PluginInfo {
id: "sampler",
name: "Sampler",
short_name: "Sampler",
category: Category::Instrument,
params: &SAMPLER_PARAMS,
is_instrument: true,
audiosuite: false,
};
const VOICES: usize = 32;
/// Fastest playback step in sample frames per output frame (keeps hostile pitch bounded).
const MAX_STEP: f64 = 256.0;
#[derive(Debug, Clone, Copy, PartialEq)]
enum LoopMode {
Off,
Forward,
PingPong,
}
#[derive(Debug, Clone, Copy)]
struct Voice {
note: u8,
/// Key still down (gate mode only; one-shot voices are never held).
held: bool,
gain: f32,
/// Read position in sample frames.
pos: f64,
step: f64,
forward: bool,
looping: bool,
env: Adsr,
}
impl Voice {
const fn new() -> Self {
Self { note: 0, held: false, gain: 0.0, pos: 0.0, step: 1.0, forward: true, looping: false, env: Adsr::new() }
}
fn active(&self) -> bool {
self.env.stage != Stage::Idle
}
fn stop(&mut self) {
*self = Voice::new();
}
}
/// Values derived from the parameters (refreshed on every change).
#[derive(Debug, Clone, Copy)]
struct Derived {
root: f64,
/// Transpose plus fine tune, in semitones.
offset: f64,
key_track: bool,
keys: (u8, u8),
vels: (u8, u8),
sens: f32,
one_shot: bool,
start: f64,
loop_mode: LoopMode,
/// Loop start and end as fractions of the sample (start <= end).
loop_span: (f64, f64),
}
/// A MIDI key or velocity parameter as a whole number in `lo..=127`.
fn whole(v: f32, lo: u8) -> u8 {
if v.is_finite() { v.round().clamp(f32::from(lo), 127.0) as u8 } else { lo }
}
fn ordered(a: u8, b: u8) -> (u8, u8) {
(a.min(b), a.max(b))
}
/// 4-point cubic (Catmull-Rom) interpolation of `x` at fractional frame `pos` (>= 0). Reads
/// outside the slice are silence.
#[inline]
fn hermite(x: &[f32], pos: f64) -> f32 {
let i = pos as usize;
let t = (pos - i as f64) as f32;
let at = |k: usize| x.get(k).copied().unwrap_or(0.0);
let x0 = at(i);
if t == 0.0 {
return if x0.is_finite() { x0 } else { 0.0 };
}
let xm1 = at(i.saturating_sub(1));
let x1 = at(i.saturating_add(1));
let x2 = at(i.saturating_add(2));
let c1 = 0.5 * (x1 - xm1);
let c2 = xm1 - 2.5 * x0 + 2.0 * x1 - 0.5 * x2;
let c3 = 0.5 * (x2 - xm1) + 1.5 * (x0 - x1);
let y = ((c3 * t + c2) * t + c1) * t + x0;
// A float file can hold NaN or infinities; they play as silence.
if y.is_finite() { y } else { 0.0 }
}
/// Plays a sample across the keyboard.
pub struct Sampler {
p: Params,
d: Derived,
sr: f32,
ch: usize,
sample: Option<Arc<dyn SampleSource>>,
/// Frames in the sample (0 = nothing to play).
len: usize,
/// The sample's own rate.
sample_sr: f32,
voices: [Voice; VOICES],
events: EventQueue,
rates: AdsrRates,
level: Smoother,
}
impl Default for Sampler {
fn default() -> Self {
Self::new()
}
}
impl Sampler {
pub fn new() -> Self {
let mut s = Self {
p: Params::new(&SAMPLER_PARAMS),
d: Derived {
root: 60.0,
offset: 0.0,
key_track: true,
keys: (0, 127),
vels: (1, 127),
sens: 0.7,
one_shot: false,
start: 0.0,
loop_mode: LoopMode::Off,
loop_span: (0.0, 1.0),
},
sr: 48_000.0,
ch: 0,
sample: None,
len: 0,
sample_sr: 48_000.0,
voices: [Voice::new(); VOICES],
events: EventQueue::new(),
rates: AdsrRates::default(),
level: Smoother::new(1.0),
};
s.update();
s
}
fn update(&mut self) {
let v = |i| self.p.v(i);
let pct = |i| f64::from(v(i) / 100.0).clamp(0.0, 1.0);
let (ls, le) = (pct(LOOP_START), pct(LOOP_END));
self.d = Derived {
root: f64::from(whole(v(ROOT), 0)),
offset: f64::from(v(TRANSPOSE)) + f64::from(v(FINE)) / 100.0,
key_track: self.p.on(KEY_TRACK),
keys: ordered(whole(v(KEY_LOW), 0), whole(v(KEY_HIGH), 0)),
vels: ordered(whole(v(VEL_LOW), 1), whole(v(VEL_HIGH), 1)),
sens: (v(VEL_SENS) / 100.0).clamp(0.0, 1.0),
one_shot: self.p.choice(PLAY_MODE) == 1,
start: pct(START),
loop_mode: match self.p.choice(LOOP_MODE) {
1 => LoopMode::Forward,
2 => LoopMode::PingPong,
_ => LoopMode::Off,
},
loop_span: (ls.min(le), ls.max(le)),
};
self.rates = AdsrRates::new(v(ATTACK), v(DECAY), v(SUSTAIN) / 100.0, v(RELEASE), self.sr);
self.level.set(db_to_gain(v(LEVEL)));
}
/// The loop region in sample frames, when it is long enough to loop.
fn loop_frames(&self) -> Option<(f64, f64)> {
if self.d.loop_mode == LoopMode::Off {
return None;
}
let len = self.len as f64;
let (a, b) = (self.d.loop_span.0 * len, self.d.loop_span.1 * len);
(b - a >= 1.0).then_some((a, b))
}
fn start_note(&mut self, note: u8, vel: u8) {
if vel == 0 {
self.stop_note(note);
return;
}
let d = self.d;
let (note, vel) = (note.min(127), vel.min(127));
if self.len == 0 || note < d.keys.0 || note > d.keys.1 || vel < d.vels.0 || vel > d.vels.1 {
return;
}
let semis = if d.key_track { f64::from(note) - d.root } else { 0.0 } + d.offset;
let step = f64::from(self.sample_sr) / f64::from(self.sr) * 2f64.powf(semis / 12.0);
if !step.is_finite() || step <= 0.0 {
return;
}
if !d.one_shot {
// Retriggering a held key releases the earlier voice instead of stacking it.
self.stop_note(note);
}
let idx = self
.voices
.iter()
.position(|v| !v.active())
.or_else(|| {
self.voices
.iter()
.enumerate()
.min_by(|(_, a), (_, b)| (a.held, a.env.level).partial_cmp(&(b.held, b.env.level)).unwrap_or(std::cmp::Ordering::Equal))
.map(|(i, _)| i)
})
.unwrap_or(0);
let start = d.start * self.len.saturating_sub(1) as f64;
let looping = self.loop_frames().is_some_and(|(_, end)| start < end);
let gain = (1.0 - d.sens) + d.sens * f32::from(vel) / 127.0;
if let Some(v) = self.voices.get_mut(idx) {
*v = Voice { note, held: !d.one_shot, gain, pos: start, step: step.min(MAX_STEP), forward: true, looping, env: Adsr::new() };
v.env.gate_on();
}
}
fn stop_note(&mut self, note: u8) {
for v in self.voices.iter_mut().filter(|v| v.held && v.note == note) {
v.held = false;
v.env.gate_off();
}
}
fn apply(&mut self, kind: EventKind) {
match kind {
EventKind::On(n, v) => self.start_note(n, v),
EventKind::Off(n) => self.stop_note(n),
EventKind::AllOff => {
for v in &mut self.voices {
v.held = false;
v.env.gate_off();
}
}
}
}
/// Adds every voice's output for frame `n` into `io` and advances the voices.
#[inline]
fn render_frame(&mut self, io: &mut [Vec<f32>], ch: usize, n: usize, level: f32) {
let Some(sample) = self.sample.as_deref() else { return };
let nch = sample.num_channels();
let len = self.len as f64;
let mode = self.d.loop_mode;
let region = self.loop_frames();
for v in &mut self.voices {
if !v.active() {
continue;
}
let a = v.env.next(&self.rates) * v.gain * level;
for (c, b) in io.iter_mut().take(ch).enumerate() {
let src = if nch == 1 { 0 } else { c };
if let (Some(x), Some(data)) = (b.get_mut(n), sample.channel(src)) {
*x += hermite(data, v.pos) * a;
}
}
let r = if v.looping { region } else { None };
advance(v, len, r, mode);
}
}
}
/// Moves a voice's read position one output frame on, looping or ending it.
#[inline]
fn advance(v: &mut Voice, len: f64, region: Option<(f64, f64)>, mode: LoopMode) {
v.pos = if v.forward { v.pos + v.step } else { v.pos - v.step };
match region {
Some((ls, le)) if mode == LoopMode::PingPong => {
if v.forward && v.pos >= le {
v.pos = (le - (v.pos - le)).max(ls);
v.forward = false;
} else if !v.forward && v.pos <= ls {
v.pos = (ls + (ls - v.pos)).min(le);
v.forward = true;
}
}
Some((ls, le)) => {
if v.pos >= le {
v.pos = ls + (v.pos - ls) % (le - ls);
}
}
None => {
if v.pos >= len || !v.pos.is_finite() {
v.stop();
}
}
}
if !v.pos.is_finite() || v.pos < 0.0 {
v.stop();
}
}
impl Plugin for Sampler {
param_plumbing!(SAMPLER_INFO);
fn prepare(&mut self, sample_rate: f32, _max_block: usize, channels: usize) {
self.sr = sane_sr(sample_rate);
self.ch = sane_channels(channels);
self.level.set_time(20.0, self.sr);
self.update();
self.reset();
}
fn reset(&mut self) {
for v in &mut self.voices {
v.stop();
}
self.events.clear();
self.level.snap();
}
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
let ch = self.ch.min(io.len());
let frames = frames_in(io, frames, ch);
for b in io.iter_mut().take(ch) {
b.iter_mut().take(frames).for_each(|x| *x = 0.0);
}
let mut evs = [Event { offset: 0, kind: EventKind::AllOff }; MAX_EVENTS];
let count = {
let sorted = self.events.sorted();
for (d, s) in evs.iter_mut().zip(sorted) {
*d = *s;
}
sorted.len().min(MAX_EVENTS)
};
self.events.clear();
let mut next = 0;
for n in 0..frames {
while let Some(e) = evs.get(next).filter(|_| next < count) {
if e.offset > n && n + 1 < frames {
break;
}
let kind = e.kind;
self.apply(kind);
next += 1;
}
let level = self.level.next();
self.render_frame(io, ch, n, level);
}
// Events for an empty block take effect immediately.
for e in evs.iter().take(count).skip(next) {
self.apply(e.kind);
}
}
fn tail_samples(&self) -> usize {
let release = ms_to_samples(self.p.v(RELEASE), self.sr);
// A one-shot keeps playing its sample after the note ends.
let rest = if self.d.one_shot { self.len as f32 * self.sr / self.sample_sr } else { 0.0 };
let t = release + rest;
if t.is_finite() { t.min(1.0e9) as usize } else { 0 }
}
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
self.events.push(offset, EventKind::On(note, velocity));
}
fn note_off(&mut self, offset: usize, note: u8) {
self.events.push(offset, EventKind::Off(note));
}
fn all_notes_off(&mut self) {
self.events.push(0, EventKind::AllOff);
}
fn set_sample(&mut self, sample: Option<Arc<dyn SampleSource>>) -> bool {
for v in &mut self.voices {
v.stop();
}
self.len = sample.as_deref().map_or(0, |s| if s.num_channels() == 0 { 0 } else { s.frames() });
let rate = sample.as_deref().map_or(self.sr, |s| s.sample_rate());
// An unusable rate plays at the host rate rather than not at all.
self.sample_sr = if rate.is_finite() && rate > 0.0 { rate.min(1_536_000.0) } else { self.sr };
self.sample = sample;
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SampleBuffer;
use std::f32::consts::TAU;
const SR: f32 = 48_000.0;
fn sampler(sample: SampleBuffer) -> Sampler {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
assert!(s.set_sample(Some(Arc::new(sample))));
s
}
fn mono(sample_rate: f32, data: Vec<f32>) -> SampleBuffer {
SampleBuffer { sample_rate, channels: vec![data] }
}
fn sine(freq: f32, sr: f32, len: usize) -> Vec<f32> {
(0..len).map(|i| 0.5 * (TAU * freq * i as f32 / sr).sin()).collect()
}
/// Renders `len` frames in 512-frame blocks; returns both channels.
fn run(s: &mut Sampler, len: usize) -> [Vec<f32>; 2] {
let mut out = [Vec::new(), Vec::new()];
let mut buf = vec![vec![1.0f32; 512]; 2];
let mut done = 0;
while done < len {
let n = 512.min(len - done);
s.process(&mut buf, n);
out[0].extend_from_slice(&buf[0][..n]);
out[1].extend_from_slice(&buf[1][..n]);
done += n;
}
out
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
/// Frequency from upward zero crossings.
fn freq(x: &[f32], sr: f32) -> f32 {
let ups: Vec<usize> = x.windows(2).enumerate().filter(|(_, w)| w[0] < 0.0 && w[1] >= 0.0).map(|(i, _)| i).collect();
let (Some(a), Some(b)) = (ups.first(), ups.last()) else { return 0.0 };
(ups.len() - 1) as f32 * sr / (b - a) as f32
}
#[test]
fn silent_without_a_sample_and_replaces_the_buffer() {
let mut s = Sampler::new();
s.prepare(SR, 512, 2);
s.note_on(0, 60, 127);
let out = run(&mut s, 4800);
assert_eq!(peak(&out[0]), 0.0);
assert_eq!(peak(&out[1]), 0.0);
}
#[test]
fn root_note_plays_the_sample_verbatim() {
let data = sine(440.0, SR, 9600);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.note_on(0, 60, 100);
let out = run(&mut s, 9600);
// Past the 0.1-ms-ish attack the output is the sample itself, on both sides.
for i in 100..9600 {
assert!((out[0][i] - data[i]).abs() < 1e-6, "frame {i}: {} vs {}", out[0][i], data[i]);
}
assert_eq!(out[0], out[1], "a mono sample feeds both channels");
}
#[test]
fn keys_transpose_from_the_root_note() {
let mut s = sampler(mono(SR, sine(400.0, SR, 96_000)));
s.note_on(0, 72, 127);
let up = run(&mut s, 24_000);
assert!((freq(&up[0][480..], SR) - 800.0).abs() < 2.0, "an octave up: {}", freq(&up[0][480..], SR));
s.reset();
s.note_on(0, 53, 127);
let down = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(-7.0 / 12.0);
assert!((freq(&down[0][480..], SR) - want).abs() < 2.0);
// Root note 48 makes key 60 play an octave up; transpose and fine tune add on top.
s.reset();
s.set_param("root_note", 48.0);
s.set_param("transpose", -12.0);
s.set_param("fine_tune", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
let want = 400.0 * 2f32.powf(1.0 / 12.0);
assert!((freq(&out[0][480..], SR) - want).abs() < 2.0, "{}", freq(&out[0][480..], SR));
}
#[test]
fn key_tracking_off_plays_every_key_at_the_sample_pitch() {
let mut s = sampler(mono(SR, sine(400.0, SR, 48_000)));
s.set_param("key_track", 0.0);
s.note_on(0, 90, 127);
let out = run(&mut s, 24_000);
assert!((freq(&out[0][480..], SR) - 400.0).abs() < 2.0);
}
#[test]
fn converts_the_sample_rate() {
// A 300 Hz tone recorded at 22.05 kHz still plays at 300 Hz on a 48 kHz host.
let mut s = sampler(mono(22_050.0, sine(300.0, 22_050.0, 22_050)));
s.note_on(0, 60, 127);
let out = run(&mut s, 60_000);
assert!((freq(&out[0][480..], SR) - 300.0).abs() < 1.0, "{}", freq(&out[0][480..], SR));
// And it lasts as long as the recording: one second.
assert!(peak(&out[0][46_000..47_500]) > 0.1);
assert_eq!(peak(&out[0][48_200..]), 0.0, "the sample ended");
// A 96 kHz recording is read faster, not cut short.
let mut s = sampler(mono(96_000.0, sine(1000.0, 96_000.0, 96_000)));
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!((freq(&out[0][480..], SR) - 1000.0).abs() < 2.0);
}
#[test]
fn key_and_velocity_ranges_gate_notes() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("key_low", 48.0);
s.set_param("key_high", 60.0);
s.set_param("vel_low", 40.0);
s.set_param("vel_high", 100.0);
for (note, vel, sounds) in
[(47, 80, false), (61, 80, false), (48, 80, true), (60, 80, true), (55, 39, false), (55, 101, false), (55, 40, true)]
{
s.reset();
s.note_on(0, note, vel);
let out = run(&mut s, 960);
assert_eq!(peak(&out[0]) > 0.0, sounds, "note {note} velocity {vel}");
}
// Reversed ranges are read in order rather than muting everything.
s.set_param("key_low", 60.0);
s.set_param("key_high", 48.0);
s.reset();
s.note_on(0, 50, 80);
assert!(peak(&run(&mut s, 960)[0]) > 0.0);
}
#[test]
fn velocity_sensitivity_scales_level() {
let level = |sens: f32, vel: u8| {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.set_param("velocity", sens);
s.note_on(0, 60, vel);
peak(&run(&mut s, 2400)[0][1000..])
};
assert!((level(100.0, 127) - 0.5).abs() < 1e-4);
assert!((level(100.0, 64) - 0.5 * 64.0 / 127.0).abs() < 1e-4);
assert!((level(0.0, 1) - 0.5).abs() < 1e-4, "no sensitivity: every velocity is full level");
assert!(level(70.0, 30) < level(70.0, 127));
}
#[test]
fn adsr_shapes_the_note() {
let mut s = sampler(mono(SR, vec![0.5; 96_000]));
s.set_param("velocity", 0.0);
s.set_param("attack", 100.0);
s.set_param("decay", 100.0);
s.set_param("sustain", 50.0);
s.set_param("release", 100.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 24_000);
// Linear attack: halfway up at 50 ms, full level at 100 ms, then decay to the sustain level.
assert!((out[0][2400] - 0.25).abs() < 0.01, "attack midpoint {}", out[0][2400]);
assert!(peak(&out[0][4700..4900]) > 0.49);
assert!((out[0][20_000] - 0.25).abs() < 0.005, "sustain {}", out[0][20_000]);
s.note_off(0, 60);
let out = run(&mut s, 24_000);
assert!(out[0][100] > 0.2, "release starts from the sustain level");
assert!(peak(&out[0][5000..]) < 1e-3, "released after 100 ms");
assert_eq!(peak(&out[0][12_000..]), 0.0, "the voice ended");
}
#[test]
fn gate_mode_stops_on_note_off_and_one_shot_plays_through() {
let mut s = sampler(mono(SR, vec![0.5; 24_000]));
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 24_000);
assert!(peak(&out[0][..900]) > 0.1);
assert_eq!(peak(&out[0][2000..]), 0.0, "gate: released at the note-off");
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_off(1000, 60);
let out = run(&mut s, 30_000);
assert!(peak(&out[0][20_000..23_900]) > 0.1, "one shot: ignores the note-off");
assert_eq!(peak(&out[0][24_100..]), 0.0, "and stops at the end of the sample");
assert!(s.tail_samples() >= 24_000, "a one-shot's tail covers the sample");
// Stopping the transport still silences one-shots.
s.reset();
s.note_on(0, 60, 127);
run(&mut s, 512);
s.all_notes_off();
assert_eq!(peak(&run(&mut s, 4800)[0][2400..]), 0.0);
}
#[test]
fn retriggering_a_held_key_releases_the_old_voice() {
let mut s = sampler(mono(SR, vec![0.25; 48_000]));
s.set_param("velocity", 0.0);
s.set_param("release", 1.0);
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!(peak(&out[0][2400..]) < 0.26, "voices did not stack: {}", peak(&out[0][2400..]));
// One-shots overlap.
s.set_param("play_mode", 1.0);
s.reset();
s.note_on(0, 60, 127);
s.note_on(256, 60, 127);
let out = run(&mut s, 4800);
assert!((peak(&out[0][2400..]) - 0.5).abs() < 1e-3);
}
#[test]
fn forward_loop_sustains_past_the_end() {
let data = sine(480.0, SR, 4800);
let mut s = sampler(mono(SR, data.clone()));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 48_000);
assert!(peak(&out[0][40_000..]) > 0.4, "still sounding long after the 0.1 s sample");
// After the first pass it repeats the loop region (2400 frames, whole cycles of 480 Hz).
for i in 0..2400 {
assert!((out[0][4800 + i] - data[2400 + i]).abs() < 1e-4, "frame {i}");
}
assert!((freq(&out[0][4800..], SR) - 480.0).abs() < 1.0);
// Without the loop the note ends with the sample.
s.set_param("loop_mode", 0.0);
s.reset();
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 9600)[0][4900..]), 0.0);
}
#[test]
fn ping_pong_loop_reverses_direction() {
// A rising ramp, looped ping-pong over the whole sample, rises then falls.
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("loop_mode", 2.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 3000);
assert!(out[0][900] > out[0][500], "rising");
assert!(out[0][1500] < out[0][1100], "falling after the loop end");
assert!(out[0][2500] > out[0][2100], "rising again after the loop start");
assert!(out[0].iter().all(|x| x.is_finite() && *x <= 1.0));
}
#[test]
fn a_loop_too_short_to_loop_plays_once() {
let mut s = sampler(mono(SR, vec![0.5; 1000]));
s.set_param("loop_mode", 1.0);
s.set_param("loop_start", 40.0);
s.set_param("loop_end", 40.0);
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 4800)[0][1100..]), 0.0);
}
#[test]
fn sample_start_skips_into_the_sample() {
let data: Vec<f32> = (0..1000).map(|i| i as f32 / 1000.0).collect();
let mut s = sampler(mono(SR, data));
s.set_param("velocity", 0.0);
s.set_param("attack", 0.1);
s.set_param("start", 50.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2000);
assert!((out[0][10] - 0.509).abs() < 0.002, "{}", out[0][10]);
assert_eq!(peak(&out[0][520..]), 0.0, "half the sample left");
}
#[test]
fn stereo_samples_keep_their_sides() {
let sample = SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 4800], vec![-0.25; 4800]] };
let mut s = sampler(sample);
s.set_param("velocity", 0.0);
s.note_on(0, 60, 127);
let out = run(&mut s, 2400);
assert!((out[0][1000] - 0.5).abs() < 1e-6);
assert!((out[1][1000] + 0.25).abs() < 1e-6);
}
#[test]
fn note_offsets_are_sample_accurate() {
let mut s = sampler(mono(SR, vec![0.5; 4800]));
s.note_on(300, 60, 127);
let out = run(&mut s, 512);
assert!(out[0][..300].iter().all(|&v| v == 0.0));
assert!(out[0][301..].iter().any(|&v| v != 0.0));
}
#[test]
fn many_notes_steal_voices_and_stay_bounded() {
let mut s = sampler(mono(SR, sine(220.0, SR, 48_000)));
s.set_param("level", 12.0);
for n in 0..100u8 {
s.note_on(usize::from(n), n, 127);
}
let out = run(&mut s, 9600);
assert!(out[0].iter().all(|v| v.is_finite()));
assert!(s.voices.iter().filter(|v| v.active()).count() <= VOICES);
}
#[test]
fn a_new_sample_stops_the_old_voices() {
let mut s = sampler(mono(SR, vec![0.5; 48_000]));
s.note_on(0, 60, 127);
run(&mut s, 512);
assert!(s.set_sample(Some(Arc::new(mono(SR, vec![0.1; 10])))));
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
assert!(s.set_sample(None));
s.note_on(0, 60, 127);
assert_eq!(peak(&run(&mut s, 512)[0]), 0.0);
}
#[test]
fn hostile_samples_and_params_never_panic() {
let samples = [
SampleBuffer { sample_rate: f32::NAN, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 0.0, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: f32::INFINITY, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: 1.0e-30, channels: vec![vec![0.5; 100]] },
SampleBuffer { sample_rate: SR, channels: vec![] },
SampleBuffer { sample_rate: SR, channels: vec![vec![]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 100], vec![0.5; 3]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![0.5; 1]] },
SampleBuffer { sample_rate: SR, channels: vec![vec![f32::NAN; 100]; 9] },
];
let params: Vec<(&str, f32)> = SAMPLER_PARAMS.iter().flat_map(|p| [(p.id, f32::NAN), (p.id, f32::INFINITY), (p.id, -1.0e30)]).collect();
for sample in samples {
let mut s = Sampler::new();
s.prepare(f32::NAN, 0, 99);
s.set_sample(Some(Arc::new(sample)));
for (id, v) in &params {
assert!(s.set_param(id, *v));
for n in [0u8, 60, 127, 255] {
s.note_on(0, n, 255);
s.note_on(7, n, 1);
}
let mut io = vec![vec![0.0f32; 64]; 3];
s.process(&mut io, 1000);
assert!(io.iter().flatten().all(|v| v.is_finite()), "{id} = {v}");
s.process(&mut [], 64);
let _ = s.tail_samples();
}
}
// Loops with every mode at the extremes of pitch.
let mut s = sampler(mono(SR, sine(100.0, SR, 1000)));
for mode in [0.0, 1.0, 2.0] {
s.set_param("loop_mode", mode);
for (t, root) in [(48.0, 0.0), (-48.0, 127.0)] {
s.set_param("transpose", t);
s.set_param("root_note", root);
s.note_on(0, 127, 127);
s.note_on(0, 0, 127);
let out = run(&mut s, 4096);
assert!(out[0].iter().all(|v| v.is_finite() && v.abs() < 4.0), "mode {mode} transpose {t}");
}
}
}
}
+10 -10
View File
@@ -97,7 +97,7 @@ const VOICES: usize = 16;
const SILENT: f32 = 1.0e-5;
#[derive(Debug, Clone, Copy, PartialEq)]
enum Stage {
pub(crate) enum Stage {
Idle,
Attack,
Decay,
@@ -107,13 +107,13 @@ enum Stage {
/// Linear-attack, exponential decay/release ADSR. Times are "to -60 dB" for decay/release.
#[derive(Debug, Clone, Copy)]
struct Adsr {
stage: Stage,
level: f32,
pub(crate) struct Adsr {
pub(crate) stage: Stage,
pub(crate) level: f32,
}
#[derive(Debug, Clone, Copy, Default)]
struct AdsrRates {
pub(crate) struct AdsrRates {
attack: f32,
decay: f32,
sustain: f32,
@@ -121,29 +121,29 @@ struct AdsrRates {
}
impl AdsrRates {
fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
pub(crate) fn new(a_ms: f32, d_ms: f32, s: f32, r_ms: f32, sr: f32) -> Self {
let exp60 = |ms: f32| (0.001f32.ln() / ms_to_samples(ms, sr).max(1.0)).exp();
Self { attack: 1.0 / ms_to_samples(a_ms, sr).max(1.0), decay: exp60(d_ms), sustain: s.clamp(0.0, 1.0), release: exp60(r_ms) }
}
}
impl Adsr {
const fn new() -> Self {
pub(crate) const fn new() -> Self {
Self { stage: Stage::Idle, level: 0.0 }
}
fn gate_on(&mut self) {
pub(crate) fn gate_on(&mut self) {
self.stage = Stage::Attack;
}
fn gate_off(&mut self) {
pub(crate) fn gate_off(&mut self) {
if self.stage != Stage::Idle {
self.stage = Stage::Release;
}
}
#[inline]
fn next(&mut self, r: &AdsrRates) -> f32 {
pub(crate) fn next(&mut self, r: &AdsrRates) -> f32 {
match self.stage {
Stage::Idle => self.level = 0.0,
Stage::Attack => {
+1
View File
@@ -22,6 +22,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
log.workspace = true
zip.workspace = true
[dev-dependencies]
proptest.workspace = true
+46 -2
View File
@@ -107,8 +107,12 @@ fn process(e: &mut Engine, p: &Value) -> Result<Value> {
nc.name = new_name;
nc.content = ClipContent::Audio { source: src, offset: 0 };
nc.length = i64::try_from(frames).unwrap_or(nc.length);
nc.gain_db = 0.0;
nc.gain_env.clear();
// Invert and Duplicate are phase/copy operations. The clip's gain stays a clip gain,
// the same way its fades stay editable. Other processes bake a new file at unity gain.
if !matches!(name.as_str(), "invert" | "duplicate") {
nc.gain_db = 0.0;
nc.gain_env.clear();
}
if let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) {
pl.clips.retain(|c| c.id != id);
}
@@ -122,3 +126,43 @@ fn short(n: &str) -> String {
let s: String = n.split('_').map(|w| w.chars().take(4).collect::<String>()).collect::<Vec<_>>().join("");
s.chars().take(8).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Session, TrackKind};
fn gained_clip() -> (crate::Engine, soundcraft_model::ClipId) {
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);
let id = s.new_clip_id();
let mut c = Clip::audio(id, "tone", src, 0, 0, 64);
c.gain_db = -6.0;
c.gain_env = vec![(0, 0.0), (32, -12.0)];
crate::edit::place_clip(&mut s, t, c);
s.edit.selected_clips = vec![id];
(crate::Engine::new(s), id)
}
#[test]
fn invert_and_duplicate_keep_clip_gain() {
let (mut e, id) = gained_clip();
e.execute("audiosuite.process", &json!({"process": "invert", "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
assert!((sample + 0.5).abs() < 1e-3, "invert flips the file and leaves the gain on the clip, got {sample}");
let (mut e, id) = gained_clip();
e.execute("audiosuite.process", &json!({"process": "duplicate", "clips": [id.0]})).unwrap();
let c = e.session().tracks[0].clips()[0].clone();
assert!((c.gain_db + 6.0).abs() < 1e-6, "{}", c.gain_db);
assert_eq!(c.gain_env, vec![(0, 0.0), (32, -12.0)]);
let sample = e.session().pool.get(c.source().unwrap()).unwrap().buffer.channels[0][0];
assert!((sample - 0.5).abs() < 1e-3, "duplicate keeps the file level and the clip gain, got {sample}");
}
}
+27 -8
View File
@@ -185,10 +185,7 @@ fn elastic(e: &mut Engine, p: &Value) -> Result<Value> {
let elastic = s.track(t).and_then(|x| x.elastic.clone()).filter(|a| a != "varispeed");
// Start from the unwarped original if this clip was warped before.
let key = |k: &str| format!("elastic.{}.{k}", id.0);
let orig = match (s.edit.values.get(&key("src")), s.edit.values.get(&key("off")), s.edit.values.get(&key("len"))) {
(Some(src), Some(off), Some(len)) => Some((soundcraft_model::SourceId(*src as u64), *off as i64, *len as i64)),
_ => None,
};
let orig = warp_original(s, *id);
let mut base = c.clone();
if let Some((src, off, len)) = orig {
base.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
@@ -229,12 +226,12 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
let mut n = 0;
for id in &ids {
let key = |k: &str| format!("elastic.{}.{k}", id.0);
let orig = (s.edit.values.get(&key("src")).copied(), s.edit.values.get(&key("off")).copied(), s.edit.values.get(&key("len")).copied());
let orig = warp_original(s, *id);
if let Some(c) = s.find_clip_mut(*id) {
c.stretch = 1.0;
if let (Some(src), Some(off), Some(len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: soundcraft_model::SourceId(src as u64), offset: off as i64 };
c.length = (len as i64).max(1);
if let Some((src, off, len)) = orig {
c.content = soundcraft_model::ClipContent::Audio { source: src, offset: off };
c.length = len.max(1);
c.fade_out.len = c.fade_out.len.min(c.length);
n += 1;
}
@@ -246,6 +243,14 @@ fn remove_warp(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"restored": n}))
}
/// The audio a warped clip was rendered from, while that file is still in the session.
fn warp_original(s: &soundcraft_model::Session, id: ClipId) -> Option<(soundcraft_model::SourceId, Samples, Samples)> {
let v = |k: &str| s.edit.values.get(&format!("elastic.{}.{k}", id.0)).copied();
let src = soundcraft_model::SourceId(v("src")? as u64);
s.source(src)?;
Some((src, v("off")? as i64, v("len")? as i64))
}
fn flag(e: &mut Engine, p: &Value, get: fn(&soundcraft_model::Clip) -> bool, set: fn(&mut soundcraft_model::Clip, bool)) -> Result<Value> {
let ids = clip_ids_param(e, p);
let s = e.session_mut();
@@ -373,6 +378,20 @@ mod tests {
assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0);
}
#[test]
fn remove_warp_keeps_the_audio_when_the_original_file_is_gone() {
let mut e = crate::demo::demo_engine();
let pad = e.session().track_by_name("Pad").map(|t| t.id).unwrap();
e.execute("track.elastic", &json!({"track": pad, "algorithm": "polyphonic"})).unwrap();
let clip = e.session().track(pad).unwrap().clips()[0].clone();
e.execute("clip.elastic_properties", &json!({"clips": [clip.id], "ratio": 1.5})).unwrap();
e.execute("clip.clear", &json!({"sources": [clip.source()]})).unwrap();
let r = e.execute("clip.remove_warp", &json!({"clips": [clip.id]})).unwrap();
assert_eq!(r["restored"], 0);
let src = e.session().find_clip(clip.id).and_then(|(_, c)| c.source()).unwrap();
assert!(e.session().source(src).is_some());
}
#[test]
fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() {
let mut e = crate::demo::demo_engine();
+160
View File
@@ -0,0 +1,160 @@
//! Clip List commands.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::SourceId;
use std::collections::BTreeSet;
const CLEAR: &str = "clip.clear";
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"clip.clear",
"Clear Clips",
[],
Some("Cmd+Shift+B"),
"{clips?: [id], sources?: [id], with_clips?: bool} — removes clips and audio files from the session (files stay on disk); default: the Clip List selection. A file still used on the timeline needs `with_clips: true`, which removes those clips too",
has_list_selection,
clear
)]
}
fn has_list_selection(e: &Engine) -> std::result::Result<(), String> {
let ed = &e.session().edit;
if ed.selected_clips.is_empty() && ed.selected_sources.is_empty() { Err("select clips or files in the Clip List".into()) } else { Ok(()) }
}
fn ids<'a, T: serde::Deserialize<'a>>(p: &'a Value, key: &str) -> Result<Option<T>> {
p.get(key).map(|v| T::deserialize(v).map_err(|_| bad(CLEAR, format!("`{key}` must be an array of ids")))).transpose()
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session();
let (clips, sources): (BTreeSet<ClipId>, BTreeSet<SourceId>) = match (ids(p, "clips")?, ids(p, "sources")?) {
(None, None) => (s.edit.selected_clips.iter().copied().collect(), s.edit.selected_sources.iter().copied().collect()),
(c, f) => (c.unwrap_or_default(), f.unwrap_or_default()),
};
let known: BTreeSet<ClipId> = s.all_clips().map(|c| c.id).collect();
if let Some(c) = clips.iter().find(|c| !known.contains(c)) {
return Err(bad(CLEAR, format!("no clip with id {c}")));
}
let files = sources.iter().map(|f| s.source(*f).ok_or_else(|| bad(CLEAR, format!("no audio file with id {f}")))).collect::<Result<Vec<_>>>()?;
if clips.is_empty() && files.is_empty() {
return Err(bad(CLEAR, "nothing to clear: pass `clips` or `sources`, or select them in the Clip List"));
}
if !bool_or(p, "with_clips", false)
&& let Some((f, n)) = files.iter().map(|f| (f, s.clips_using(f.id).filter(|c| !clips.contains(&c.id)).count())).find(|(_, n)| *n > 0)
{
let plural = if n == 1 { "" } else { "s" };
return Err(bad(CLEAR, format!("`{}` is used by {n} clip{plural} on the timeline; pass `with_clips: true` to remove them too", f.name)));
}
let gone: BTreeSet<ClipId> = clips.iter().copied().chain(sources.iter().flat_map(|f| s.clips_using(*f).map(|c| c.id))).collect();
let s = e.session_mut();
for pl in s.tracks.iter_mut().flat_map(|t| t.playlists.iter_mut()) {
pl.clips.retain(|c| !gone.contains(&c.id));
}
s.sources.retain(|f| !sources.contains(&f.id));
for f in &sources {
s.pool.remove(*f);
}
s.edit.selected_clips.retain(|c| !gone.contains(c));
s.edit.selected_sources.retain(|f| !sources.contains(f));
Ok(json!({"clips": gone.len(), "sources": sources.len()}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::{AudioBuffer, FileFormat};
fn kick(e: &Engine) -> (SourceId, Vec<ClipId>) {
let s = e.session();
let f = s.sources.iter().find(|x| x.name == "Kick").map(|x| x.id).unwrap();
(f, s.clips_using(f).map(|c| c.id).collect())
}
#[test]
fn clearing_a_clip_takes_it_off_the_timeline_and_keeps_its_file() {
let mut e = crate::demo::demo_engine();
let (f, clips) = kick(&e);
let r = e.execute("clip.clear", &json!({"clips": [clips[0]]})).unwrap();
assert_eq!(r, json!({"clips": 1, "sources": 0}));
assert!(e.session().find_clip(clips[0]).is_none());
assert!(e.session().source(f).is_some());
assert!(e.undo());
assert!(e.session().find_clip(clips[0]).is_some());
}
#[test]
fn a_file_in_use_stays_unless_its_clips_go_too() {
let mut e = crate::demo::demo_engine();
e.execute("track.playlist_duplicate", &json!({"track": "Kick"})).unwrap();
let (f, clips) = kick(&e);
assert_eq!(clips.len(), 6);
let err = e.execute("clip.clear", &json!({"sources": [f]})).unwrap_err();
assert!(err.to_string().contains("used by 6 clips"), "{err}");
let r = e.execute("clip.clear", &json!({"sources": [f], "with_clips": true})).unwrap();
assert_eq!(r, json!({"clips": 6, "sources": 1}));
let s = e.session();
assert!(s.source(f).is_none() && !s.pool.contains(f));
assert_eq!(s.clips_using(f).count(), 0);
assert_eq!(e.undo_label(), Some("Clear Clips"));
assert!(e.undo());
assert!(e.session().pool.contains(f));
assert_eq!(e.session().clips_using(f).count(), 6);
}
#[test]
fn listing_a_files_clips_too_is_enough() {
let mut e = crate::demo::demo_engine();
let (f, clips) = kick(&e);
let r = e.execute("clip.clear", &json!({"clips": clips, "sources": [f]})).unwrap();
assert_eq!(r, json!({"clips": 3, "sources": 1}));
}
#[test]
fn the_clip_list_selection_is_the_default_target() {
let mut e = crate::demo::demo_engine();
let buf = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4_800]] };
let f = crate::io::add_source(e.session_mut(), "Spare", buf, None, FileFormat::Wav);
let (_, clips) = kick(&e);
e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
let r = e.execute("edit.select", &json!({"sources": [f, f, 999_999]})).unwrap();
assert_eq!(r["sources"], json!([f]));
assert_eq!(r["clips"], json!([]));
let r = e.execute("edit.select", &json!({"clips": [clips[0]]})).unwrap();
assert_eq!(r["sources"], json!([]));
e.execute("clip.clear", &json!({})).unwrap();
assert!(e.session().find_clip(clips[0]).is_none());
assert!(e.session().edit.selected_clips.is_empty());
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
e.execute("clip.clear", &json!({})).unwrap();
assert!(e.session().source(f).is_none());
assert!(e.session().edit.selected_sources.is_empty());
}
#[test]
fn bad_targets_are_errors() {
let mut e = crate::demo::demo_engine();
for p in [
json!({}),
json!({"clips": []}),
json!({"clips": [999_999]}),
json!({"sources": [999_999]}),
json!({"sources": "Kick"}),
json!({"clips": [-1]}),
] {
assert!(e.execute("clip.clear", &p).is_err(), "{p}");
}
}
#[test]
fn deselect_all_clears_the_clip_list_selection() {
let mut e = crate::demo::demo_engine();
let (f, _) = kick(&e);
e.execute("edit.select", &json!({"sources": [f]})).unwrap();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.session().edit.selected_sources.is_empty());
}
}
+65
View File
@@ -362,4 +362,69 @@ mod tests {
assert_eq!(c.length, 18_000);
assert_ne!(c.source(), Some(src));
}
fn peak_from(ch: &[f32], start: usize) -> f32 {
ch.get(start..).unwrap_or(&[]).iter().fold(0.0f32, |m, x| m.max(x.abs()))
}
fn max_abs_diff(a: &[Vec<f32>], b: &[Vec<f32>]) -> f32 {
a.iter().zip(b.iter()).flat_map(|(x, y)| x.iter().zip(y.iter()).map(|(p, q)| (p - q).abs())).fold(0.0f32, f32::max)
}
/// Gain and effects renders bake the current varispeed. The clip must not keep stretching the result.
#[test]
fn render_preserves_stretched_audio() {
for command in ["clip.gain_render", "clip.effects_render"] {
for ratio in [0.5_f64, 1.0, 2.0] {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
s.edit.selected_clips = vec![id];
let mut e = Engine::new(s);
e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
e.execute("clip.conform_to_tempo", &json!({"clip": id.0, "source_bpm": 120.0 * ratio})).unwrap();
if command == "clip.effects_render" {
e.execute("clip.effects_set", &json!({"clip": id.0, "params": {"gain": 3.0}})).unwrap();
}
let range = e.session().find_clip(id).unwrap().1.range();
let before = soundcraft_mix::render_clips(e.session(), t, range);
e.execute(command, &json!({"clip": id.0})).unwrap();
let c = e.session().find_clip(id).unwrap().1.clone();
assert!((c.stretch - 1.0).abs() < 1e-9, "{command} ratio {ratio} left stretch {}", c.stretch);
assert_eq!(c.gain_db, 0.0);
let after = soundcraft_mix::render_clips(e.session(), t, range);
let diff = max_abs_diff(&before, &after);
assert!(diff < 1e-5, "{command} ratio {ratio} changed the audio by {diff}");
if ratio < 1.0 {
let tail = before.first().map(|ch| peak_from(ch, ch.len() / 2)).unwrap_or(0.0);
assert!(tail > 0.02, "{command} conformed tail was silent before render: {tail}");
}
}
}
// Varispeed on a non-elastic track does not change the clip length. Render still matches it.
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let tone: Vec<f32> = (0..9_600).map(|i| (i as f32 * 0.05).sin() * 0.4).collect();
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![tone] };
let src = crate::io::add_source(&mut s, "tone", buf, None, soundcraft_audio_io::FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(&mut s, t, Clip::audio(id, "tone", src, 0, 0, 9_600));
s.edit.selected_clips = vec![id];
let mut e = Engine::new(s);
e.execute("clip.elastic_properties", &json!({"clip": id.0, "ratio": 0.5})).unwrap();
e.execute("clip.gain", &json!({"clip": id.0, "db": -6.0})).unwrap();
let range = e.session().find_clip(id).unwrap().1.range();
let before = soundcraft_mix::render_clips(e.session(), t, range);
e.execute("clip.gain_render", &json!({"clip": id.0})).unwrap();
let c = e.session().find_clip(id).unwrap().1.clone();
assert_eq!(c.length, 9_600);
assert!((c.stretch - 1.0).abs() < 1e-9);
let after = soundcraft_mix::render_clips(e.session(), t, range);
assert!(max_abs_diff(&before, &after) < 1e-5);
}
}
+44 -13
View File
@@ -49,11 +49,12 @@ pub fn specs() -> Vec<CommandSpec> {
repeat_to_fill
),
cmd!(noundo "edit.select_all", "Select All", ["Edit"], Some("Cmd+A"), "{}", always, select_all),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select", "Set Edit Selection", [], None, "{tracks?: [id|name], start?, end?, clips?: [id], sources?: [id] (Clip List files), exact?: bool (ignore edit groups)}", always, select),
cmd!(noundo "edit.select_none", "Deselect All", [], None, "{}", always, |e, _| {
let s = e.session_mut();
s.edit.selected_tracks.clear();
s.edit.selected_clips.clear();
s.edit.selected_sources.clear();
s.edit.selection.end = s.edit.selection.start;
Ok(json!({}))
}),
@@ -351,8 +352,12 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let en = position_param(e, "edit.select", p, "end")?;
let clips =
p.get("clips").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::ClipId).collect::<Vec<_>>());
let sources =
p.get("sources").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(soundcraft_model::SourceId).collect::<Vec<_>>());
let list_changes = clips.is_some() || sources.is_some() || st.is_some();
let exact = bool_or(p, "exact", false);
let s = e.session_mut();
let s = e.session();
let mut ed = s.edit.clone();
if let Some(mut t) = tracks {
// Active edit groups select all their members together.
if !exact {
@@ -366,11 +371,11 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
let order: Vec<soundcraft_model::TrackId> = s.tracks.iter().map(|x| x.id).collect();
t.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
}
s.edit.selected_tracks = t;
ed.selected_tracks = t;
}
match (st, en) {
(Some(a), Some(b)) => s.edit.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => s.edit.selection = soundcraft_time::Range::point(a.max(0)),
(Some(a), Some(b)) => ed.selection = soundcraft_time::Range::new(a.max(0), b.max(0)),
(Some(a), None) => ed.selection = soundcraft_time::Range::point(a.max(0)),
_ => {}
}
if let Some(c) = clips {
@@ -386,17 +391,25 @@ fn select(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
if let Some(r) = range {
s.edit.selection = r;
s.edit.selected_tracks = trs;
ed.selection = r;
ed.selected_tracks = trs;
}
s.edit.selected_clips = c;
} else if st.is_some() {
s.edit.selected_clips.clear();
ed.selected_clips = c;
} else if list_changes {
ed.selected_clips.clear();
}
if s.edit.link_timeline_edit {
s.edit.timeline_selection = s.edit.selection;
if list_changes {
let mut known: std::collections::BTreeSet<_> = s.sources.iter().map(|f| f.id).collect();
ed.selected_sources = sources.unwrap_or_default().into_iter().filter(|id| known.remove(id)).collect();
}
Ok(json!({"selection": s.edit.selection, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips}))
if ed.link_timeline_edit {
ed.timeline_selection = ed.selection;
}
let out = json!({"selection": ed.selection, "tracks": ed.selected_tracks, "clips": ed.selected_clips, "sources": ed.selected_sources});
if ed != s.edit {
e.session_mut().edit = ed;
}
Ok(out)
}
fn shift_selection(e: &mut Engine, dir: i64) -> Result<Value> {
@@ -752,3 +765,21 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result<Value> {
}
Ok(json!({"consolidated": n}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
let mut e = Engine::default();
let ids = e.execute("track.new", &json!({"count": 2})).unwrap()["tracks"].clone();
let sel = json!({"tracks": [ids[0]], "start": 100, "end": 200});
e.execute("edit.select", &sel).unwrap();
let rev = e.revision;
assert_eq!(e.execute("edit.select", &sel).unwrap()["tracks"], json!([ids[0]]));
assert_eq!(e.revision, rev);
e.execute("edit.select", &json!({"tracks": [ids[0]], "start": 100, "end": 300})).unwrap();
assert_ne!(e.revision, rev);
}
}
+259 -19
View File
@@ -15,34 +15,41 @@ pub fn specs() -> Vec<CommandSpec> {
Ok(json!({"name": e.session().name, "tracks": e.session().tracks.len(), "missing": missing}))
}),
cmd!(noundo "session.close", "Close Session", ["File"], Some("Cmd+Shift+W"), "{}", always, |e, _| { e.replace_session(Session::default()); e.path = None; Ok(json!({})) }),
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?}", always, |e, p| {
cmd!(noundo "session.save", "Save Session", ["File"], Some("Cmd+S"), "{path?, replace?: false}", always, |e, p| {
let path = str_param(p, "path").map(str::to_string).or_else(|| e.path.clone()).ok_or_else(|| bad("session.save", "no path yet: pass `path` (Save As)"))?;
refuse_replacing(e, "session.save", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path}", always, |e, p| {
cmd!(noundo "session.save_as", "Save Session As...", ["File"], Some("Cmd+Shift+S"), "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_as", "`path` required"))?.to_string();
refuse_replacing(e, "session.save_as", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path}", always, |e, p| {
cmd!(noundo "session.save_copy", "Save Session Copy In...", ["File"], None, "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_copy", "`path` required"))?.to_string();
let keep = e.path.clone();
let written = crate::io::save_session(e, &path)?;
e.path = keep;
refuse_replacing(e, "session.save_copy", &crate::io::session_file(&path), p)?;
let written = crate::io::save_session_copy(e, &path)?;
Ok(json!({"path": path, "audio_files_written": written}))
}),
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path}", always, |e, p| {
cmd!(noundo "session.save_template", "Save As Template...", ["File"], None, "{path, replace?: false}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("session.save_template", "`path` required"))?.to_string();
let file = crate::io::session_file(&path);
refuse_replacing(e, "session.save_template", &file, p)?;
let mut s = e.session().clone();
for t in &mut s.tracks { for pl in &mut t.playlists { pl.clips.clear() } }
s.sources.clear();
s.videos.clear();
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
Ok(json!({"path": path}))
if cfg!(target_arch = "wasm32") {
crate::io::write_file(&file.to_string_lossy(), text.as_bytes())?;
} else {
crate::io::replace_file(&file.with_extension("scraft.tmp"), &file, text.as_bytes())?;
}
Ok(json!({"path": file}))
}),
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", always, |e, _| {
cmd!(noundo "session.revert", "Revert Session to Saved...", ["File"], None, "{}", |e| if e.path.is_some() { Ok(()) } else { Err("the session has not been saved".into()) }, |e, _| {
let path = e.path.clone().ok_or_else(|| bad("session.revert", "the session has not been saved"))?;
crate::io::open_session(e, &path)?;
Ok(json!({}))
@@ -98,25 +105,30 @@ pub fn specs() -> Vec<CommandSpec> {
}),
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
let bytes = crate::io::export_midi(e)?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let only = if p.get("tracks").is_some() || p.get("track").is_some() { Some(tracks_param(e, "file.export_midi", p)?) } else { None };
let bytes = crate::io::export_midi(e, only.as_deref())?;
crate::io::write_file(&path, &bytes)?;
Ok(json!({"path": path, "bytes": bytes.len()}))
}),
cmd!(noundo "file.export_session_text", "Session Info as Text...", ["File", "Export"], None, "{path?}", always, |e, p| {
let text = crate::inspect::session_text(e);
if let Some(path) = str_param(p, "path") {
std::fs::write(path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(path, text.as_bytes())?;
}
Ok(json!({"text": text}))
}),
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], None, "{dir, clips?, format?: wav}", always, |e, p| {
cmd!(noundo "file.export_clips", "Export Clips as Files...", [], Some("Cmd+Shift+K"), "{dir, clips?, format?: wav}", always, |e, p| {
let dir = str_param(p, "dir").ok_or_else(|| bad("file.export_clips", "`dir` required"))?.to_string();
let ids = clip_ids_param(e, p);
if ids.is_empty() {
return Err(bad("file.export_clips", "no clips selected"));
}
let n = crate::io::export_clips(e, &ids, &dir)?;
Ok(json!({"written": n}))
Ok(json!({"written": n, "dir": dir}))
}),
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?}", has_selection, |e, p| {
cmd!(noundo "file.export_selected_tracks_as_session", "Selected Tracks as New Session...", ["File", "Export"], None, "{path, tracks?, replace?: false}", has_selection, |e, p| {
let path = str_param(p, "path").ok_or_else(|| bad("file.export_selected_tracks_as_session", "`path` required"))?.to_string();
refuse_replacing(e, "file.export_selected_tracks_as_session", &crate::io::session_file(&path), p)?;
let keep = tracks_required(e, "file.export_selected_tracks_as_session", p)?;
let mut s = e.session().clone();
s.tracks.retain(|t| keep.contains(&t.id) || t.kind == soundcraft_model::TrackKind::Master);
@@ -180,6 +192,14 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
/// Writing a session over an existing file needs `replace: true` (see `io::replaces`).
fn refuse_replacing(e: &Engine, id: &str, file: &std::path::Path, p: &Value) -> Result<()> {
if crate::io::replaces(e, id, file) && !bool_or(p, "replace", false) {
return Err(bad(id, format!("{} already exists: pass `replace: true` to replace it", file.display())));
}
Ok(())
}
fn new_session(e: &mut Engine, p: &Value) -> Result<Value> {
let name = str_param(p, "name").unwrap_or("Untitled").to_string();
let sr =
@@ -234,6 +254,17 @@ fn import_audio_target(e: &Engine, p: &Value) -> Result<Option<TrackId>> {
selected.map(Some).ok_or_else(|| bad("file.import_audio", "`new_tracks: false` needs `track` or a selected audio track"))
}
/// `source` omitted or `main` is the main mix. Any other name is a bus, and an unknown bus is an error.
fn bounce_bus(e: &Engine, p: &Value) -> Result<Option<soundcraft_model::BusId>> {
let Some(name) = str_param(p, "source").map(str::trim).filter(|s| !s.is_empty()) else {
return Ok(None);
};
if name.eq_ignore_ascii_case("main") {
return Ok(None);
}
e.session().bus_by_name(name).map(|b| Some(b.id)).ok_or_else(|| bad("file.bounce_mix", format!("unknown bus `{name}`")))
}
/// `fold_down: "stereo"` bounces an ITU stereo fold-down of a surround mix.
fn fold_down_param(p: &Value, id: &str) -> Result<bool> {
match str_param(p, "fold_down").map(str::trim) {
@@ -260,8 +291,9 @@ fn bounce(e: &mut Engine, p: &Value) -> Result<Value> {
let opts = soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: bool_or(p, "dither", true), bwf: None };
let normalize = bool_or(p, "normalize", false);
let fold = fold_down_param(p, "file.bounce_mix")?;
let (bytes, stats) = crate::io::bounce_bytes_with(e, r, &opts, normalize, fold)?;
std::fs::write(&path, &bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let bus = bounce_bus(e, p)?;
let (bytes, stats) = crate::io::bounce_source(e, r, &opts, normalize, fold, bus)?;
crate::io::write_file(&path, &bytes)?;
Ok(
json!({"path": path, "bytes": bytes.len(), "seconds": e.session().sample_rate.seconds(r.len()), "peak_db": stats.0, "lufs": stats.1, "true_peak_db": stats.2}),
)
@@ -280,7 +312,7 @@ fn write_score(e: &mut Engine, p: &Value, id: &str, svg: bool) -> Result<Value>
Some(p) => p.to_string(),
None => std::env::temp_dir().join(format!("{}.{ext}", crate::io::sanitize_name(&setup.title))).to_string_lossy().to_string(),
};
std::fs::write(&path, &text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, text.as_bytes())?;
let measures = parts.iter().map(|p| p.measures.len()).max().unwrap_or(0);
Ok(json!({"path": path, "parts": parts.len(), "measures": measures}))
}
@@ -365,6 +397,53 @@ mod tests {
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
#[test]
fn bounce_source_renders_the_named_bus() {
let dir = std::env::temp_dir().join(format!("sc-bounce-source-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = tone_wav(&dir, "tone.wav");
let mut e = Engine::default();
e.execute("file.import_audio", &json!({"path": wav})).unwrap();
let stem = e.session_mut().add_bus("Stem", ChannelFormat::Stereo);
e.session_mut().add_bus("Empty", ChannelFormat::Stereo);
let track = e.session().tracks[0].id;
{
let tr = e.session_mut().track_mut(track).unwrap();
let mut send = soundcraft_model::SendSlot::new(soundcraft_model::Route::Bus(stem));
send.level_db = -12.0;
tr.mixer.sends[0] = Some(send);
}
let bounce = |e: &mut Engine, name: &str, source: Option<&str>| {
let path = dir.join(name);
let mut params = json!({"path": path.to_string_lossy(), "start": 0, "end": 4800, "bit_depth": "32f", "dither": false});
if let Some(source) = source {
params["source"] = json!(source);
}
let value = e.execute("file.bounce_mix", &params).unwrap();
let peak = value.get("peak_db").and_then(serde_json::Value::as_f64).unwrap_or(0.0);
(std::fs::read(&path).unwrap(), peak)
};
let (main_bytes, main_peak) = bounce(&mut e, "main.wav", None);
let (named_main, named_peak) = bounce(&mut e, "named-main.wav", Some("main"));
let (stem_bytes, stem_peak) = bounce(&mut e, "stem.wav", Some("STEM"));
let (empty_bytes, empty_peak) = bounce(&mut e, "empty.wav", Some("Empty"));
assert_eq!(main_bytes, named_main);
assert!((main_peak - named_peak).abs() < 1e-4, "{main_peak} vs {named_peak}");
assert!(main_peak > -20.0, "{main_peak}");
assert_ne!(stem_bytes, main_bytes);
assert!(main_peak - stem_peak > 6.0 && main_peak - stem_peak < 18.0, "main {main_peak} stem {stem_peak}");
assert_ne!(empty_bytes, main_bytes);
assert!(empty_peak < -100.0, "{empty_peak}");
let missing = dir.join("missing.wav");
let err = e
.execute("file.bounce_mix", &json!({"path": missing.to_string_lossy(), "source": "NoSuchBus", "start": 0, "end": 4800, "dither": false}))
.unwrap_err();
assert!(err.to_string().contains("unknown bus"), "{err}");
assert!(!missing.exists());
assert!(std::fs::remove_dir_all(&dir).is_ok());
}
fn remove_media(dir: &std::path::Path) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = entry.path();
@@ -380,6 +459,7 @@ mod tests {
fn session_open_reports_missing_media() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-open-missing-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Gone.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": path})).unwrap();
let mut ok = Engine::default();
@@ -391,6 +471,16 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_a_session_without_audio_creates_its_folder() {
let dir = std::env::temp_dir().join(format!("soundcraft-new-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song").join("Song.scraft");
Engine::default().execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
assert!(path.exists());
let _ = std::fs::remove_dir_all(&dir);
}
fn tone_engine() -> (Engine, u64) {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Tone", "format": "mono"})).unwrap();
@@ -428,4 +518,154 @@ mod tests {
assert_eq!(wavs, vec!["Take-2.wav".to_string(), "Take.wav".to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn exports_the_selected_clips_and_refuses_an_empty_selection() {
let mut e = crate::demo::demo_engine();
let dir = std::env::temp_dir().join(format!("soundcraft-clips-{}", std::process::id()));
let p = json!({"dir": dir.to_string_lossy()});
e.execute("edit.select_none", &json!({})).unwrap();
assert!(e.execute("file.export_clips", &p).is_err());
e.execute("edit.select", &json!({"tracks": ["Kick"], "start": {"seconds": 10.0}, "end": {"seconds": 11.0}})).unwrap();
let r = e.execute("file.export_clips", &p).unwrap();
let files = std::fs::read_dir(&dir).map_or(0, Iterator::count);
let _ = std::fs::remove_dir_all(&dir);
assert!(files >= 1 && r["written"] == files, "{r}, {files} files");
}
fn midi_engine() -> Engine {
use soundcraft_midi::{CtrlEvent, CtrlKind, Note, Sequence};
use soundcraft_model::{ChannelFormat, Clip, TrackKind};
let mut s = Session::default();
let keys = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Keys"));
let drums = s.add_track(TrackKind::Midi, ChannelFormat::Mono, Some("Drums"));
let _audio = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Room"));
let mut keys_seq = Sequence::default();
keys_seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 480 });
keys_seq.ctrls.push(CtrlEvent { tick: 10, channel: 0, kind: CtrlKind::Cc(7), value: 90 });
let mut drum_seq = Sequence::default();
drum_seq.notes.push(Note { pitch: 36, velocity: 110, release_velocity: 64, channel: 9, start: 0, length: 120 });
drum_seq.ctrls.push(CtrlEvent { tick: 20, channel: 9, kind: CtrlKind::Cc(11), value: 40 });
let a = s.new_clip_id();
let b = s.new_clip_id();
crate::edit::place_clip(&mut s, keys, Clip::midi(a, "Keys", 0, 48_000, keys_seq));
crate::edit::place_clip(&mut s, drums, Clip::midi(b, "Drums", 0, 48_000, drum_seq));
Engine::new(s)
}
fn cc_numbers(bytes: &[u8]) -> Vec<(String, u8, i32)> {
let smf = soundcraft_midi::read_smf(bytes).unwrap();
let mut out = Vec::new();
for t in &smf.tracks {
for c in &t.sequence.ctrls {
if let soundcraft_midi::CtrlKind::Cc(n) = c.kind {
out.push((t.name.clone(), n, c.value));
}
}
}
out
}
#[test]
fn midi_export_keeps_controllers_and_honours_tracks() {
let mut e = midi_engine();
let dir = std::env::temp_dir().join(format!("sc-midi-export-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let all = dir.join("all.mid");
e.execute("file.export_midi", &json!({"path": all.to_string_lossy()})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&all).unwrap());
assert!(ctrls.iter().any(|(n, cc, v)| n == "Keys" && *cc == 7 && *v == 90), "{ctrls:?}");
assert!(ctrls.iter().any(|(n, cc, v)| n == "Drums" && *cc == 11 && *v == 40), "{ctrls:?}");
assert!(ctrls.iter().all(|(n, _, _)| n != "Room"));
let keys = dir.join("keys.mid");
e.execute("file.export_midi", &json!({"path": keys.to_string_lossy(), "tracks": ["Keys"]})).unwrap();
let ctrls = cc_numbers(&std::fs::read(&keys).unwrap());
assert_eq!(ctrls.len(), 1, "{ctrls:?}");
assert_eq!(ctrls[0].0, "Keys");
let err = e.execute("file.export_midi", &json!({"path": dir.join("nope.mid").to_string_lossy(), "tracks": ["Room"]})).unwrap_err();
assert!(err.to_string().contains("none of the requested tracks hold MIDI"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_surround_creates_a_matching_track() {
let channels: Vec<Vec<f32>> = (0..6).map(|ch| vec![0.1 * (ch as f32 + 1.0); 32]).collect();
let buf = AudioBuffer { sample_rate: 48_000, channels };
let bytes = soundcraft_audio_io::encode(&buf, &EncodeOptions::default()).unwrap();
let mut e = Engine::default();
let v = crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, None, 0).unwrap();
assert_eq!(v["channels"], 6);
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
let src = e.session().sources[0].id;
let audio = e.session().pool.get(src).unwrap();
assert_eq!(audio.buffer.channels.len(), 6);
assert!((audio.buffer.channels[5][0] - 0.6).abs() < 1e-3);
let mut e = Engine::default();
e.execute("track.new", &json!({"name": "Bed", "format": "5.1"})).unwrap();
let id = e.session().tracks[0].id;
crate::io::import_audio_bytes(&mut e, "bed.wav", &bytes, None, Some(id), 0).unwrap();
assert_eq!(e.session().tracks.len(), 1);
assert_eq!(e.session().tracks[0].format, ChannelFormat::Surround51);
assert_eq!(e.session().tracks[0].clips().len(), 1);
}
#[test]
fn repeated_consolidate_keeps_distinct_audio_files() {
let (mut e, track) = tone_engine();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let cid = e.session().tracks[0].clips()[0].id;
e.execute("clip.gain", &json!({"clip": cid.0, "db": -24.0})).unwrap();
e.execute("edit.consolidate", &json!({"track": track, "start": 0, "end": 4800})).unwrap();
let consolidated: Vec<_> = e.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| s.path.clone()).collect();
assert_eq!(consolidated.len(), 2, "{consolidated:?}");
assert_ne!(consolidated[0], consolidated[1]);
let dir = std::env::temp_dir().join(format!("sc-consolidate-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Tone.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
let mut opened = Engine::default();
opened.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let files: Vec<_> = opened.session().sources.iter().filter(|s| s.name.contains("consolidated")).map(|s| dir.join(&s.path)).collect();
assert_eq!(files.len(), 2, "{:?}", opened.session().sources);
let a = std::fs::read(&files[0]).unwrap();
let b = std::fs::read(&files[1]).unwrap();
assert_ne!(a, b, "the second consolidation replaced the first file");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_copy_leaves_the_open_session_alone() {
let (mut e, _) = tone_engine();
let dir = std::env::temp_dir().join(format!("sc-save-copy-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let original = dir.join("Original.scraft");
e.execute("session.save_as", &json!({"path": original.to_string_lossy()})).unwrap();
e.execute("track.new", &json!({"count": 1, "name": "Extra", "format": "mono"})).unwrap();
assert!(e.is_dirty());
let copy_dir = dir.join("copy");
std::fs::create_dir_all(&copy_dir).unwrap();
let copy = copy_dir.join("Copy.scraft");
e.execute("session.save_copy", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(e.path.as_deref(), Some(original.to_string_lossy().as_ref()));
assert_eq!(e.session().name, "Original");
assert!(e.is_dirty());
assert_eq!(e.session().tracks.len(), 2);
let mut from_original = Engine::default();
from_original.execute("session.open", &json!({"path": original.to_string_lossy()})).unwrap();
assert_eq!(from_original.session().tracks.len(), 1);
let mut from_copy = Engine::default();
from_copy.execute("session.open", &json!({"path": copy.to_string_lossy()})).unwrap();
assert_eq!(from_copy.session().tracks.len(), 2);
assert!(from_copy.session().track_by_name("Extra").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
+52 -9
View File
@@ -8,15 +8,26 @@ use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
pub fn specs() -> Vec<CommandSpec> {
vec![cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
)]
vec![
cmd!(
"mix.instrument",
"Set Instrument",
[],
None,
"{track?, plugin: id} — an instrument track's instrument: built-in, `clap:`, `vst3:` or `au:` id",
has_selection,
set_instrument
),
cmd!(
"mix.instrument_param",
"Set Instrument Parameter",
[],
None,
"{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",
has_selection,
set_instrument_param
),
]
}
fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -39,6 +50,20 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {
Ok(json!({"track": t, "plugin": info.id}))
}
fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.instrument_param";
let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();
let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;
// Hosted instruments keep their parameters in their own state.
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;
let pi = info.param(&param).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;
let v = pi.clamp(value);
ins.params.insert(param.clone(), v);
Ok(json!({"track": t, "param": param, "value": v}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
@@ -68,6 +93,24 @@ mod tests {
assert_eq!(instrument_of(&e, t).as_deref(), Some("subtractive_synth"));
}
#[test]
fn sets_a_built_in_instruments_parameters() {
let mut e = Engine::default();
let t = track(&mut e, "instrument");
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 5000})).unwrap();
assert_eq!(r["value"], 5000.0);
let r = e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 1.0e9})).unwrap();
assert_eq!(r["value"], 20000.0, "clamped");
let v = e.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get("cutoff").copied());
assert_eq!(v, Some(20000.0));
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "nope", "value": 1})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff"})).is_err());
assert!(e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": "loud"})).is_err());
let audio = track(&mut e, "audio");
assert!(e.execute("mix.instrument_param", &json!({"track": audio.0, "param": "cutoff", "value": 1})).is_err());
e.execute("edit.undo", &json!({})).unwrap();
}
#[test]
fn refuses_effects_unknown_plugins_and_other_track_kinds() {
let mut e = Engine::default();
+222 -13
View File
@@ -105,7 +105,15 @@ pub fn specs() -> Vec<CommandSpec> {
insert_params
),
cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move),
cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send),
cmd!(
"mix.send",
"Assign Send",
[],
None,
"{track?, slot?: 0..9|a..j, bus: name, level_db?: 0 (kept when re-targeting), pre_fader?: false}",
has_selection,
send
),
cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| {
let (t, slot) = track_slot(e, p, "mix.send_remove", SEND_SLOTS)?;
if let Some(tr) = e.session_mut().track_mut(t)
@@ -390,15 +398,17 @@ fn send(e: &mut Engine, p: &Value) -> Result<Value> {
let bus = str_param(p, "bus").ok_or_else(|| bad("mix.send", "`bus` required"))?.to_string();
let t = tracks_required(e, "mix.send", p)?.first().copied().ok_or_else(|| bad("mix.send", "no track"))?;
let explicit = slot_of(p, "slot", SEND_SLOTS);
let level = f32_or(p, "level_db", 0.0).clamp(-144.0, 12.0);
let pre = bool_or(p, "pre_fader", false);
let s = e.session_mut();
let bid = s.add_bus(&bus, soundcraft_model::ChannelFormat::Stereo);
let tr = s.track_mut(t).ok_or_else(|| bad("mix.send", "no track"))?;
let slot = explicit.or_else(|| tr.mixer.sends.iter().position(Option::is_none)).ok_or_else(|| bad("mix.send", "all send slots are in use"))?;
let mut sl = SendSlot::new(soundcraft_model::Route::Bus(bid));
sl.level_db = level;
sl.pre_fader = pre;
// Re-targeting an existing send keeps its level, pan, mute and pre/post setting.
let mut sl = match tr.mixer.sends.get(slot).cloned().flatten() {
Some(old) => SendSlot { target: soundcraft_model::Route::Bus(bid), ..old },
None => SendSlot::new(soundcraft_model::Route::Bus(bid)),
};
sl.level_db = f32_or(p, "level_db", sl.level_db).clamp(-144.0, 12.0);
sl.pre_fader = bool_or(p, "pre_fader", sl.pre_fader);
if let Some(x) = tr.mixer.sends.get_mut(slot) {
*x = Some(sl);
}
@@ -534,25 +544,95 @@ fn write_current(e: &mut Engine, p: &Value, all: bool) -> Result<Value> {
Ok(json!({}))
}
/// Clip-gain envelope value in dB. An empty envelope contributes nothing, and the last point holds.
fn env_db(env: &[(i64, f32)], at: i64) -> f32 {
let mut prev: Option<(i64, f32)> = None;
for &(t, v) in env {
if t >= at {
return match prev {
Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
_ => v,
};
}
prev = Some((t, v));
}
prev.map_or(0.0, |(_, v)| v)
}
fn vol_to_clip_gain(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "automation.volume_to_clip_gain", p)?;
let r = range_param(e, "automation.volume_to_clip_gain", p)?;
let s = e.session_mut();
for t in &tracks {
let Some(tr) = s.track_mut(*t) else { continue };
let lane = tr.lane(&AutoParam::Volume).cloned();
let Some(lane) = tr.lane(&AutoParam::Volume).cloned().filter(|l| !l.points.is_empty()) else { continue };
let base = tr.mixer.volume_db;
let Some(lane) = lane else { continue };
let delta = |at: i64| lane.value_at(at, base) - base;
if let Some(pl) = tr.playlist_mut() {
for c in pl.clips.iter_mut().filter(|c| c.range().overlaps(&r)) {
let mut env: Vec<(i64, f32)> =
lane.points.iter().filter(|pt| c.range().contains(pt.at)).map(|pt| (pt.at - c.start, pt.value - base)).collect();
env.insert(0, (0, lane.value_at(c.start, base) - base));
env.push((c.length, lane.value_at(c.end(), base) - base));
let sel0 = r.start.max(c.start);
let sel1 = r.end.min(c.end());
if sel1 <= sel0 {
continue;
}
let rel = |at: i64| at - c.start;
let rel0 = rel(sel0);
let rel1 = rel(sel1);
// Later writes win, so the selection anchors replace any point they land on.
let mut pts: Vec<(i64, f32)> =
c.gain_env.iter().copied().filter(|&(at, _)| at < rel0.saturating_sub(1) || (sel1 < c.end() && at > rel1)).collect();
if rel0 > 0 {
pts.push((rel0 - 1, env_db(&c.gain_env, rel0 - 1)));
}
pts.push((rel0, env_db(&c.gain_env, rel0) + delta(sel0)));
for pt in lane.points.iter().filter(|pt| pt.at > sel0 && pt.at < sel1) {
let at = rel(pt.at);
pts.push((at, env_db(&c.gain_env, at) + delta(pt.at)));
}
// The clip's own gain points inside the selection keep their shape, offset by the lane.
for &(at, _) in c.gain_env.iter().filter(|&&(at, _)| at > rel0 && at < rel1) {
pts.push((at, env_db(&c.gain_env, at) + delta(c.start.saturating_add(at))));
}
if sel1 - 1 > sel0 {
let at = rel(sel1 - 1);
pts.push((at, env_db(&c.gain_env, at) + delta(sel1 - 1)));
}
if sel1 < c.end() {
pts.push((rel1, env_db(&c.gain_env, rel1)));
}
pts.sort_by_key(|p| p.0);
let mut env = Vec::new();
for p in pts {
if env.last().is_some_and(|q: &(i64, f32)| q.0 == p.0) {
env.pop();
}
env.push(p);
}
c.gain_env = env;
}
}
tr.lane_mut(&AutoParam::Volume).clear_range(r.start, r.end);
// Clearing the lane is only safe when no audio plays outside the selection; otherwise the
// held edge values outside would fall back to the fader level.
let clips_inside = tr.clips().iter().all(|c| c.start >= r.start && c.end() <= r.end);
let covers_all = clips_inside && lane.points.iter().all(|pt| pt.at >= r.start && pt.at < r.end);
let lane_m = tr.lane_mut(&AutoParam::Volume);
if covers_all {
lane_m.clear_range(r.start, r.end);
} else {
// Hold the fader across the selection and anchor the samples on either side, so the
// curve outside does not interpolate through the gap or keep the old level inside.
let before = (r.start > 0).then(|| lane.value_at(r.start - 1, base));
let after = lane.value_at(r.end, base);
lane_m.clear_range(r.start, r.end);
if let Some(v) = before {
lane_m.set_point(r.start - 1, v);
}
lane_m.set_point(r.start, base);
if r.end - 1 > r.start {
lane_m.set_point(r.end - 1, base);
}
lane_m.set_point(r.end, after);
}
}
Ok(json!({}))
}
@@ -606,3 +686,132 @@ fn copy_to_send(e: &mut Engine, p: &Value) -> Result<Value> {
}
Ok(json!({}))
}
#[cfg(test)]
mod tests {
use super::*;
use soundcraft_audio_io::AudioBuffer;
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
use soundcraft_time::Range;
use std::sync::Arc;
fn rms_db(before: &[f32], after: &[f32]) -> f32 {
let energy = |xs: &[f32]| xs.iter().map(|x| x * x).sum::<f32>() / xs.len().max(1) as f32;
10.0 * (energy(after).max(1e-20) / energy(before).max(1e-20)).log10()
}
fn tone_session(points: &[(i64, f32)]) -> (Engine, TrackId) {
let mut s = Session::default();
let n = 8192;
let tone: Vec<f32> = (0..n).map(|i| (i as f32 * 0.07).sin() * 0.25).collect();
s.pool.insert(SourceId(910), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![tone] })));
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, Some("Tone"));
let id = s.new_clip_id();
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "tone", SourceId(910), 0, 0, n));
s.track_mut(t).unwrap().mixer.volume_db = -3.0;
for &(at, value) in points {
s.track_mut(t).unwrap().lane_mut(&AutoParam::Volume).set_point(at, value);
}
s.edit.selected_tracks = vec![t];
s.edit.selection = Range::new(2048, 6144);
(Engine::new(s), t)
}
fn rendered(s: &Session) -> Vec<f32> {
soundcraft_mix::render_range(s, Range::new(0, 8192), 1024).into_iter().next().unwrap_or_default()
}
fn convert(e: &mut Engine, t: TrackId, start: i64, end: i64) {
e.session_mut().edit.selection = Range::new(start, end);
e.execute("automation.volume_to_clip_gain", &json!({"track": t.0, "start": start, "end": end})).unwrap();
}
#[test]
fn partial_volume_to_clip_gain_keeps_each_region() {
let points = [(0, -6.0), (2048, -6.0), (4096, -6.0), (6144, -6.0), (8192, -6.0)];
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (2048, 6144), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "constant {start}..{end} changed by {delta} dB");
}
let tr = e.session().track(t).unwrap();
assert!((tr.mixer.volume_db + 3.0).abs() < 1e-5);
let lane = tr.lane(&AutoParam::Volume).unwrap();
assert!((lane.value_at(100, 0.0) + 6.0).abs() < 1e-3);
assert!((lane.value_at(3000, 0.0) + 3.0).abs() < 1e-3);
assert!((lane.value_at(7000, 0.0) + 6.0).abs() < 1e-3);
let (mut e, t) = tone_session(&[]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-5, "no lane changed a sample by {diff}");
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 0, 8193);
let after = rendered(e.session());
let diff = before.iter().zip(&after).map(|(a, b)| (a - b).abs()).fold(0.0f32, f32::max);
assert!(diff < 1e-4, "full selection changed a sample by {diff}");
assert!(e.session().track(t).unwrap().lane(&AutoParam::Volume).is_none_or(|l| l.points.is_empty()));
for points in [
[(0, -12.0), (2048, -9.0), (4096, -6.0), (6144, -3.0), (8192, 0.0)],
[(0, -12.0), (2048, -6.0), (4096, -12.0), (6144, -6.0), (8192, -12.0)],
] {
let (mut e, t) = tone_session(&points);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2048), (6144, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.02, "outside {start}..{end} changed by {delta} dB for {points:?}");
}
let delta = rms_db(&before[2048..6144], &after[2048..6144]);
assert!(delta.abs() < 0.15, "inside changed by {delta} dB for {points:?}");
}
}
#[test]
fn lane_points_all_inside_the_selection_keep_the_outside_level() {
let (mut e, t) = tone_session(&[(2048, -9.0), (6143, -9.0)]);
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(0, 2000), (2048, 6144), (6200, 8192)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.05, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn clip_gain_points_inside_the_selection_are_kept() {
let (mut e, t) = tone_session(&[(0, -6.0), (8192, -6.0)]);
let clip = e.session().track(t).unwrap().clips()[0].id;
e.session_mut().find_clip_mut(clip).unwrap().gain_env = vec![(3000, 0.0), (4000, -20.0), (5000, 0.0)];
let before = rendered(e.session());
convert(&mut e, t, 2048, 6144);
let after = rendered(e.session());
for (start, end) in [(2048, 3000), (3500, 4500), (5000, 6144)] {
let delta = rms_db(&before[start..end], &after[start..end]);
assert!(delta.abs() < 0.2, "{start}..{end} changed by {delta} dB");
}
}
#[test]
fn retargeting_a_send_keeps_its_settings() {
let mut e = Engine::default();
e.execute("track.new", &json!({"count": 1, "name": "Gtr", "format": "mono"})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Verb", "level_db": -9.0, "pre_fader": true})).unwrap();
e.execute("mix.send_level", &json!({"track": "Gtr", "slot": 0, "pan": 0.5, "mute": true})).unwrap();
e.execute("mix.send", &json!({"track": "Gtr", "slot": 0, "bus": "Delay"})).unwrap();
let sl = e.session().tracks[0].mixer.sends[0].clone().unwrap();
let delay = e.session().busses.iter().find(|b| b.name == "Delay").unwrap().id;
assert_eq!(sl.target, soundcraft_model::Route::Bus(delay));
assert_eq!((sl.level_db, sl.pan, sl.mute, sl.pre_fader), (-9.0, 0.5, true, true));
}
}
+13 -9
View File
@@ -10,6 +10,7 @@ mod audio_midi;
mod audiosuite;
mod clap;
mod clip;
mod clip_list;
mod clip_more;
mod edit;
mod edit_more;
@@ -24,6 +25,7 @@ mod more_util;
mod options;
mod plugin_state;
mod query;
mod sampler;
mod setup_more;
mod surround;
mod track;
@@ -51,6 +53,8 @@ pub struct CommandSpec {
pub run: Run,
pub journal: bool,
pub undoable: bool,
/// Only touches `edit.zoom`, through `Engine::zoom_mut`.
pub view: bool,
}
/// Serializable description of a command.
@@ -80,18 +84,16 @@ impl CommandSpec {
}
}
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo` variants.
/// `cmd!(id, label, [menu…], shortcut, params, enabled, run)`; `query`/`noundo`/`view` variants.
#[macro_export]
macro_rules! cmd {
(query $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: false, undoable: false }
};
(noundo $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: false }
};
($id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: true, undoable: true }
(query $($t:tt)*) => { $crate::cmd!(@spec false, false, false; $($t)*) };
(noundo $($t:tt)*) => { $crate::cmd!(@spec true, false, false; $($t)*) };
(view $($t:tt)*) => { $crate::cmd!(@spec false, false, true; $($t)*) };
(@spec $journal:literal, $undoable:literal, $view:literal; $id:literal, $label:literal, [$($m:literal),*], $sc:expr, $params:literal, $en:expr, $run:expr) => {
$crate::cmd::CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: $sc, params: $params, enabled: $en, run: $run, journal: $journal, undoable: $undoable, view: $view }
};
($id:literal, $($t:tt)*) => { $crate::cmd!(@spec true, true, false; $id, $($t)*) };
}
pub fn command_specs() -> &'static [CommandSpec] {
@@ -116,6 +118,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(view_more::specs());
v.extend(track_more::specs());
v.extend(clip_more::specs());
v.extend(clip_list::specs());
v.extend(event_more::specs());
v.extend(setup_more::specs());
v.extend(clap::specs());
@@ -123,6 +126,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(surround::specs());
v.extend(plugin_state::specs());
v.extend(instrument::specs());
v.extend(sampler::specs());
v.extend(video::specs());
v
})
+1
View File
@@ -17,6 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
run: insert_state,
journal: false,
undoable: false,
view: false,
}]
}
+321
View File
@@ -0,0 +1,321 @@
//! The built-in Sampler's sample: `mix.sampler_load` decodes an audio file (WAV, AIFF, FLAC, MP3,
//! OGG, anything `file.import_audio` reads) into a session source, resampled to the session rate,
//! and makes it the sample an instrument track's Sampler plays; `mix.sampler_clear` removes it.
//! The source is saved in `Audio Files/` with the session like any other audio, so a session that
//! uses a sample reopens with it. Sampler parameters (root note, key range, ADSR, loop…) are set
//! with `mix.instrument_param`, or all at once with `params` here.
use super::*;
use crate::cmd;
use serde_json::json;
use soundcraft_model::{Insert, TrackKind};
use std::path::Path;
const SAMPLER: &str = "sampler";
/// Largest sample accepted (frames × channels): ten minutes of 192 kHz stereo.
const MAX_SAMPLE_VALUES: usize = 192_000 * 600 * 2;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"mix.sampler_load",
"Load Sampler Sample",
[],
None,
"{track?, path, params?: {root_note, key_low, key_high, attack, loop_mode, …}}: an instrument track plays the file through the built-in Sampler (made its instrument if it is not already) → {track, source, name, frames, channels, sample_rate}",
has_selection,
load
),
cmd!("mix.sampler_clear", "Clear Sampler Sample", [], None, "{track?}: the track's Sampler stops playing its sample", has_selection, clear),
]
}
/// The instrument track a sampler command acts on.
fn instrument_track(e: &Engine, id: &str, p: &Value) -> Result<TrackId> {
let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;
if e.session().track(t).map(|tr| tr.kind) != Some(TrackKind::Instrument) {
return Err(bad(id, "only instrument tracks have a sampler"));
}
Ok(t)
}
fn load(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_load";
let path = str_param(p, "path").ok_or_else(|| bad(id, "`path` required"))?.to_string();
let t = instrument_track(e, id, p)?;
let info = soundcraft_dsp::plugin_info(SAMPLER).ok_or_else(|| bad(id, "the sampler is not available"))?;
// Validate the parameters before touching the session.
let mut values = Vec::new();
if let Some(v) = p.get("params") {
let obj = v.as_object().ok_or_else(|| bad(id, "`params` must be an object"))?;
for (k, v) in obj {
let pi = info.param(k).ok_or_else(|| bad(id, format!("the sampler has no parameter `{k}`")))?;
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(id, format!("`{k}` must be a number")))?;
values.push((k.clone(), pi.clamp(x as f32)));
}
}
let bytes = std::fs::read(&path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
let name = Path::new(&path).file_name().and_then(|n| n.to_str()).unwrap_or("Sample").to_string();
let ext = Path::new(&name).extension().and_then(|x| x.to_str()).map(str::to_ascii_lowercase);
let (format, buf) = soundcraft_audio_io::decode(&bytes, ext.as_deref()).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
if buf.frames() == 0 || buf.num_channels() == 0 {
return Err(EngineError::Io(format!("{name}: no audio")));
}
if buf.frames().saturating_mul(buf.num_channels()) > MAX_SAMPLE_VALUES {
return Err(EngineError::Io(format!("{name}: too long for a sample (ten minutes of 192 kHz stereo at most)")));
}
let channels = buf.num_channels();
let s = e.session_mut();
let src = crate::io::add_source(s, &name, buf, Some(&path), format.format);
let (frames, rate, stem) = s.source(src).map_or((0, 0, String::new()), |x| (x.frames, x.sample_rate, x.name.clone()));
let tr = s.track_mut(t).ok_or_else(|| bad(id, "no track"))?;
let keep = tr.instrument.as_ref().is_some_and(|i| i.plugin == SAMPLER);
if !keep {
let mut ins = Insert::new(SAMPLER);
for pi in info.params {
ins.params.insert(pi.id.to_string(), pi.default);
}
tr.instrument = Some(ins);
}
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = Some(src);
for (k, v) in values {
ins.params.insert(k, v);
}
}
Ok(json!({"track": t, "source": src, "name": stem, "frames": frames, "channels": channels, "sample_rate": rate}))
}
fn clear(e: &mut Engine, p: &Value) -> Result<Value> {
let id = "mix.sampler_clear";
let t = instrument_track(e, id, p)?;
let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).filter(|i| i.plugin == SAMPLER);
let ins = ins.ok_or_else(|| bad(id, "the track's instrument is not the sampler"))?;
let had = ins.sample.take();
Ok(json!({"track": t, "cleared": had}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_audio_io::{AudioBuffer, BitDepth, EncodeOptions, FileFormat};
use soundcraft_midi::{Note, Sequence};
use soundcraft_model::{Clip, TrackId};
use soundcraft_time::{Range, TICKS_PER_QUARTER};
use std::path::PathBuf;
/// A scratch folder for one test.
fn dir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("sc-sampler-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Writes a generated tone (no recorded or third-party audio) as a 16-bit WAV.
fn tone_wav(path: &std::path::Path, freq: f32, rate: u32, secs: f32, channels: usize) {
let n = (rate as f32 * secs) as usize;
let one: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * freq * i as f32 / rate as f32).sin()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: vec![one; channels] };
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
}
fn instrument_track(e: &mut Engine) -> TrackId {
e.execute("track.new", &json!({"kind": "instrument", "format": "stereo"})).unwrap();
e.session().tracks.last().unwrap().id
}
/// One MIDI note (pitch, start in quarter notes, length in quarters) on the track at 120 bpm.
fn add_notes(e: &mut Engine, t: TrackId, notes: &[(u8, i64, i64)]) {
let q = TICKS_PER_QUARTER;
let mut seq = Sequence::default();
for &(pitch, start, len) in notes {
seq.notes.push(Note { pitch, velocity: 127, release_velocity: 64, channel: 0, start: start * q, length: len * q });
}
let s = e.session_mut();
let cid = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::midi(cid, "notes", 0, 48_000 * 8, seq));
}
fn render(e: &Engine, secs: i64) -> Vec<Vec<f32>> {
soundcraft_mix::render_range(e.session(), Range::new(0, 48_000 * secs), 512)
}
fn peak(x: &[f32]) -> f32 {
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
}
fn crossings(x: &[f32]) -> usize {
x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count()
}
#[test]
fn loads_a_sample_plays_it_and_undoes() {
let d = dir("play");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 44_100, 2.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1), (72, 2, 1)]);
assert!(peak(&render(&e, 2)[0]) > 0.0, "the default synth plays the notes first");
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert_eq!(r["frames"], 96_000, "resampled to the 48 kHz session: {r}");
assert_eq!(r["sample_rate"], 48_000);
assert_eq!(r["channels"], 1);
assert_eq!(r["name"], "tone");
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let out = render(&e, 2);
// Middle C plays the tone at its pitch for the first quarter (0.5 s), key 72 an octave up.
let first = &out[0][2400..21_600];
let second = &out[0][50_400..69_600];
assert!(peak(first) > 0.05, "the sample plays");
let (a, b) = (crossings(first), crossings(second));
assert!((a as f32 - 176.0).abs() <= 2.0, "440 Hz over 0.4 s: {a}");
assert!((b as f32 - 352.0).abs() <= 3.0, "an octave up: {b}");
assert!(peak(&out[0][36_000..48_000]) < 1e-3, "released between the notes");
e.execute("edit.undo", &json!({})).unwrap();
let back = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(back.plugin, "subtractive_synth");
assert!(back.sample.is_none());
assert!(e.session().sources.is_empty(), "undo removes the source too");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn loads_flac_and_aiff_too() {
let d = dir("formats");
let n = 4800;
let tone: Vec<f32> = (0..n).map(|i| 0.5 * (std::f32::consts::TAU * 440.0 * i as f32 / 96_000.0).sin()).collect();
let buf = AudioBuffer { sample_rate: 96_000, channels: vec![tone.clone(), tone] };
let mut e = Engine::default();
let t = instrument_track(&mut e);
for (ext, format) in [("flac", FileFormat::Flac), ("aif", FileFormat::Aiff)] {
let path = d.join(format!("tone.{ext}"));
let opts = EncodeOptions { format, bit_depth: BitDepth::Int24, dither: false, bwf: None };
std::fs::write(&path, soundcraft_audio_io::encode(&buf, &opts).unwrap()).unwrap();
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": path.to_string_lossy()})).unwrap();
assert_eq!(r["channels"], 2, "{ext}");
assert_eq!(r["frames"], 2400, "{ext}: 96 kHz resampled to the 48 kHz session");
}
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn instrument_params_reach_the_sampler() {
let d = dir("params");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 2.0, 2);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"key_low": 61}})).unwrap();
assert_eq!(peak(&render(&e, 1)[0]), 0.0, "key 60 is below the key range");
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "key_low", "value": 0})).unwrap();
e.execute("mix.instrument_param", &json!({"track": t.0, "param": "root_note", "value": 48})).unwrap();
let out = render(&e, 1);
assert!((crossings(&out[0][2400..21_600]) as f32 - 352.0).abs() <= 3.0, "root note 48: key 60 plays an octave up");
assert!(peak(&out[1]) > 0.05, "a stereo sample plays on both sides");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"nope": 1}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": "C3"}})).is_err());
assert!(e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": 3})).is_err());
assert_eq!(e.session().sources.len(), 1, "rejected loads add nothing");
// Reloading keeps the sampler's settings and swaps the sample.
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let ins = e.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.params.get("root_note"), Some(&48.0));
assert_eq!(ins.sample.map(|s| s.0), r["source"].as_u64());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn clear_silences_the_sampler() {
let d = dir("clear");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
assert!(peak(&render(&e, 1)[0]) > 0.05);
let r = e.execute("mix.sampler_clear", &json!({"track": t.0})).unwrap();
assert!(r["cleared"].is_u64());
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
e.execute("mix.instrument", &json!({"track": t.0, "plugin": "drum_synth"})).unwrap();
assert!(e.execute("mix.sampler_clear", &json!({"track": t.0})).is_err(), "not a sampler");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn a_sampled_session_saves_and_reopens_with_its_sample() {
let d = dir("save");
let wav = d.join("pluck.wav");
tone_wav(&wav, 330.0, 22_050, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(64, 0, 1)]);
e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy(), "params": {"root_note": 64, "loop_mode": 1}})).unwrap();
let before = render(&e, 1);
let path = d.join("song.scraft");
e.execute("session.save_as", &json!({"path": path.to_string_lossy()})).unwrap();
std::fs::remove_file(&wav).unwrap();
let mut e2 = Engine::default();
e2.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
let ins = e2.session().track(t).unwrap().instrument.clone().unwrap();
assert_eq!(ins.plugin, "sampler");
assert_eq!(ins.params.get("loop_mode"), Some(&1.0));
let after = render(&e2, 1);
assert!(peak(&after[0]) > 0.05, "the saved copy in Audio Files plays");
let diff = before[0].iter().zip(&after[0]).fold(0.0f32, |m, (a, b)| m.max((a - b).abs()));
assert!(diff < 1e-5, "same audio after reopening: {diff}");
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn refuses_bad_input() {
let d = dir("bad");
let junk = d.join("junk.wav");
std::fs::write(&junk, b"definitely not audio").unwrap();
let empty = d.join("empty.wav");
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: BitDepth::Int16, dither: false, bwf: None };
std::fs::write(&empty, soundcraft_audio_io::encode(&AudioBuffer { sample_rate: 48_000, channels: vec![vec![]] }, &opts).unwrap()).unwrap();
let wav = d.join("ok.wav");
tone_wav(&wav, 440.0, 48_000, 0.1, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
e.execute("track.new", &json!({"kind": "audio"})).unwrap();
let audio = e.session().tracks.last().unwrap().id;
let load = |e: &mut Engine, track: u64, path: &std::path::Path| {
e.execute("mix.sampler_load", &json!({"track": track, "path": path.to_string_lossy()}))
};
assert!(load(&mut e, t.0, &junk).is_err(), "not audio");
assert!(load(&mut e, t.0, &empty).is_err(), "no frames");
assert!(load(&mut e, t.0, &d.join("missing.wav")).is_err(), "missing file");
assert!(load(&mut e, audio.0, &wav).is_err(), "an audio track");
assert!(load(&mut e, 999_999, &wav).is_err(), "no such track");
assert!(e.execute("mix.sampler_load", &json!({"track": t.0})).is_err(), "no path");
assert!(e.execute("mix.sampler_clear", &json!({"track": audio.0})).is_err());
assert!(e.session().sources.is_empty());
assert_eq!(e.session().track(t).unwrap().instrument.as_ref().map(|i| i.plugin.as_str()), Some("subtractive_synth"));
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn missing_media_plays_silence() {
let d = dir("missing");
let wav = d.join("tone.wav");
tone_wav(&wav, 440.0, 48_000, 1.0, 1);
let mut e = Engine::default();
let t = instrument_track(&mut e);
add_notes(&mut e, t, &[(60, 0, 1)]);
let r = e.execute("mix.sampler_load", &json!({"track": t.0, "path": wav.to_string_lossy()})).unwrap();
let src = soundcraft_model::SourceId(r["source"].as_u64().unwrap());
e.session_mut().pool.remove(src);
assert_eq!(peak(&render(&e, 1)[0]), 0.0);
let _ = std::fs::remove_dir_all(d);
}
}
+28
View File
@@ -436,6 +436,8 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
let mut needed: Vec<SourceId> =
picked.iter().flat_map(|t| t.playlists.iter().flat_map(|pl| pl.clips.iter().filter_map(|c| c.source()))).collect();
// A Sampler's sample is session audio too.
needed.extend(picked.iter().filter_map(|t| t.instrument.as_ref().and_then(|i| i.sample)));
needed.sort_by_key(|x| x.0);
needed.dedup();
let sr = e.session().sample_rate.hz();
@@ -481,6 +483,10 @@ fn import_session_data(e: &mut Engine, p: &Value) -> Result<Value> {
}
}
}
// The donor's source ids mean nothing here: remap the sample, or drop it when it didn't come along.
if let Some(ins) = tr.instrument.as_mut() {
ins.sample = ins.sample.and_then(|sid| map.get(&sid.0).copied());
}
// Busses are matched by name (created when missing).
let remap = |s: &mut Session, r: &Route| -> Route {
match r {
@@ -603,4 +609,26 @@ mod tests {
assert!(e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Nope"]})).is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn import_session_data_brings_a_samplers_sample() {
let dir = std::env::temp_dir().join(format!("sc-import-sampler-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let wav = dir.join("hit.wav");
let buf = soundcraft_audio_io::AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 4_800]] };
std::fs::write(&wav, soundcraft_audio_io::encode(&buf, &soundcraft_audio_io::EncodeOptions::default()).unwrap()).unwrap();
let mut donor = Engine::default();
donor.execute("track.new", &json!({"count": 1, "name": "Pads", "kind": "instrument"})).unwrap();
donor.execute("mix.sampler_load", &json!({"track": "Pads", "path": wav.to_string_lossy()})).unwrap();
let path = dir.join("donor.scraft").to_string_lossy().into_owned();
crate::io::save_session(&mut donor, &path).unwrap();
let mut e = crate::demo::demo_engine();
e.execute("file.import_session_data", &json!({"path": path, "tracks": ["Pads"]})).unwrap();
let sample = e.session().track_by_name("Pads").and_then(|t| t.instrument.as_ref()).and_then(|i| i.sample).unwrap();
let src = e.session().source(sample).unwrap();
assert_eq!(src.name, "hit");
assert!(e.session().pool.get(sample).is_some());
let _ = std::fs::remove_dir_all(&dir);
}
}
+1 -1
View File
@@ -263,7 +263,7 @@ fn save_preset(e: &mut Engine, p: &Value) -> Result<Value> {
"comments": tr.comments,
});
let text = serde_json::to_string_pretty(&preset).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(&path, text).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
crate::io::write_file(&path, text.as_bytes())?;
Ok(json!({"path": path, "track": first}))
}
+98 -41
View File
@@ -7,54 +7,59 @@ use soundcraft_time::{GridValue, NoteValue, TimeFormat};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(noundo "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5)),
cmd!(noundo "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0)),
cmd!(noundo "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(0.25, 1_000_000.0);
e.session_mut().edit.zoom.samples_per_px = v;
cmd!(view "view.zoom_in", "Zoom In", [], Some("Cmd+]"), "{}", always, |e, _| zoom(e, 0.5, None)),
cmd!(view "view.zoom_out", "Zoom Out", [], Some("Cmd+["), "{}", always, |e, _| zoom(e, 2.0, None)),
cmd!(view "view.zoom_by", "Zoom By", [], None, "{factor: >1 zooms in, x?: px into the timeline to hold (else the insertion point if on screen)}", always, |e, p| {
let f = p.get("factor").and_then(Value::as_f64).filter(|f| *f > 0.0).ok_or_else(|| bad("view.zoom_by", "factor must be a positive number"))?;
zoom(e, 1.0 / f, p.get("x").and_then(Value::as_f64))
}),
cmd!(view "view.zoom_set", "Set Zoom", [], None, "{samples_per_px}", always, |e, p| {
let v = f64_or(p, "samples_per_px", 1024.0).clamp(MIN_SPP, MAX_SPP);
e.zoom_mut().samples_per_px = v;
Ok(json!({"samples_per_px": v}))
}),
cmd!(noundo "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
cmd!(view "view.zoom_preset", "Zoom Preset", [], Some("Cmd+Ctrl+1-5"), "{preset: 1..5, store?: bool}", always, |e, p| {
let i = (i64_or(p, "preset", 1).clamp(1, 5) - 1) as usize;
let s = e.session_mut();
let z = e.zoom_mut();
if bool_or(p, "store", false) {
if let Some(z) = s.edit.zoom.presets.get_mut(i) { *z = s.edit.zoom.samples_per_px }
} else if let Some(z) = s.edit.zoom.presets.get(i) {
s.edit.zoom.samples_per_px = *z;
if let Some(slot) = z.presets.get_mut(i) { *slot = z.samples_per_px }
} else if let Some(v) = z.presets.get(i) {
z.samples_per_px = v.clamp(MIN_SPP, MAX_SPP);
}
Ok(json!({"samples_per_px": s.edit.zoom.samples_per_px}))
Ok(json!({"samples_per_px": z.samples_per_px}))
}),
cmd!(noundo "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
cmd!(view "view.zoom_to_selection", "Zoom to Selection", [], Some("Alt+F"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session_mut();
let r = s.edit.selection;
let r = e.session().edit.selection;
if !r.is_empty() {
s.edit.zoom.samples_per_px = (r.len() as f64 / w).max(0.25);
s.edit.zoom.scroll = r.start;
let z = e.zoom_mut();
z.samples_per_px = (r.len() as f64 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = r.start;
}
Ok(json!({}))
}),
cmd!(noundo "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
cmd!(view "view.zoom_fit", "Fill Window With Session", [], Some("Alt+A"), "{width_px?: 1200}", always, |e, p| {
let w = f64_or(p, "width_px", 1200.0).max(100.0);
let s = e.session_mut();
let s = e.session();
let end = s.content_end().max(s.sample_rate.samples(30.0));
s.edit.zoom.samples_per_px = (end as f64 * 1.05 / w).max(0.25);
s.edit.zoom.scroll = 0;
let z = e.zoom_mut();
z.samples_per_px = (end as f64 * 1.05 / w).clamp(MIN_SPP, MAX_SPP);
z.scroll = 0;
Ok(json!({}))
}),
cmd!(noundo "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
cmd!(view "view.scroll", "Scroll Timeline", [], None, "{to?: position, by_px?: n}", always, |e, p| {
let to = position_param(e, "view.scroll", p, "to")?;
let by = f64_or(p, "by_px", 0.0);
let s = e.session_mut();
let spp = s.edit.zoom.samples_per_px;
s.edit.zoom.scroll = to.unwrap_or(s.edit.zoom.scroll + soundcraft_time::to_samples(by * spp)).max(0);
Ok(json!({"scroll": s.edit.zoom.scroll}))
let z = e.zoom_mut();
let by = soundcraft_time::to_samples(by * z.samples_per_px);
z.scroll = to.unwrap_or(z.scroll.saturating_add(by)).clamp(0, MAX_POSITION);
Ok(json!({"scroll": z.scroll}))
}),
cmd!(noundo "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let s = e.session_mut();
let z = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { s.edit.zoom.waveform_zoom * f32_or(p, "factor", 2.0) };
s.edit.zoom.waveform_zoom = if z.is_finite() { z.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": s.edit.zoom.waveform_zoom}))
cmd!(view "view.waveform_zoom", "Waveform Zoom", [], Some("Cmd+Alt+[ / ]"), "{factor?: 2 | value}", always, |e, p| {
let z = e.zoom_mut();
let w = if let Some(v) = p.get("value").and_then(Value::as_f64) { v as f32 } else { z.waveform_zoom * f32_or(p, "factor", 2.0) };
z.waveform_zoom = if w.is_finite() { w.clamp(0.25, 64.0) } else { 1.0 };
Ok(json!({"waveform_zoom": z.waveform_zoom}))
}),
cmd!(noundo "view.ruler", "Rulers", ["View", "Rulers"], None, "{ruler: bars_beats|min_secs|timecode|feet_frames|samples|tempo|meter|markers|key|chords, visible?: bool}", always, ruler),
cmd!(noundo "view.main_counter", "Main Counter", ["View", "Main Counter"], None, "{format: bars_beats|min_secs|timecode|feet_frames|samples}", always, |e, p| {
@@ -88,18 +93,19 @@ pub fn specs() -> Vec<CommandSpec> {
]
}
fn zoom(e: &mut Engine, f: f64) -> Result<Value> {
let s = e.session_mut();
let anchor = s.edit.selection.start;
let old = s.edit.zoom.samples_per_px;
let new = (old * f).clamp(0.25, 1_000_000.0);
// Keep the insertion point at the same screen x when it is visible.
let x = (anchor - s.edit.zoom.scroll) as f64 / old;
if (0.0..4000.0).contains(&x) {
s.edit.zoom.scroll = (anchor - soundcraft_time::to_samples(x * new)).max(0);
const MIN_SPP: f64 = 0.25;
const MAX_SPP: f64 = 1_000_000.0;
fn zoom(e: &mut Engine, f: f64, x: Option<f64>) -> Result<Value> {
let insertion = e.session().edit.selection.start as f64;
let z = e.zoom_mut();
let (old, scroll) = (z.samples_per_px, z.scroll as f64);
let new = (old * f).clamp(MIN_SPP, MAX_SPP);
if let Some(x) = x.or_else(|| Some((insertion - scroll) / old).filter(|x| (0.0..4000.0).contains(x))) {
z.scroll = soundcraft_time::to_samples(scroll + x * (old - new)).clamp(0, MAX_POSITION);
}
s.edit.zoom.samples_per_px = new;
Ok(json!({"samples_per_px": new}))
z.samples_per_px = new;
Ok(json!({"samples_per_px": new, "scroll": z.scroll}))
}
fn ruler(e: &mut Engine, p: &Value) -> Result<Value> {
@@ -157,4 +163,55 @@ mod tests {
assert_eq!(grid_param(&json!({"v": {"seconds": 0.5}}), "v"), Some(GridValue::Seconds(0.5)));
assert_eq!(grid_param(&json!({"v": "nonsense"}), "v"), None);
}
fn view(e: &Engine) -> (f64, Samples) {
(e.session().edit.zoom.samples_per_px, e.session().edit.zoom.scroll)
}
#[test]
fn zoom_by_holds_the_position_under_the_pointer() {
let mut e = Engine::default();
e.execute("view.zoom_set", &json!({"samples_per_px": 100.0})).unwrap();
e.execute("view.scroll", &json!({"to": 48_000})).unwrap();
e.execute("view.zoom_by", &json!({"factor": 2.0, "x": 300.0})).unwrap();
assert_eq!(view(&e), (50.0, 78_000 - 300 * 50));
e.execute("view.zoom_by", &json!({"factor": 0.5, "x": 300.0})).unwrap();
assert_eq!(view(&e), (100.0, 48_000));
}
#[test]
fn zoom_and_scroll_stay_in_range() {
let mut e = Engine::default();
e.execute("edit.select", &json!({"start": 0, "end": 4_000_000_000_i64})).unwrap();
e.execute("view.zoom_to_selection", &json!({})).unwrap();
assert_eq!(view(&e).0, MAX_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e12, "x": 0.0})).unwrap();
assert_eq!(view(&e).0, MIN_SPP);
e.execute("view.zoom_by", &json!({"factor": 1e-300, "x": 1e300})).unwrap();
assert_eq!(view(&e), (MAX_SPP, 0));
for bad in [json!(0.0), json!(-2.0), json!("2"), json!(null)] {
assert!(e.execute("view.zoom_by", &json!({"factor": bad})).is_err(), "{bad}");
}
e.execute("view.scroll", &json!({"by_px": 1e300})).unwrap();
assert_eq!(view(&e).1, MAX_POSITION);
e.execute("view.scroll", &json!({"by_px": -1e300})).unwrap();
assert_eq!(view(&e).1, 0);
}
#[test]
fn view_commands_skip_undo_the_journal_and_session_copies() {
let mut e = Engine::default();
let t = e.execute("track.new", &json!({})).unwrap()["tracks"][0].clone();
let steps = e.undo_history().len();
for db in [-1.0, -2.0] {
e.execute_merged("mix.volume", &json!({"track": t, "db": db}), "fader").unwrap();
e.execute("view.scroll", &json!({"by_px": 5.0})).unwrap();
}
assert_eq!(e.undo_history().len(), steps + 1);
let state = |e: &Engine| (std::ptr::from_ref(e.session()), e.revision, e.journal.len());
let before = state(&e);
e.execute("view.zoom_by", &json!({"factor": 2.0})).unwrap();
e.execute("view.scroll", &json!({"by_px": 1.0})).unwrap();
assert_eq!(state(&e), before);
}
}
+1
View File
@@ -86,6 +86,7 @@ pub fn session(e: &Engine, full: bool) -> Value {
})).collect::<Vec<_>>(),
"selection": {"start": s.edit.selection.start, "end": s.edit.selection.end, "tracks": s.edit.selected_tracks, "clips": s.edit.selected_clips},
"edit_mode": s.edit.edit_mode, "tool": s.edit.tool, "grid": s.edit.grid.label(), "nudge": s.edit.nudge.label(),
"zoom": {"samples_per_px": s.edit.zoom.samples_per_px, "scroll": s.edit.zoom.scroll},
"transport": e.transport,
"undo": e.undo_label(), "redo": e.redo_label(),
})
+733 -71
View File
@@ -3,7 +3,7 @@
use crate::{Engine, EngineError, Result};
use serde_json::{Value, json};
use soundcraft_audio_io::{AudioBuffer, EncodeOptions, FileFormat};
use soundcraft_model::{ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_model::{BusId, ChannelFormat, Clip, ClipContent, Session, Source, SourceAudio, SourceId, TrackId, TrackKind};
use soundcraft_time::{Range, Samples};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -11,6 +11,21 @@ use std::sync::Arc;
/// Largest import we accept (samples per channel × channels), ~4 GiB of f32.
const MAX_IMPORT_SAMPLES: usize = 1 << 30;
/// A relative `Audio Files/` path no other source in `s` already uses.
fn unique_audio_rel(s: &Session, stem: &str) -> String {
let stem = sanitize_name(stem);
let mut path = format!("Audio Files/{stem}.wav");
let mut n = 2u32;
while s.sources.iter().any(|src| src.path == path) {
path = format!("Audio Files/{stem}-{n}.wav");
n = n.saturating_add(1);
if n > 10_000 {
break;
}
}
path
}
/// Register decoded audio as a new source. Resamples to the session rate if needed.
pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Option<&str>, format: FileFormat) -> SourceId {
let sr = s.sample_rate.hz();
@@ -20,10 +35,14 @@ pub fn add_source(s: &mut Session, name: &str, mut buf: AudioBuffer, path: Optio
}
let id = SourceId(s.alloc());
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or(name).to_string();
let stored = match path {
Some(p) => p.to_string(),
None => unique_audio_rel(s, &stem),
};
s.sources.push(Source {
id,
name: stem.clone(),
path: path.map_or_else(|| format!("Audio Files/{stem}.wav"), str::to_string),
path: stored,
channels: u16::try_from(buf.num_channels()).unwrap_or(u16::MAX),
frames: buf.frames() as u64,
sample_rate: sr,
@@ -56,7 +75,7 @@ pub fn import_audio_bytes(e: &mut Engine, name: &str, bytes: &[u8], path: Option
Some(t) => t,
None => {
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).unwrap_or("Audio").to_string();
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels.min(2)), Some(&stem))
s.add_track(TrackKind::Audio, ChannelFormat::for_channels(channels), Some(&stem))
}
};
let cid = s.new_clip_id();
@@ -180,8 +199,8 @@ pub fn import_midi_bytes(e: &mut Engine, bytes: &[u8], at: Samples, tempo_map: b
Ok(out)
}
/// Export all MIDI clips as a format-1 SMF.
pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
/// Export MIDI clips as a format-1 SMF. `only` limits the file to those tracks.
pub fn export_midi(e: &Engine, only: Option<&[TrackId]>) -> Result<Vec<u8>> {
let s = e.session();
let mut smf = soundcraft_midi::Smf {
format: 1,
@@ -191,16 +210,25 @@ pub fn export_midi(e: &Engine) -> Result<Vec<u8>> {
markers: s.markers.iter().map(|m| (s.tempo.samples_to_ticks(m.start, s.sample_rate), m.name.clone())).collect(),
key_sigs: Vec::new(),
};
for t in s.tracks.iter().filter(|t| t.kind.is_midi()) {
let chosen: Vec<_> = s.tracks.iter().filter(|t| t.kind.is_midi() && only.is_none_or(|ids| ids.contains(&t.id))).collect();
if only.is_some() && chosen.is_empty() {
return Err(EngineError::BadParams("file.export_midi".into(), "none of the requested tracks hold MIDI".into()));
}
for t in chosen {
let mut seq = soundcraft_midi::Sequence::default();
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 {
let mut n = *n;
n.start += base;
n.start = n.start.saturating_add(base);
seq.notes.push(n);
}
for ev in &sequence.ctrls {
let mut ev = *ev;
ev.tick = ev.tick.saturating_add(base);
seq.ctrls.push(ev);
}
}
}
seq.sort();
@@ -217,11 +245,23 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool)
/// Render the main mix in its own format (every main channel, SMPTE/WAV order, with the WAV
/// speaker mask of the main format), or an ITU stereo fold-down when `fold_stereo`.
pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool, fold_stereo: bool) -> Result<(Vec<u8>, (f32, f32, f32))> {
bounce_source(e, r, opts, normalize, fold_stereo, None)
}
/// [`bounce_bytes_with`] for the main mix, or for `bus` when one is named.
pub fn bounce_source(
e: &Engine,
r: Range,
opts: &EncodeOptions,
normalize: bool,
fold_stereo: bool,
bus: Option<BusId>,
) -> Result<(Vec<u8>, (f32, f32, f32))> {
let s = e.session();
if r.len() > s.sample_rate.samples(4.0 * 3600.0) {
return Err(EngineError::BadParams("file.bounce_mix".into(), "bounces are limited to 4 hours".into()));
}
let (mut ch, fmt) = render_main(s, r, fold_stereo);
let (mut ch, fmt) = render_bounce(s, r, fold_stereo, bus);
if normalize {
soundcraft_dsp::offline::normalize(&mut ch, -0.1, false);
}
@@ -238,8 +278,19 @@ pub fn bounce_bytes_with(e: &Engine, r: Range, opts: &EncodeOptions, normalize:
/// The main mix over `r` and its format (folded to stereo on request).
fn render_main(s: &Session, r: Range, fold_stereo: bool) -> (Vec<Vec<f32>>, ChannelFormat) {
let ch = soundcraft_mix::render_range(s, r, 1024);
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
render_bounce(s, r, fold_stereo, None)
}
/// The main mix, or one bus, over `r`.
fn render_bounce(s: &Session, r: Range, fold_stereo: bool, bus: Option<BusId>) -> (Vec<Vec<f32>>, ChannelFormat) {
let (ch, fmt) = if let Some(id) = bus {
let fmt = s.bus(id).map(|b| b.format).unwrap_or(ChannelFormat::Stereo);
(soundcraft_mix::render_bus(s, id, r, 1024), fmt)
} else {
let ch = soundcraft_mix::render_range(s, r, 1024);
let fmt = if ch.len() <= 2 { ChannelFormat::Stereo } else { s.main_format() };
(ch, fmt)
};
if fold_stereo && ch.len() > 2 { (soundcraft_mix::fold_down_stereo(fmt, &ch), ChannelFormat::Stereo) } else { (ch, fmt) }
}
@@ -379,6 +430,9 @@ pub fn render_clip_gain(e: &mut Engine, id: soundcraft_model::ClipId) -> Result<
cl.content = ClipContent::Audio { source: src, offset: 0 };
cl.gain_db = 0.0;
cl.gain_env.clear();
// The new source is the clip as it already sounds, stretch included.
// Keeping the old ratio would play that audio through the stretch again.
cl.stretch = 1.0;
}
Ok(true)
}
@@ -456,7 +510,13 @@ pub fn sanitize_name(n: &str) -> String {
/// Save the session to `path` (a `.scraft` file). Audio that only exists in memory is written
/// as 32-bit float WAV into `Audio Files/` next to it. Returns how many audio files were written.
///
/// In the browser there are no folders: the session and all its audio go out as one bundle file
/// (`<name>.scraftz`, see [`session_bundle`]) through the host's delivery hook ([`set_deliver`]).
pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
if cfg!(target_arch = "wasm32") {
return save_session_bundle(e, path);
}
let (fresh, written, p) = write_session(e.session(), path, false)?;
// Keep the in-memory document in sync (paths, unsaved flags) without an undo step.
*e.session_mut() = fresh;
@@ -466,66 +526,186 @@ pub fn save_session(e: &mut Engine, path: &str) -> Result<usize> {
}
/// Write a copy of the session (and any audio it needs) without touching the open document —
/// used for autosave / crash recovery. Audio files in the target folder are overwritten in place.
/// used for autosave / crash recovery. Audio already written for a source is reused.
pub fn save_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, true).map(|(_, n, _)| n)
}
/// File › Save Session Copy In: a normal session folder that does not rename, retarget or
/// clean the session that stays open.
pub fn save_session_copy(e: &Engine, path: &str) -> Result<usize> {
write_session(e.session(), path, false).map(|(_, n, _)| n)
}
/// Write `session` to `path`; returns the session with updated media paths, the number of audio
/// files written and the final path.
fn write_session(session: &Session, path: &str, overwrite: bool) -> Result<(Session, usize, PathBuf)> {
let p = PathBuf::from(path);
let p = if p.extension().is_none() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p };
let p = session_file(path);
let dir = p.parent().map(Path::to_path_buf).unwrap_or_default();
let audio_dir = dir.join("Audio Files");
let mut s = session.clone();
let from = s.folder.clone();
let same_dir = from.as_deref().is_some_and(|f| same_path(f, &dir));
let mut written = 0;
for src in &mut s.sources {
let rel_target = format!("Audio Files/{}.wav", sanitize_name(&src.name));
let exists_rel = !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
if exists_rel && !src.unsaved {
let shared_paths: Vec<bool> =
s.sources.iter().enumerate().map(|(i, src)| s.sources.iter().enumerate().any(|(j, other)| j != i && other.path == src.path)).collect();
for (i, src) in s.sources.iter_mut().enumerate() {
if !same_dir
&& s.pool.get(src.id).is_none()
&& Path::new(&src.path).is_relative()
&& let Some(f) = &from
{
src.path = f.join(&src.path).to_string_lossy().into_owned();
}
let exists_rel = same_dir && !Path::new(&src.path).is_absolute() && dir.join(&src.path).exists();
let shared = shared_paths.get(i).copied().unwrap_or(false);
if exists_rel && !src.unsaved && !shared {
continue;
}
let in_folder = Path::new(&src.path).strip_prefix(&audio_dir).ok().and_then(Path::to_str).map(str::to_string);
if let Some(f) = in_folder.filter(|_| !src.unsaved && Path::new(&src.path).is_file()) {
src.path = format!("Audio Files/{f}");
continue;
}
let Some(audio) = s.pool.get(src.id) else { continue };
std::fs::create_dir_all(&audio_dir).map_err(|err| EngineError::Io(format!("{}: {err}", audio_dir.display())))?;
let mut target = rel_target.clone();
let mut stem = sanitize_name(&src.name);
if overwrite {
// One file per source id so autosaves never collide or pile up.
target = format!("Audio Files/{}_{}.wav", sanitize_name(&src.name), src.id.0);
if dir.join(&target).exists() {
src.path = target;
stem = format!("{stem}_{}", src.id.0);
if audio_dir.join(format!("{stem}.wav")).exists() {
src.path = format!("Audio Files/{stem}.wav");
src.unsaved = false;
continue;
}
} else {
let mut k = 1;
while dir.join(&target).exists() && !(exists_rel && target == src.path) {
target = format!("Audio Files/{}_{k:02}.wav", sanitize_name(&src.name));
k += 1;
if k > 999 {
break;
}
}
}
let opts = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts).map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::write(dir.join(&target), bytes).map_err(|err| EngineError::Io(format!("{target}: {err}")))?;
src.path = target;
let (name, new) = store_wav(&audio_dir, &stem, &bytes)?;
src.path = format!("Audio Files/{name}");
src.unsaved = false;
src.format = FileFormat::Wav;
written += 1;
written += usize::from(new);
}
if written > 0 {
sync_dir(&audio_dir);
}
s.folder = Some(dir.clone());
if !overwrite && let Some(stem) = p.file_stem().and_then(|x| x.to_str()) {
s.name = stem.to_string();
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
std::fs::create_dir_all(&dir).map_err(|err| EngineError::Io(format!("{}: {err}", dir.display())))?;
// Atomic-ish write: temp file then rename.
let tmp = p.with_extension("scraft.tmp");
std::fs::write(&tmp, text).map_err(|err| EngineError::Io(format!("{}: {err}", tmp.display())))?;
std::fs::rename(&tmp, &p).map_err(|err| EngineError::Io(format!("{}: {err}", p.display())))?;
if !overwrite && std::fs::read(&p).is_ok_and(|old| old != text.as_bytes()) {
back_up(&p);
}
replace_file(&tmp, &p, text.as_bytes())?;
Ok((s, written, p))
}
/// Write `bytes` to `tmp`, flush it to disk and rename it over `file`, so `file` holds either the
/// old or the new contents, never a mix.
pub fn replace_file(tmp: &Path, file: &Path, bytes: &[u8]) -> Result<()> {
if let Err(err) = write_synced(tmp, bytes).and_then(|()| std::fs::rename(tmp, file)) {
let _ = std::fs::remove_file(tmp);
return Err(EngineError::Io(format!("{}: {err}", file.display())));
}
sync_dir(file.parent().unwrap_or(Path::new("")));
Ok(())
}
fn sync_dir(dir: &Path) {
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let _ = std::fs::File::open(dir).and_then(|d| d.sync_all());
}
fn write_synced(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let mut f = std::fs::File::create(path)?;
f.write_all(bytes)?;
f.sync_all()
}
/// Copy the session file about to be replaced to `Backups/<name> backup <n>.<ext>`, keeping the
/// newest ten.
fn back_up(file: &Path) {
let (Some(dir), Some(stem), Some(ext)) = (file.parent(), file.file_stem().and_then(|x| x.to_str()), file.extension().and_then(|x| x.to_str()))
else {
return;
};
let backups = dir.join("Backups");
let prefix = format!("{stem} backup ");
let suffix = format!(".{ext}");
let numbers: Vec<u32> = std::fs::read_dir(&backups)
.into_iter()
.flatten()
.flatten()
.filter_map(|f| f.file_name().to_str()?.strip_prefix(&prefix)?.strip_suffix(&suffix)?.parse().ok())
.collect();
let next = numbers.iter().max().map_or(1, |n| n.saturating_add(1));
let copied = std::fs::create_dir_all(&backups).and_then(|()| std::fs::copy(file, backups.join(format!("{prefix}{next}{suffix}"))));
if let Err(err) = copied {
log::warn!("no backup of {}: {err}", file.display());
return;
}
for n in numbers.into_iter().filter(|n| n.saturating_add(10) <= next) {
let _ = std::fs::remove_file(backups.join(format!("{prefix}{n}{suffix}")));
}
}
/// The file a session saved to `path` goes to: `path`, with `.scraft` added when it has no
/// extension and isn't an existing file.
pub fn session_file(path: &str) -> PathBuf {
let p = PathBuf::from(path);
if p.extension().is_none() && !p.is_file() { p.with_extension(soundcraft_model::SESSION_EXTENSION) } else { p }
}
/// True when `command` would write a session over the existing `file`. Save, Save As and Save
/// Copy In may write the open session's own file; other commands never ask.
pub fn replaces(e: &Engine, command: &str, file: &Path) -> bool {
match command {
"session.save" | "session.save_as" | "session.save_copy" => {
file.exists() && !e.path.as_deref().is_some_and(|own| same_path(Path::new(own), file))
}
"session.save_template" | "file.export_selected_tracks_as_session" => file.exists(),
_ => false,
}
}
fn same_path(a: &Path, b: &Path) -> bool {
let full = |p: &Path| if p.as_os_str().is_empty() { Path::new(".").canonicalize() } else { p.canonicalize() };
a == b || matches!((full(a), full(b)), (Ok(x), Ok(y)) if x == y)
}
/// Store `bytes` as the first of `<stem>.wav`, `<stem>_01.wav`, … in `dir` that is free or already
/// holds exactly these bytes, so saving never replaces audio; returns the name and whether it was
/// written.
fn store_wav(dir: &Path, stem: &str, bytes: &[u8]) -> Result<(String, bool)> {
use std::io::Write;
for k in 0..10_000u32 {
let name = if k == 0 { format!("{stem}.wav") } else { format!("{stem}_{k:02}.wav") };
let path = dir.join(&name);
match std::fs::OpenOptions::new().write(true).create_new(true).open(&path) {
Ok(mut f) => {
if let Err(err) = f.write_all(bytes).and_then(|()| f.sync_all()) {
let _ = std::fs::remove_file(&path);
return Err(EngineError::Io(format!("{}: {err}", path.display())));
}
return Ok((name, true));
}
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if std::fs::metadata(&path).is_ok_and(|m| m.len() == bytes.len() as u64) && std::fs::read(&path).is_ok_and(|b| b == bytes) {
return Ok((name, false));
}
}
Err(err) => return Err(EngineError::Io(format!("{}: {err}", path.display()))),
}
}
Err(EngineError::Io(format!("{}: no free file name for {stem}", dir.display())))
}
/// Open a `.scraft` session and decode its audio files (missing files are reported, not fatal).
pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
let text = std::fs::read_to_string(path).map_err(|err| EngineError::Io(format!("{path}: {err}")))?;
@@ -535,20 +715,16 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
for src in s.sources.clone() {
let p = if Path::new(&src.path).is_absolute() { PathBuf::from(&src.path) } else { dir.join(&src.path) };
match std::fs::read(&p) {
Ok(bytes) => match soundcraft_audio_io::decode(&bytes, p.extension().and_then(|x| x.to_str())) {
Ok((_, mut buf)) => {
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
buf.sample_rate = s.sample_rate.hz();
}
s.pool.insert(src.id, Arc::new(SourceAudio::new(buf)));
Ok(bytes) => {
if let Err(err) = load_source_audio(&mut s, src.id, &bytes, p.extension().and_then(|x| x.to_str())) {
missing.push(format!("{}: {err}", p.display()));
}
Err(err) => missing.push(format!("{}: {err}", p.display())),
},
}
Err(_) => missing.push(p.display().to_string()),
}
}
missing.extend(relink_videos(&mut s, &dir));
s.folder = Some(dir);
e.replace_session(s);
e.path = Some(path.to_string());
for m in &missing {
@@ -557,17 +733,181 @@ pub fn open_session(e: &mut Engine, path: &str) -> Result<Vec<String>> {
Ok(missing)
}
/// Turn a recorded take into clips on the record-armed tracks (one undo step: "Record").
/// `channels` is the planar input capture at `rate`; each track takes the input channels its
/// input route names ("In 3" → channel 2), defaulting to the first channels.
pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, rate: u32) -> Result<Vec<soundcraft_model::ClipId>> {
let frames = channels.first().map_or(0, Vec::len);
if frames == 0 {
return Ok(Vec::new());
/// Decode one source's audio file into the session pool, at the session rate.
fn load_source_audio(s: &mut Session, id: SourceId, bytes: &[u8], ext: Option<&str>) -> std::result::Result<(), String> {
let (_, mut buf) = soundcraft_audio_io::decode(bytes, ext).map_err(|err| err.to_string())?;
if buf.sample_rate != s.sample_rate.hz() && buf.sample_rate > 0 {
buf.channels = soundcraft_dsp::offline::resample(&buf.channels, buf.sample_rate, s.sample_rate.hz());
buf.sample_rate = s.sample_rate.hz();
}
let armed: Vec<(TrackId, usize, usize, String)> = e
.session()
.tracks
s.pool.insert(id, Arc::new(SourceAudio::new(buf)));
Ok(())
}
// ─── Hosts without a filesystem (the browser) ─────────────────────────────────────────────────
/// Extension of a session bundle: one zip file holding the `.scraft` session and its
/// `Audio Files/`, the same layout as a saved session folder. The browser has no folders, so it
/// saves and opens sessions as bundles.
pub const BUNDLE_EXTENSION: &str = "scraftz";
/// Most entries and bytes a bundle may unpack to (bundles are hostile input).
const MAX_BUNDLE_ENTRIES: usize = 10_000;
const MAX_BUNDLE_BYTES: u64 = 1 << 30;
/// Hands a finished file (name, bytes) to the person on a host without a filesystem: the web app
/// installs one that starts a download. Native builds write files and never install it.
pub type DeliverHook = fn(&str, &[u8]) -> std::result::Result<(), String>;
static DELIVER: std::sync::OnceLock<DeliverHook> = std::sync::OnceLock::new();
/// Install the delivery hook (once, at start-up of the web app).
pub fn set_deliver(h: DeliverHook) {
let _ = DELIVER.set(h);
}
/// Write one output file: to `path` natively; in the browser, delivered under its file name.
/// Without this, every export on the web failed with «operation not supported on this platform».
pub fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
if cfg!(target_arch = "wasm32") {
let deliver = DELIVER.get().ok_or_else(|| EngineError::Io("saving files is not available in this build".into()))?;
let name = Path::new(path).file_name().and_then(|n| n.to_str()).filter(|n| !n.is_empty()).unwrap_or("Untitled");
return deliver(name, bytes).map_err(EngineError::Io);
}
std::fs::write(path, bytes).map_err(|err| EngineError::Io(format!("{path}: {err}")))
}
/// The session as a bundle: `<name>.scraft` plus every source's audio as 32-bit float WAV under
/// `Audio Files/` (stored, not compressed: audio barely compresses). Returns the bytes and the
/// session with its source paths pointing into the bundle.
pub fn session_bundle(session: &Session) -> Result<(Vec<u8>, Session)> {
use std::io::Write as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let ioerr = |err: std::io::Error| EngineError::Io(format!("bundle: {err}"));
let mut s = session.clone();
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored).large_file(true);
let mut taken = std::collections::HashSet::new();
for src in &mut s.sources {
let Some(audio) = s.pool.get(src.id) else { continue };
let base = sanitize_name(&src.name);
let mut target = format!("Audio Files/{base}.wav");
let mut k = 1;
while taken.contains(&target) && k <= 9_999 {
target = format!("Audio Files/{base}_{k:02}.wav");
k += 1;
}
let opts_wav = EncodeOptions { format: FileFormat::Wav, bit_depth: soundcraft_audio_io::BitDepth::Float32, dither: false, bwf: None };
let bytes = soundcraft_audio_io::encode(&audio.buffer, &opts_wav).map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(target.as_str(), opts).map_err(zerr)?;
w.write_all(&bytes).map_err(ioerr)?;
taken.insert(target.clone());
src.path = target;
src.unsaved = false;
src.format = FileFormat::Wav;
}
let text = s.to_json().map_err(|err| EngineError::Io(err.to_string()))?;
w.start_file(format!("{}.{}", sanitize_name(&s.name), soundcraft_model::SESSION_EXTENSION), opts).map_err(zerr)?;
w.write_all(text.as_bytes()).map_err(ioerr)?;
let bytes = w.finish().map_err(zerr)?.into_inner();
Ok((bytes, s))
}
/// Save as a bundle named after `path`'s file name, delivered through the host ([`write_file`]).
fn save_session_bundle(e: &mut Engine, path: &str) -> Result<usize> {
let stem = Path::new(path).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).map(str::to_string);
let mut s = e.session().clone();
if let Some(stem) = stem {
s.name = stem;
}
let (bytes, fresh) = session_bundle(&s)?;
let file = format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&fresh.name));
write_file(&file, &bytes)?;
let written = fresh.sources.iter().filter(|x| fresh.pool.get(x.id).is_some()).count();
*e.session_mut() = fresh;
e.path = Some(file);
e.mark_clean();
Ok(written)
}
/// Open a session from bytes (the browser has no paths): a bundle ([`session_bundle`]) or a bare
/// `.scraft`, whose audio is then reported missing. Returns the missing media, like
/// [`open_session`].
pub fn open_session_bytes(e: &mut Engine, name: &str, bytes: &[u8]) -> Result<Vec<String>> {
let (text, mut files) = if bytes.starts_with(b"PK\x03\x04") {
read_bundle(bytes)?
} else {
(
String::from_utf8(bytes.to_vec()).map_err(|_| EngineError::Io(format!("{name}: not a SoundCraft session")))?,
std::collections::HashMap::new(),
)
};
let mut s = Session::from_json(&text).map_err(|err| EngineError::Io(format!("{name}: {err}")))?;
let mut missing = Vec::new();
for src in s.sources.clone() {
match files.remove(&src.path) {
Some(audio) => {
let ext = Path::new(&src.path).extension().and_then(|x| x.to_str()).map(str::to_string);
if let Err(err) = load_source_audio(&mut s, src.id, &audio, ext.as_deref()) {
missing.push(format!("{}: {err}", src.path));
}
}
None => missing.push(src.path.clone()),
}
}
// Movies are referenced by path, never copied into a session: none can be found here.
missing.extend(s.videos.iter().map(|v| v.path.clone()));
let stem = Path::new(name).file_stem().and_then(|x| x.to_str()).filter(|x| !x.is_empty()).unwrap_or("Untitled").to_string();
e.replace_session(s);
e.path = Some(format!("{}.{BUNDLE_EXTENSION}", sanitize_name(&stem)));
for m in &missing {
e.message(format!("missing media: {m}"));
}
Ok(missing)
}
/// Unpack a bundle: the one `.scraft` at its top level and the files beside it, by relative path.
fn read_bundle(bytes: &[u8]) -> Result<(String, std::collections::HashMap<String, Vec<u8>>)> {
use std::io::Read as _;
let zerr = |err: zip::result::ZipError| EngineError::Io(format!("bundle: {err}"));
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(zerr)?;
if z.len() > MAX_BUNDLE_ENTRIES {
return Err(EngineError::Io("bundle: too many files".into()));
}
let (mut text, mut files, mut total) = (None, std::collections::HashMap::new(), 0u64);
for i in 0..z.len() {
let mut f = z.by_index(i).map_err(zerr)?;
if f.is_dir() {
continue;
}
let rel = f.name().map_err(zerr)?.to_string();
let size = f.size();
total = total.saturating_add(size);
if total > MAX_BUNDLE_BYTES {
return Err(EngineError::Io("bundle: too large".into()));
}
let mut data = Vec::new();
// The declared size is hostile too: never read past it.
f.by_ref().take(size).read_to_end(&mut data).map_err(|err| EngineError::Io(format!("bundle: {rel}: {err}")))?;
let is_session = !rel.contains('/') && Path::new(&rel).extension().and_then(|x| x.to_str()) == Some(soundcraft_model::SESSION_EXTENSION);
if is_session && text.is_none() {
text = Some(String::from_utf8(data).map_err(|_| EngineError::Io(format!("bundle: {rel} is not text")))?);
} else {
files.insert(rel, data);
}
}
let text = text.ok_or_else(|| EngineError::Io("bundle: no .scraft session inside".into()))?;
Ok((text, files))
}
/// The record-enabled audio tracks, each with the input channels it records (those its input
/// route names, "In 3" → 2, wrapped to the device's `input_channels`) and a new take file
/// `<track>_<NN>.wav` in the `Audio Files` of the session's folder (`unsaved_dir` until it is saved).
pub fn plan_takes(e: &Engine, unsaved_dir: &Path, input_channels: usize) -> Vec<(TrackId, Vec<usize>, PathBuf)> {
let dir = e.path.as_deref().and_then(|p| Path::new(p).parent()).unwrap_or(unsaved_dir);
let s = e.session();
let take_no = s.sources.len() + 1;
s.tracks
.iter()
.filter(|t| t.mixer.record_arm && t.kind == TrackKind::Audio && !t.inactive)
.map(|t| {
@@ -577,28 +917,118 @@ pub fn add_recording(e: &mut Engine, start: Samples, channels: Vec<Vec<f32>>, ra
}
_ => 0,
};
(t.id, first, t.channels(), t.name.clone())
let inputs = (0..t.channels()).map(|k| first.saturating_add(k) % input_channels.max(1)).collect();
let file = |n: usize| dir.join("Audio Files").join(format!("{}_{n:02}.wav", sanitize_name(&t.name)));
let path = (take_no..take_no + 1_000).map(file).find(|p| !p.exists()).unwrap_or_else(|| file(take_no));
(t.id, inputs, path)
})
.collect();
if armed.is_empty() {
return Err(EngineError::BadParams("record".into(), "no tracks are record-enabled".into()));
}
.collect()
}
/// Turn recorded take files into clips at `start`, as one undo step ("Record"); each file is the
/// source of its clips. With `pass` (loop recording), a take becomes one clip per pass (at most
/// 1000), each pass after the first on a new playlist.
pub fn add_recording(e: &mut Engine, start: Samples, takes: Vec<(TrackId, PathBuf)>, pass: Option<Samples>) -> Vec<soundcraft_model::ClipId> {
let before = e.session_arc();
let mut out = Vec::new();
let take_no = e.session().sources.len() + 1;
for (tid, first, n, name) in armed {
let chans: Vec<Vec<f32>> = (0..n).map(|k| channels.get((first + k) % channels.len().max(1)).cloned().unwrap_or_default()).collect();
let buf = AudioBuffer { sample_rate: rate, channels: chans };
let clip_name = format!("{name}_{take_no:02}");
for (tid, path) in takes {
let read = std::fs::read(&path).map_err(|x| x.to_string()).and_then(|b| soundcraft_audio_io::decode(&b, None).map_err(|x| x.to_string()));
let Ok((_, buf)) = read.inspect_err(|err| e.message(format!("{}: {err}", path.display()))) else { continue };
let s = e.session_mut();
let src = add_source(s, &clip_name, buf, None, FileFormat::Wav);
let len = s.source(src).map_or(0, |x| i64::try_from(x.frames).unwrap_or(0));
let cid = s.new_clip_id();
crate::edit::place_clip(s, tid, Clip::audio(cid, clip_name, src, 0, start, len));
out.push(cid);
if s.track(tid).is_none() {
continue;
}
let resampled = buf.sample_rate != s.sample_rate.hz();
let name = path.file_stem().and_then(|x| x.to_str()).unwrap_or("Take");
let src = add_source(s, name, buf, Some(&path.to_string_lossy()), FileFormat::Wav);
let Some(x) = s.sources.last_mut() else { continue };
x.unsaved = resampled;
let len = i64::try_from(x.frames).unwrap_or(0);
let pass = pass.unwrap_or(len).max(1);
for offset in (0..len).step_by(usize::try_from(pass).unwrap_or(usize::MAX)).take(1_000) {
let n = pass.min(len - offset);
if offset > 0 && n < pass / 8 {
break;
}
if offset > 0
&& let Some(t) = s.track_mut(tid)
{
t.playlists.push(soundcraft_model::Playlist::new(format!("{}.{:02}", t.name, t.playlists.len() + 1)));
t.active_playlist = t.playlists.len() - 1;
}
let cid = s.new_clip_id();
crate::edit::place_clip(s, tid, Clip::audio(cid, name, src, offset, start, n));
out.push(cid);
}
}
if !out.is_empty() {
e.push_undo("Record", before);
}
out
}
#[cfg(test)]
mod record_tests {
use super::*;
use serde_json::json;
fn armed(e: &mut Engine, name: &str, format: &str, input: &str) -> TrackId {
let id = e.execute("track.new", &json!({"name": name, "format": format})).unwrap()["tracks"][0].as_u64().unwrap();
e.execute("track.input", &json!({"tracks": [id], "input": input})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [id], "value": true})).unwrap();
TrackId(id)
}
fn write_take(path: &Path, frames: usize) {
let mut bytes = soundcraft_audio_io::wav_float_header(1, 48_000, frames as u64).unwrap();
bytes.extend((0..frames).flat_map(|i| (i as f32 / frames as f32 - 0.5).to_le_bytes()));
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}
#[test]
fn armed_tracks_get_new_take_files_used_in_place() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 2");
let gtr = armed(&mut e, "Gtr", "stereo", "In 3-4");
write_take(&dir.join("Audio Files/Vox_01.wav"), 10);
let plan = plan_takes(&e, &dir, 4);
assert_eq!(plan, vec![(vox, vec![1], dir.join("Audio Files/Vox_02.wav")), (gtr, vec![2, 3], dir.join("Audio Files/Gtr_01.wav"))]);
assert_eq!(plan_takes(&e, &dir, 2)[1].1, vec![0, 1]);
let session = dir.join("Song.scraft").to_string_lossy().into_owned();
e.execute("session.save_as", &json!({"path": session})).unwrap();
write_take(&plan[0].2, 4_800);
let clips = add_recording(&mut e, 96_000, vec![(vox, plan[0].2.clone())], None);
let clip = e.session().find_clip(clips[0]).unwrap().1.clone();
assert_eq!((clip.start, clip.length), (96_000, 4_800));
assert_eq!(save_session(&mut e, &session).unwrap(), 0);
let src = e.session().sources.last().unwrap().clone();
assert_eq!((src.name.as_str(), src.path.as_str(), src.unsaved, src.frames), ("Vox_02", "Audio Files/Vox_02.wav", false, 4_800));
e.execute("edit.undo", &json!({})).unwrap();
assert!(e.session().find_clip(clips[0]).is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_loop_take_becomes_one_clip_per_pass_and_autosaves_in_place() {
let dir = std::env::temp_dir().join(format!("soundcraft-loop-take-{}", std::process::id()));
let path = dir.join("Audio Files/Vox_01.wav");
write_take(&path, 1_050);
let mut e = Engine::default();
let vox = armed(&mut e, "Vox", "mono", "In 1");
assert_eq!(add_recording(&mut e, 1_000, vec![(vox, path.clone())], Some(400)).len(), 3);
assert_eq!(save_copy(&e, &dir.join("Untitled.scraft").to_string_lossy()).unwrap(), 0);
let t = e.session().track(vox).unwrap();
let passes: Vec<_> = t.playlists.iter().flat_map(|p| p.clips.iter().map(|c| (c.source_offset(), c.start, c.length))).collect();
assert_eq!(passes, vec![(0, 1_000, 400), (400, 1_000, 400), (800, 1_000, 250)]);
assert_eq!((t.active_playlist, t.playlists[2].name.as_str()), (2, "Vox.03"));
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(e.session().track(vox).unwrap().playlists.len(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
e.push_undo("Record", before);
Ok(out)
}
#[cfg(test)]
@@ -622,6 +1052,166 @@ mod save_tests {
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
let _ = std::fs::remove_dir_all(&dir);
}
fn with_take(level: f32) -> Engine {
let mut e = Engine::default();
let s = e.session_mut();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let src = add_source(s, "Audio 1_02", AudioBuffer { sample_rate: 48_000, channels: vec![vec![level; 4_800]] }, None, FileFormat::Wav);
let id = s.new_clip_id();
crate::edit::place_clip(s, t, Clip::audio(id, "Audio 1_02", src, 0, 0, 4_800));
e
}
fn reopen(path: &str) -> Engine {
let mut r = Engine::default();
assert_eq!(open_session(&mut r, path).unwrap(), Vec::<String>::new());
r
}
#[test]
fn saving_never_replaces_audio_of_another_session() {
let dir = std::env::temp_dir().join(format!("soundcraft-no-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
save_session(&mut with_take(0.5), &path("A/A.scraft")).unwrap();
save_session(&mut with_take(-0.25), &path("A/A v2.scraft")).unwrap();
save_session(&mut with_take(0.75), &path("B/B.scraft")).unwrap();
save_session(&mut reopen(&path("B/B.scraft")), &path("A/B.scraft")).unwrap();
let levels = ["A/A.scraft", "A/A v2.scraft", "B/B.scraft", "A/B.scraft"].map(|p| {
let r = reopen(&path(p));
r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0]
});
assert_eq!(levels, [0.5, -0.25, 0.75, 0.75]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn consolidating_twice_keeps_both_renders() {
let dir = std::env::temp_dir().join(format!("soundcraft-consolidate-twice-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut e = crate::demo::demo_engine();
let kick = e.session().track_by_name("Kick").unwrap().id;
let end = e.session().content_end();
let render = |e: &Engine| soundcraft_mix::render_clips(e.session(), kick, Range::new(0, end));
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 500_000, "end": 560_000})).unwrap();
save_session(&mut e, &path).unwrap();
e.execute("edit.consolidate", &json!({"tracks": [kick.0], "start": 1_400_000, "end": 1_500_000})).unwrap();
save_session(&mut e, &path).unwrap();
assert!(render(&reopen(&path)) == render(&e));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_after_undo_reuses_the_saved_audio() {
let dir = std::env::temp_dir().join(format!("soundcraft-undo-resave-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut e = with_take(0.5);
e.execute("track.new", &json!({})).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 1);
e.execute("edit.undo", &json!({})).unwrap();
assert_eq!(save_session(&mut e, &path).unwrap(), 0);
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saved_audio_is_trusted_only_in_the_folder_it_was_saved_in() {
let dir = std::env::temp_dir().join(format!("soundcraft-own-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = |p: &str| dir.join(p).to_string_lossy().into_owned();
let level = |r: &Engine| r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0];
save_session(&mut with_take(-0.25), &path("B/Other.scraft")).unwrap();
let mut e = with_take(0.5);
save_session(&mut e, &path("A/A.scraft")).unwrap();
e.execute("track.new", &json!({})).unwrap();
save_session(&mut e, &path("B/B.scraft")).unwrap();
e.execute("edit.undo", &json!({})).unwrap();
e.execute("session.save", &json!({})).unwrap();
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
e.execute("session.save_copy", &json!({"path": path("A/Copy.scraft")})).unwrap();
e.execute("session.save", &json!({})).unwrap();
assert_eq!(level(&reopen(&path("B/B.scraft"))), 0.5);
std::fs::remove_file(dir.join("A/Audio Files/Audio 1_02.wav")).unwrap();
let mut missing = Engine::default();
open_session(&mut missing, &path("A/A.scraft")).unwrap();
save_session(&mut missing, &path("B/Moved.scraft")).unwrap();
let mut r = Engine::default();
assert_eq!(open_session(&mut r, &path("B/Moved.scraft")).unwrap(), vec![dir.join("A/Audio Files/Audio 1_02.wav").display().to_string()]);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_over_another_session_needs_replace() {
let dir = std::env::temp_dir().join(format!("soundcraft-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut first = with_take(0.5);
first.execute("session.save_as", &json!({"path": path})).unwrap();
first.execute("session.save", &json!({})).unwrap();
first.execute("session.save_as", &json!({"path": dir.join("Song")})).unwrap();
assert!(first.execute("session.save_template", &json!({"path": path})).is_err());
let mut second = with_take(-0.25);
let track = second.session().tracks[0].id.0;
for id in ["session.save", "session.save_as", "session.save_copy", "session.save_template", "file.export_selected_tracks_as_session"] {
let err = second.execute(id, &json!({"path": path, "tracks": [track]})).unwrap_err();
assert!(err.to_string().contains("`replace: true`"), "{id}: {err}");
}
assert_eq!(std::fs::read_dir(dir.join("Audio Files")).unwrap().count(), 1);
Engine::default().execute("session.save_as", &json!({"path": dir.join("New/New.scraft")})).unwrap();
std::fs::copy(&path, dir.join("Song")).unwrap();
let mut bare = Engine::default();
open_session(&mut bare, &dir.join("Song").to_string_lossy()).unwrap();
bare.execute("session.save", &json!({})).unwrap();
second.execute("session.save_as", &json!({"path": path, "replace": true})).unwrap();
let r = reopen(&path);
assert_eq!(r.session().pool.get(r.session().sources[0].id).unwrap().buffer.channels[0][0], -0.25);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_keeps_the_last_ten_versions_as_backups() {
let dir = std::env::temp_dir().join(format!("soundcraft-backups-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Song.scraft");
let mut e = Engine::default();
let mut versions = Vec::new();
for i in 0..12 {
e.execute("track.new", &json!({"name": format!("T{i}")})).unwrap();
save_session(&mut e, &path.to_string_lossy()).unwrap();
save_session(&mut e, &path.to_string_lossy()).unwrap();
versions.push(std::fs::read(&path).unwrap());
}
let backup = |n: usize| std::fs::read(dir.join(format!("Backups/Song backup {n}.scraft"))).ok();
assert_eq!(std::fs::read_dir(dir.join("Backups")).unwrap().count(), 10);
assert_eq!((backup(1), backup(2), backup(11)), (None, Some(versions[1].clone()), Some(versions[10].clone())));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn save_gives_sources_that_share_a_path_their_own_files() {
let mut s = Session::default();
let loud = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.5; 64]] };
let quiet = AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.0; 64]] };
add_source(&mut s, "tone", loud, Some("Audio Files/tone.wav"), FileFormat::Wav);
add_source(&mut s, "tone", quiet, Some("Audio Files/tone.wav"), FileFormat::Wav);
let mut e = Engine::new(s);
let dir = std::env::temp_dir().join(format!("soundcraft-shared-path-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Shared.scraft");
save_session(&mut e, &path.to_string_lossy()).unwrap();
let paths: Vec<_> = e.session().sources.iter().map(|src| src.path.clone()).collect();
assert_eq!(paths.len(), 2);
assert_ne!(paths[0], paths[1], "{paths:?}");
let a = std::fs::read(dir.join(&paths[0])).unwrap();
let b = std::fs::read(dir.join(&paths[1])).unwrap();
assert_ne!(a, b);
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
@@ -638,3 +1228,75 @@ mod stem_tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(test)]
mod bundle_tests {
use super::*;
#[test]
fn bundle_round_trips_session_and_audio() {
let mut e = crate::demo::demo_engine();
e.execute("mix.volume", &serde_json::json!({"track": "Kick", "db": -7.0})).unwrap();
let (bytes, fresh) = session_bundle(e.session()).unwrap();
assert!(bytes.starts_with(b"PK\x03\x04"));
assert!(fresh.sources.iter().all(|s| s.path.starts_with("Audio Files/") && !s.unsaved));
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Midnight Groove.scraftz", &bytes).unwrap();
assert!(missing.is_empty(), "{missing:?}");
assert_eq!(r.session().track_by_name("Kick").unwrap().mixer.volume_db, -7.0);
assert_eq!(r.session().tracks.len(), e.session().tracks.len());
for src in &e.session().sources {
let (a, b) = (e.session().pool.get(src.id).unwrap(), r.session().pool.get(src.id).unwrap());
assert_eq!(a.buffer.channels, b.buffer.channels, "{}: float WAV keeps every sample", src.name);
}
assert!(!r.is_dirty());
assert_eq!(r.path.as_deref(), Some("Midnight Groove.scraftz"));
}
#[test]
fn same_named_sources_get_distinct_files() {
let mut e = crate::demo::demo_engine();
let name = e.session().sources[0].name.clone();
let s = e.session_mut();
let n = s.sources.len();
s.sources[1].name = name;
let (bytes, fresh) = session_bundle(e.session()).unwrap();
let paths: std::collections::HashSet<_> = fresh.sources.iter().map(|s| s.path.clone()).collect();
assert_eq!(paths.len(), n);
let mut r = Engine::default();
assert!(open_session_bytes(&mut r, "x.scraftz", &bytes).unwrap().is_empty());
}
#[test]
fn bare_session_opens_with_its_audio_reported_missing() {
let e = crate::demo::demo_engine();
let (_, fresh) = session_bundle(e.session()).unwrap();
let text = fresh.to_json().unwrap();
let mut r = Engine::default();
let missing = open_session_bytes(&mut r, "Song.scraft", text.as_bytes()).unwrap();
assert_eq!(missing.len(), fresh.sources.len());
assert_eq!(r.session().tracks.len(), fresh.tracks.len());
}
#[test]
fn hostile_bundles_are_errors_not_panics() {
let mut r = Engine::default();
for bad in [&b"PK\x03\x04 not really a zip"[..], b"", b"{ not json", b"\xff\xfe\x00"] {
assert!(open_session_bytes(&mut r, "bad.scraftz", bad).is_err());
}
// A valid zip without a session inside.
let mut w = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
w.start_file("Audio Files/a.wav", zip::write::SimpleFileOptions::default()).unwrap();
let only_audio = w.finish().unwrap().into_inner();
assert!(open_session_bytes(&mut r, "a.scraftz", &only_audio).is_err());
assert_eq!(r.session().tracks.len(), 0, "a failed open leaves the document alone");
}
#[test]
fn write_file_writes_to_disk_natively() {
let p = std::env::temp_dir().join(format!("soundcraft-write-file-{}.txt", std::process::id()));
write_file(&p.to_string_lossy(), b"hello").unwrap();
assert_eq!(std::fs::read(&p).unwrap(), b"hello");
let _ = std::fs::remove_file(&p);
}
}
+88 -9
View File
@@ -16,8 +16,9 @@ pub mod io;
pub mod score;
use serde_json::{Value, json};
use soundcraft_model::{Clip, Session, TrackId};
use soundcraft_model::{Clip, Session, TrackId, ZoomState};
use soundcraft_time::Samples;
use std::cell::Cell;
use std::sync::Arc;
pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command};
@@ -108,9 +109,13 @@ pub struct Engine {
pub journal: Vec<(String, Value)>,
/// Path of the session file, if saved.
pub path: Option<String>,
/// Revision counter; bumps on every document change (UI caches key on it).
/// Revision counter; bumps on every document change but zoom/scroll (UI caches key on it).
pub revision: u64,
dirty: bool,
/// The document as last opened or saved, for `is_dirty`.
saved: Arc<Session>,
/// `is_dirty` as of a revision.
dirty_at: Cell<(u64, bool)>,
/// Gesture key for undo coalescing (see `execute_merged`).
merge_key: Option<String>,
/// Messages for the UI status line / agents.
@@ -126,8 +131,10 @@ impl Default for Engine {
impl Engine {
pub fn new(session: Session) -> Self {
install_panic_hook();
let doc = Arc::new(session);
Engine {
doc: Arc::new(session),
saved: Arc::clone(&doc),
doc,
undo: Vec::new(),
redo: Vec::new(),
history_limit: 200,
@@ -138,6 +145,7 @@ impl Engine {
path: None,
revision: 1,
dirty: false,
dirty_at: Cell::new((0, false)),
merge_key: None,
messages: Vec::new(),
}
@@ -158,23 +166,42 @@ impl Engine {
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
&mut Arc::make_mut(&mut self.doc).edit.zoom
}
/// Replace the whole document (open / new); clears history.
pub fn replace_session(&mut self, s: Session) {
self.transport = TransportStatus { position: s.edit.selection.start.max(0), ..TransportStatus::default() };
self.transport_requests.clear();
self.transport_requests.push(TransportRequest::ResetSession);
self.doc = Arc::new(s);
self.saved = Arc::clone(&self.doc);
self.undo.clear();
self.redo.clear();
self.revision = self.revision.wrapping_add(1);
self.dirty = false;
}
/// Whether the document changed since it was opened or saved. Selecting, locating and zooming
/// don't count, and undoing back to the saved state makes it clean again.
pub fn is_dirty(&self) -> bool {
self.dirty
if !self.dirty {
return false;
}
let (rev, dirty) = self.dirty_at.get();
if rev == self.revision {
return dirty;
}
let dirty = !same_document(&self.saved, &self.doc);
self.dirty_at.set((self.revision, dirty));
dirty
}
pub fn mark_clean(&mut self) {
self.dirty = false;
self.saved = Arc::clone(&self.doc);
}
pub fn can_undo(&self) -> bool {
@@ -227,16 +254,19 @@ impl Engine {
/// Run a command by id. Never panics: escaped panics become `EngineError::Internal` and the
/// document is restored.
pub fn execute(&mut self, id: &str, params: &Value) -> Result<Value> {
self.merge_key = None;
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
if let Err(reason) = (spec.enabled)(self) {
// A call that names its targets (tracks, clips, a range) does not need a selection;
// the command itself validates them.
let names_targets = ["track", "tracks", "clip", "clips", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
let names_targets = ["track", "tracks", "clip", "clips", "sources", "start", "end", "at"].iter().any(|k| params.get(k).is_some());
if !names_targets {
return Err(EngineError::Disabled(id.to_string(), reason));
}
}
if spec.view {
return self.execute_view(spec, params);
}
self.merge_key = None;
let before = Arc::clone(&self.doc);
let rev = self.revision;
let dirty = self.dirty;
@@ -244,12 +274,10 @@ impl Engine {
let result = match result {
Ok(r) => r,
Err(p) => {
let msg =
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into());
self.doc = before;
self.revision = rev.wrapping_add(1);
self.dirty = dirty;
return Err(EngineError::Internal(id.to_string(), msg));
return Err(EngineError::Internal(id.to_string(), panic_message(&*p)));
}
};
match result {
@@ -274,6 +302,16 @@ impl Engine {
}
}
fn execute_view(&mut self, spec: &CommandSpec, params: &Value) -> Result<Value> {
let zoom = self.doc.edit.zoom;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params)))
.unwrap_or_else(|p| Err(EngineError::Internal(spec.id.to_string(), panic_message(&*p))));
if result.is_err() && self.doc.edit.zoom != zoom {
Arc::make_mut(&mut self.doc).edit.zoom = zoom;
}
result
}
/// Execute as part of a continuous gesture (fader drag, knob turn, slider): consecutive calls
/// with the same `key` collapse into a single undo step.
pub fn execute_merged(&mut self, id: &str, params: &Value, key: &str) -> Result<Value> {
@@ -318,6 +356,19 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
to.selection = from.selection;
to.timeline_selection = from.timeline_selection;
to.selected_tracks.clone_from(&from.selected_tracks);
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
*saved == doc
}
fn install_panic_hook() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
@@ -329,6 +380,10 @@ fn install_panic_hook() {
});
}
fn panic_message(p: &(dyn std::any::Any + Send)) -> String {
p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_else(|| "panic".into())
}
#[cfg(test)]
mod merge_tests {
use super::*;
@@ -346,3 +401,27 @@ mod merge_tests {
assert_eq!(e.session().track(t).unwrap().mixer.volume_db, 0.0);
}
}
#[cfg(test)]
mod dirty_tests {
use super::*;
#[test]
fn selecting_locating_and_zooming_leave_the_session_clean() {
let mut e = crate::demo::demo_engine();
for (id, p) in [
("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})),
("edit.select_none", json!({})),
("transport.locate", json!({"at": 96_000})),
("view.zoom_in", json!({})),
("view.scroll", json!({"to": 48_000})),
] {
e.execute(id, &p).unwrap();
assert!(!e.is_dirty(), "{id}");
}
e.execute("mix.mute", &json!({"track": "Kick"})).unwrap();
assert!(e.is_dirty());
e.undo();
assert!(!e.is_dirty());
}
}
+3
View File
@@ -45,6 +45,9 @@ const KEYS: &[&str] = &[
"numerator",
"denominator",
"factor",
"x",
"by_px",
"samples_per_px",
"sensitivity",
"rating",
"direction",
+2 -4
View File
@@ -267,10 +267,8 @@ fn read_track(body: &[u8], base: usize, conv: TickConv, g: &mut Globals, budget:
let data = cur.bytes(usize::try_from(len).unwrap_or(usize::MAX))?;
match kind {
0x2F => break, // End of Track
0x03 => {
if name.is_none() {
name = Some(String::from_utf8_lossy(data).into_owned());
}
0x03 if name.is_none() => {
name = Some(String::from_utf8_lossy(data).into_owned());
}
0x06 => g.markers.push((tick, String::from_utf8_lossy(data).into_owned())),
0x51 => {
+18
View File
@@ -91,6 +91,24 @@ fn round_trip() {
assert_eq!(write_smf(&again).unwrap(), bytes);
}
#[test]
fn first_track_name_wins_and_ignored_meta_preserves_following_notes() {
let meta = |kind, data: &[u8]| [vec![0, 0xFF, kind], vlq(data.len() as u32), data.to_vec()].concat();
for first in [&b"First"[..], &b""[..], &[0xFF][..]] {
let body = [
meta(0x03, first),
meta(0x03, b"Second"),
meta(0x7F, &[0x03, 0xFF, 0x2F]), // Unhandled metadata must remain ignored.
vec![0, 0x90, 60, 100, 16, 0x80, 60, 64, 0, 0xFF, 0x2F, 0],
]
.concat();
let smf = read_smf(&file(0, 960, &[body])).unwrap();
assert_eq!(smf.tracks.len(), 1);
assert_eq!(smf.tracks[0].name, String::from_utf8_lossy(first));
assert_eq!(smf.tracks[0].sequence.notes, vec![note(60, 0, 16)]);
}
}
#[test]
fn write_rejects_bad_values() {
let bad_tempo = Smf { tempos: vec![(0, 0.0)], ..Default::default() };
+324 -32
View File
@@ -43,14 +43,22 @@
//! [`MixEngine::capture_states`] reads every live instance's state back (for saving the session);
//! it calls into the plugins and allocates, so a realtime host runs it only on request.
//!
//! A built-in instrument's parameter values are applied the same way. A sample-playing
//! instrument (the Sampler) is handed the decoded audio of its insert's
//! [`Insert::sample`] source straight from the session's pool, shared and never copied.
//!
//! [`Insert::state`]: soundcraft_model::Insert::state
//! [`Insert::sample`]: soundcraft_model::Insert::sample
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains};
use soundcraft_dsp::{Plugin, db_to_gain};
use soundcraft_model::{AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, Speaker, SurroundPan, Track, TrackId, TrackKind};
use soundcraft_dsp::{Plugin, SampleSource, db_to_gain};
use soundcraft_model::{
AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,
};
use soundcraft_time::{Range, Samples};
use std::collections::HashMap;
use std::sync::Arc;
/// Default (stereo) main mix width. The actual width is [`main_channels`].
pub const MAIN_CHANNELS: usize = 2;
@@ -187,6 +195,8 @@ struct Strip {
scratch: Vec<f32>,
plugins: Vec<Option<PluginSlot>>,
instrument: Option<PluginSlot>,
/// The pool audio handed to the instrument with `set_sample` (sample-playing instruments).
sample: Option<Arc<SourceAudio>>,
/// MIDI notes currently sounding on the instrument (pitch), for note-offs at stops/seek.
held: Vec<u8>,
muted: bool,
@@ -261,6 +271,7 @@ impl Strip {
scratch: Vec::new(),
plugins: Vec::new(),
instrument: None,
sample: None,
held: Vec::new(),
muted: false,
gr: 0.0,
@@ -293,6 +304,11 @@ pub struct MixEngine {
order: Vec<usize>,
/// Current total compensation delay (samples) of the mix.
pub latency: usize,
/// Delay already applied to audio written onto busses: the slowest track insert.
/// A bus render drops this so the bus lines up with the timeline. The extra delay
/// that lines direct outputs up with aux returns stays in `latency` and is removed
/// only from the main mix.
track_latency: usize,
/// Live input for the next block (planar), set by the audio host for input monitoring.
pub input: Vec<Vec<f32>>,
/// Transport stopped: only monitored inputs sound (no clips).
@@ -308,6 +324,8 @@ pub struct MixEngine {
adopt: HashMap<(TrackId, usize), PreparedInstance>,
/// Instances `sync` took out of service, for the host to drop (see [`MixEngine::take_retired`]).
retired: Vec<Box<dyn Plugin>>,
/// Ownership workspace reused for parallel strip processing.
work: Vec<(usize, Strip)>,
}
/// What `sync` needs to fill a slot.
@@ -363,6 +381,7 @@ impl MixEngine {
synced: (0, 0, 0),
order: Vec::new(),
latency: 0,
track_latency: 0,
input: Vec::new(),
monitor_only: false,
recording: false,
@@ -370,6 +389,7 @@ impl MixEngine {
external: false,
adopt: HashMap::new(),
retired: Vec::new(),
work: Vec::new(),
}
}
@@ -509,7 +529,9 @@ impl MixEngine {
let old = strip.instrument.take();
cx.retire(old);
strip.instrument = want.and_then(|i| cx.make(t.id, INSTRUMENT_SLOT, i, ich));
strip.sample = None;
}
sync_sample(strip, want, s);
}
// Instances nobody claimed (the session changed again before they arrived).
let leftovers: Vec<(TrackId, usize)> = cx.adopt.keys().copied().collect();
@@ -568,16 +590,17 @@ impl MixEngine {
}
let any_solo = s.tracks.iter().any(|t| t.mixer.solo && !t.inactive);
let order = std::mem::take(&mut self.order);
let live = |t: &&Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
let live = |t: &Track| !(t.kind == TrackKind::Master || t.inactive || t.kind == TrackKind::Vca);
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Phase 1: independent strips (audio, instrument, MIDI) in parallel.
let mut work: Vec<(&Track, Strip)> = order
.iter()
.filter_map(|&i| s.tracks.get(i))
.filter(live)
.filter(|t| !is_aux(t))
.filter_map(|t| self.strips.remove(&t.id).map(|st| (t, st)))
.collect();
let mut work = std::mem::take(&mut self.work);
work.clear();
for &i in &order {
let Some(t) = s.tracks.get(i).filter(|t| live(t) && !is_aux(t)) else { continue };
if let Some(st) = self.strips.remove(&t.id) {
work.push((i, st));
}
}
for (_, st) in work.iter_mut() {
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -585,16 +608,30 @@ impl MixEngine {
}
let busses = std::mem::take(&mut self.busses);
let live_in = LiveInput { input: &self.input, monitor_only: self.monitor_only, recording: self.recording };
run_strips(&mut work, |(t, st)| process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo));
run_strips(&mut work, |(i, st)| {
if let Some(t) = s.tracks.get(*i) {
process_strip(s, t, st, &busses, &live_in, pos, frames, any_solo);
}
});
self.busses = busses;
// Delay compensation: align every strip to the slowest; direct-to-main outputs also wait
// for the slowest aux return (aux latencies are from the previous block; they are stable).
// Delay compensation: align every strip to the slowest. Direct-to-main outputs also wait
// for the slowest aux return. Aux latency has to be read before those outputs are routed,
// or the first block (and a one-block bounce probe) still sees zero.
let pdc = s.edit.delay_compensation;
if pdc {
for t in s.tracks.iter().filter(|t| is_aux(t) && !t.inactive) {
if let Some(st) = self.strips.get_mut(&t.id) {
note_insert_latency(t, st, pos);
}
}
}
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
let lmax_a =
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
self.track_latency = lmax_t;
self.latency = lmax_t + lmax_a;
for (t, st) in work.iter_mut() {
for (i, st) in work.iter_mut() {
let Some(t) = s.tracks.get(*i) else { continue };
let ch = st.buf.len();
st.align.set(lmax_t.saturating_sub(st.latency), ch);
st.align.process(&mut st.buf, frames);
@@ -604,15 +641,20 @@ impl MixEngine {
}
st.out_extra.set(if t.mixer.output == Route::Main { lmax_a } else { 0 }, ch);
}
for (t, st) in work.iter_mut() {
self.route_strip(t, st, pos, frames);
for (i, st) in work.iter_mut() {
if let Some(t) = s.tracks.get(*i) {
self.route_strip(t, st, pos, frames);
}
}
for (t, st) in work {
self.strips.insert(t.id, st);
for (i, st) in work.drain(..) {
if let Some(t) = s.tracks.get(i) {
self.strips.insert(t.id, st);
}
}
self.work = work;
let is_aux = |t: &Track| matches!(t.kind, TrackKind::Aux | TrackKind::Folder);
// Phase 2: auxes in dependency order (they read busses).
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(live).filter(|t| is_aux(t)) {
for t in order.iter().filter_map(|&i| s.tracks.get(i)).filter(|t| live(t)).filter(|t| is_aux(t)) {
let Some(mut st) = self.strips.remove(&t.id) else { continue };
if st.scratch.len() < self.max_block {
st.scratch.resize(self.max_block, 0.0);
@@ -679,6 +721,24 @@ impl MixEngine {
self.main_meter.measure(&self.main, frames, 0.0);
}
/// Copy one rendered block of `bus` into `out`. A missing bus stays silent.
fn copy_bus(&self, bus: BusId, frames: usize, out: &mut [Vec<f32>]) {
for o in out.iter_mut() {
for x in o.iter_mut().take(frames) {
*x = 0.0;
}
}
let Some(src) = self.busses.get(&bus) else { return };
for (o, c) in out.iter_mut().zip(src.buf.iter()) {
for i in 0..frames {
let v = c.get(i).copied().unwrap_or(0.0);
if let Some(d) = o.get_mut(i) {
*d = if v.is_finite() { v } else { 0.0 };
}
}
}
}
/// Send, meter and pan a processed strip into the busses / main mix.
fn route_strip(&mut self, t: &Track, strip: &mut Strip, pos: Samples, frames: usize) {
self.meters.entry(t.id).or_default().measure(&strip.buf, frames, strip.gr);
@@ -826,6 +886,14 @@ fn process_strip(
}
TrackKind::Instrument | TrackKind::Midi => {
if let Some(inst) = &mut strip.instrument {
// #152: push the instrument's stored parameter values into the live plugin
// before processing, the same way inserts do below. Without this, the values
// loaded from a session (or set via `mix.insert_state`) never reach the plugin
// instance and the rendered audio matches the plugin's defaults — e.g. a saved
// `level = -12 dB` sounds the same as `level = -6 dB`.
if let Some(ins) = &t.instrument {
apply_params(inst, ins, t, INSTRUMENT_SLOT, pos);
}
schedule_notes(s, t, pos, frames, inst, &mut strip.held);
if strip.buf.len() >= 2 {
inst.plugin.process(&mut strip.buf, frames);
@@ -876,8 +944,11 @@ fn process_strip(
d.extend_from_slice(src.get(..frames).unwrap_or(&[]));
}
}
// Endpoints are the first and last sample of this block. The next block starts on its own
// first sample, so a breakpoint there does not ramp the block before it.
let last = pos.saturating_add(frames.saturating_sub(1) as i64);
let mut v0 = volume_db_at(t, pos);
let mut v1 = volume_db_at(t, pos + frames as i64);
let mut v1 = volume_db_at(t, last);
// Trim automation (an offset on top of the volume curve).
if let Some(l) = t
.automation
@@ -886,14 +957,14 @@ fn process_strip(
.filter(|l| !l.points.is_empty())
{
v0 += l.value_at(pos, 0.0);
v1 += l.value_at(pos + frames as i64, 0.0);
v1 += l.value_at(last, 0.0);
}
// VCA master: its fader offsets the member's, and its mute mutes the member.
if let Some(vid) = t.mixer.vca
&& let Some(vca) = s.tracks.iter().find(|x| x.id.0 == vid && x.kind == TrackKind::Vca)
{
v0 += volume_db_at(vca, pos);
v1 += volume_db_at(vca, pos + frames as i64);
v1 += volume_db_at(vca, last);
if vca.mixer.mute {
strip.muted = true;
}
@@ -902,7 +973,7 @@ fn process_strip(
for c in strip.buf.iter_mut() {
for i in 0..frames {
if let Some(x) = c.get_mut(i) {
*x *= lerp_gain(g0, g1, i, frames);
*x *= lerp_gain(g0, g1, i, frames.saturating_sub(1));
}
}
}
@@ -1002,6 +1073,10 @@ fn structure_fingerprint(s: &Session) -> usize {
snd.target.hash(&mut h);
}
t.instrument.as_ref().map(|x| x.plugin.as_str()).hash(&mut h);
let sample = t.instrument.as_ref().and_then(|x| x.sample);
sample.hash(&mut h);
// Which decoded audio it is, so new audio under the same id (another session) re-syncs.
sample.and_then(|id| s.pool.get(id)).map(|a| Arc::as_ptr(a) as usize).hash(&mut h);
}
for b in &s.busses {
b.id.hash(&mut h);
@@ -1202,10 +1277,12 @@ fn render_clip(s: &Session, clip: &Clip, pos: Samples, frames: usize, buf: &mut
while i < i1 {
let seg_end = (i + STEP).min(i1);
let rel0 = pos.saturating_add(i as i64).saturating_sub(clip.start);
let rel1 = pos.saturating_add(seg_end as i64).saturating_sub(clip.start);
// The last sample of this step, not the first sample of the next one, so a breakpoint
// on the boundary does not ramp the audio before it.
let rel1 = pos.saturating_add(seg_end.saturating_sub(1) as i64).saturating_sub(clip.start);
let g0 = clip.gain_at(rel0);
let g1 = clip.gain_at(rel1.min(clip.length));
let n = (seg_end - i) as f32;
let n = (seg_end - i).saturating_sub(1).max(1) as f32;
for k in i..seg_end {
let rel = pos.saturating_add(k as i64).saturating_sub(clip.start);
let v = if (stretch - 1.0).abs() < 1e-9 {
@@ -1278,21 +1355,80 @@ fn schedule_notes(s: &Session, t: &Track, pos: Samples, frames: usize, inst: &mu
}
}
/// Session audio shared with a sample-playing instrument.
struct PoolSample(Arc<SourceAudio>);
impl SampleSource for PoolSample {
fn sample_rate(&self) -> f32 {
self.0.buffer.sample_rate as f32
}
fn num_channels(&self) -> usize {
self.0.buffer.num_channels()
}
fn channel(&self, ch: usize) -> Option<&[f32]> {
self.0.channel(ch)
}
}
/// Hands a strip's instrument the audio of its insert's sample source when it changed (a new
/// sample, a cleared one, or a new instance). Missing media plays nothing.
fn sync_sample(strip: &mut Strip, want: Option<&soundcraft_model::Insert>, s: &Session) {
let audio = want.and_then(|i| i.sample).and_then(|id| s.pool.get(id));
let same = match (strip.sample.as_ref(), audio) {
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
(None, None) => true,
_ => false,
};
if same {
return;
}
if let Some(inst) = strip.instrument.as_mut() {
let shared = audio.map(|a| Arc::new(PoolSample(Arc::clone(a))) as Arc<dyn SampleSource>);
inst.plugin.set_sample(shared);
}
strip.sample = audio.cloned();
}
/// Record insert latency without processing audio, so this block can compensate for it.
fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) {
let mut latency = 0usize;
for (i, ins) in t.mixer.inserts.iter().enumerate() {
let (Some(ins), Some(Some(slot))) = (ins, strip.plugins.get_mut(i)) else { continue };
if !ins.active || ins.bypass {
continue;
}
apply_params(slot, ins, t, i, pos);
latency = latency.saturating_add(slot.plugin.latency());
}
strip.latency = latency;
}
fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {
let slot_u8 = u8::try_from(i).unwrap_or(0);
// Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it
// must not pick up slot A's lanes.
let Ok(slot_u8) = u8::try_from(i) else {
return apply_values(slot, ins, |_, v| v);
};
let automated = t.mixer.automation_mode.reads()
&& t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
let mut val = *v;
apply_values(slot, ins, |k, val| {
if automated
&& let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))
&& !l.points.is_empty()
{
val = l.value_at(pos, val);
return l.value_at(pos, val);
}
val
});
}
/// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.
fn apply_values(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, value: impl Fn(&str, f32) -> f32) {
if slot.applied.len() != ins.params.len() {
slot.applied = vec![f32::NAN; ins.params.len()];
}
for ((k, v), last) in ins.params.iter().zip(slot.applied.iter_mut()) {
let val = value(k, *v);
if val.to_bits() != last.to_bits() {
slot.plugin.set_param(k, val);
*last = val;
@@ -1640,6 +1776,46 @@ pub fn render_range(s: &Session, range: Range, block: usize) -> Vec<Vec<f32>> {
out
}
/// Offline render of one bus over `range` (planar, the bus format's channel count).
/// Track-insert compensation is removed, so a bus fed by tracks lines up with the timeline.
/// An unknown bus is silent.
pub fn render_bus(s: &Session, bus: BusId, range: Range, block: usize) -> Vec<Vec<f32>> {
let nch = s.bus(bus).map(|b| b.format.channels()).unwrap_or(0);
if nch == 0 {
return Vec::new();
}
let len = usize::try_from(range.len().max(0)).unwrap_or(0).min(MAX_RENDER_SAMPLES / nch);
let latency = {
let mut probe = MixEngine::new(s.sample_rate.as_f64() as f32, block);
let b = probe.max_block();
probe.render(s, range.start, b.min(len.max(1)), &mut []);
probe.track_latency
};
let total = len.saturating_add(latency);
let mut out = vec![vec![0.0f32; total]; nch];
let mut eng = MixEngine::new(s.sample_rate.as_f64() as f32, block);
let b = eng.max_block();
let mut tmp = vec![vec![0.0f32; b]; nch];
let mut done = 0usize;
while done < total {
let n = (total - done).min(b);
eng.render(s, range.start + done as i64, n, &mut []);
eng.copy_bus(bus, n, &mut tmp);
for (o, t) in out.iter_mut().zip(tmp.iter()) {
if let (Some(d), Some(sr)) = (o.get_mut(done..done + n), t.get(..n)) {
d.copy_from_slice(sr);
}
}
done += n;
}
if latency > 0 {
for c in &mut out {
c.drain(..latency.min(c.len()));
}
}
out
}
/// ITU-R BS.775 stereo fold-down of planar audio in `fmt` (C and surrounds at -3 dB, LFE dropped;
/// Ambisonics play their W channel). Mono/stereo input is returned unchanged.
pub fn fold_down_stereo(fmt: ChannelFormat, ch: &[Vec<f32>]) -> Vec<Vec<f32>> {
@@ -1800,6 +1976,46 @@ mod tests {
use soundcraft_model::{Insert, SourceAudio, SourceId};
use std::sync::Arc;
fn synth_session(level_db: f32) -> Session {
use soundcraft_midi::{Note, Sequence};
let mut s = Session::default();
let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);
let mut seq = Sequence::default();
seq.notes.push(Note { pitch: 60, velocity: 100, release_velocity: 64, channel: 0, start: 0, length: 960 });
let cid = s.new_clip_id();
let tr = s.track_mut(t).unwrap();
tr.playlist_mut().unwrap().clips.push(Clip::midi(cid, "n", 0, 24_000, seq));
let mut ins = Insert::new("subtractive_synth");
ins.params.insert("level".into(), level_db);
tr.instrument = Some(ins);
s
}
#[test]
fn stored_instrument_params_reach_the_plugin() {
// #152: a saved instrument level of -12 dB must render quieter than -6 dB.
// RMS over the held part of the note, past any parameter smoothing.
let rms = |db: f32| {
let out = render_range(&synth_session(db), Range::new(0, 12_000), 512);
let tail = out[0].get(6_000..).unwrap_or_default();
(tail.iter().map(|x| x * x).sum::<f32>() / tail.len().max(1) as f32).sqrt()
};
let (loud, quiet) = (rms(-6.0), rms(-12.0));
assert!(loud > 0.0, "the synth should sound");
assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");
}
#[test]
fn insert_slot_a_automation_does_not_drive_the_instrument() {
let mut s = synth_session(-6.0);
let reference = render_range(&s, Range::new(0, 12_000), 512);
let tr = &mut s.tracks[0];
let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: 0, param: "level".into() });
lane.set_point(0, -48.0);
tr.automation.push(lane);
assert_eq!(render_range(&s, Range::new(0, 12_000), 512), reference);
}
fn session_with_dc(level: f32, frames: usize) -> (Session, TrackId) {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
@@ -1879,6 +2095,20 @@ mod tests {
assert!(out[0][1000] > 0.1, "aux should carry the send: {}", out[0][1000]);
}
#[test]
fn render_bus_is_the_bus_not_the_main_mix() {
let (mut s, t) = session_with_dc(0.5, 4800);
let stem = s.add_bus("Stem", ChannelFormat::Stereo);
let empty = s.add_bus("Empty", ChannelFormat::Stereo);
s.track_mut(t).unwrap().mixer.output = Route::Bus(stem);
let main = render_range(&s, Range::new(0, 4800), 512);
let bus = render_bus(&s, stem, Range::new(0, 4800), 512);
let silent = render_bus(&s, empty, Range::new(0, 4800), 512);
assert!(main[0][1000].abs() < 1e-4, "main should not carry a track routed to the bus: {}", main[0][1000]);
assert!(bus[0][1000].abs() > 0.1, "bus should carry the track: {}", bus[0][1000]);
assert!(silent.iter().all(|c| c.iter().all(|x| x.abs() < 1e-6)));
}
#[test]
fn volume_automation_ramps() {
let (mut s, t) = session_with_dc(0.5, 48_000);
@@ -2057,6 +2287,53 @@ mod tests {
assert_ne!(argmax(&out[0]), argmax(&out[1]));
}
fn impulse_at(ch: &[f32]) -> Vec<usize> {
ch.iter().enumerate().filter(|(_, v)| v.abs() > 1e-4).map(|(i, _)| i).collect()
}
/// An aux insert's lookahead is part of the mix latency on the first block, not the block after.
#[test]
fn delay_compensation_aligns_an_aux_insert() {
let mut s = Session::default();
let n = 8192;
let mut left = vec![0.0f32; n];
for at in [0usize, 2048, 8000] {
left[at] = 0.25;
}
s.pool.insert(SourceId(901), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![left.clone(), left] })));
let bus = s.add_bus("Test Bus", ChannelFormat::Stereo);
let source = s.add_track(TrackKind::Audio, ChannelFormat::Stereo, Some("Source"));
let aux = s.add_track(TrackKind::Aux, ChannelFormat::Stereo, Some("Return"));
let cid = s.new_clip_id();
s.track_mut(source).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(cid, "impulses", SourceId(901), 0, 0, n as i64));
s.track_mut(source).unwrap().mixer.output = Route::Bus(bus);
s.track_mut(aux).unwrap().mixer.input = Route::Bus(bus);
let render = |s: &Session, start: i64, end: i64| render_range(s, Range::new(start, end), 1024);
let control = render(&s, 0, n as i64);
assert_eq!(impulse_at(&control[0]), vec![0, 2048, 8000]);
assert_eq!(impulse_at(&control[1]), impulse_at(&control[0]));
let mut comp = Insert::new("compressor");
comp.params.insert("lookahead".into(), 5.0);
comp.params.insert("ratio".into(), 1.0);
s.track_mut(source).unwrap().mixer.inserts[0] = Some(comp.clone());
let on_source = render(&s, 0, n as i64);
assert_eq!(impulse_at(&on_source[0]), impulse_at(&control[0]));
s.track_mut(source).unwrap().mixer.inserts[0] = None;
s.track_mut(aux).unwrap().mixer.inserts[0] = Some(comp);
let on_aux = render(&s, 0, n as i64);
assert_eq!(impulse_at(&on_aux[0]), vec![0, 2048, 8000], "aux lookahead must stay inside the bounce");
assert_eq!(impulse_at(&on_aux[1]), impulse_at(&on_aux[0]));
assert!((on_aux[0][8000] - 0.25).abs() < 1e-3, "{}", on_aux[0][8000]);
let partial = render(&s, 2048, 3072);
assert_eq!(impulse_at(&partial[0]), vec![0]);
s.track_mut(aux).unwrap().mixer.inserts[0].as_mut().unwrap().params.insert("lookahead".into(), 0.0);
let zero = render(&s, 0, n as i64);
assert_eq!(impulse_at(&zero[0]), impulse_at(&control[0]));
}
#[test]
fn input_monitoring_replaces_clips() {
let (mut s, t) = session_with_dc(0.5, 4800);
@@ -2085,6 +2362,21 @@ mod tests {
assert!(out.iter().all(|c| c.iter().all(|x| *x == 0.0)));
}
#[test]
fn strip_workspace_keeps_its_allocation_between_blocks() {
let mut s = Session::default();
for _ in 0..6 {
s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
}
let mut eng = MixEngine::new(48_000.0, 512);
let mut out = vec![vec![0.0; 512]; 2];
eng.render(&s, 0, 512, &mut out);
let capacity = eng.work.capacity();
assert!(capacity >= 6);
eng.render(&s, 512, 512, &mut out);
assert_eq!(eng.work.capacity(), capacity);
}
#[test]
fn stopped_monitoring_does_not_click() {
let mut s = Session::default();
+31 -3
View File
@@ -1,6 +1,6 @@
//! Per-track mixer state: inserts, sends, routing, fader, pan, mute/solo.
use crate::{AutomationMode, BusId};
use crate::{AutomationMode, BusId, SourceId};
use std::collections::BTreeMap;
pub const INSERT_SLOTS: usize = 10;
@@ -425,7 +425,7 @@ pub enum Route {
pub struct Insert {
/// Registry id from `soundcraft-dsp` (e.g. `eq7`, `comp`).
pub plugin: String,
#[serde(default)]
#[serde(default, deserialize_with = "finite_params")]
pub params: BTreeMap<String, f32>,
#[serde(default)]
pub bypass: bool,
@@ -438,6 +438,9 @@ pub struct Insert {
/// state), standard base64. Restored into a new instance before it first processes.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// The session audio source a sample-playing instrument (the built-in Sampler) plays.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sample: Option<SourceId>,
}
fn yes() -> bool {
@@ -446,7 +449,15 @@ fn yes() -> bool {
impl Insert {
pub fn new(plugin: impl Into<String>) -> Self {
Insert { plugin: plugin.into(), params: BTreeMap::new(), bypass: false, active: true, preset: String::from("<factory default>"), state: None }
Insert {
plugin: plugin.into(),
params: BTreeMap::new(),
bypass: false,
active: true,
preset: String::from("<factory default>"),
state: None,
sample: None,
}
}
/// The decoded plugin state (`None` when absent or malformed).
@@ -577,6 +588,13 @@ pub(crate) fn finite_or(v: f32, d: f32) -> f32 {
if v.is_finite() { v } else { d }
}
/// Parameters saved as `null` (how JSON writes NaN and infinity) or beyond `f32` are dropped, so
/// they keep their defaults instead of failing the whole session.
fn finite_params<'de, D: serde::Deserializer<'de>>(d: D) -> Result<BTreeMap<String, f32>, D::Error> {
let params: BTreeMap<String, Option<f64>> = serde::Deserialize::deserialize(d)?;
Ok(params.into_iter().filter_map(|(k, v)| Some((k, v.map(|v| v as f32).filter(|v| v.is_finite())?))).collect())
}
/// Fader taper used by Pro Tools-style faders: position 0..1 ↔ dB (-inf..+12).
pub fn fader_pos_to_db(pos: f32) -> f32 {
let p = finite_or(pos, 0.0).clamp(0.0, 1.0);
@@ -624,6 +642,16 @@ mod tests {
assert_eq!(bad.state_bytes(), None);
}
#[test]
fn params_saved_as_null_or_out_of_range_are_dropped() {
let mut i = Insert::new("eq_7band");
i.params.extend([("gain".to_string(), f32::NAN), ("q".to_string(), 0.7)]);
let back: Insert = serde_json::from_str(&serde_json::to_string(&i).unwrap()).unwrap();
assert_eq!(back.params, BTreeMap::from([("q".to_string(), 0.7)]));
let big: Insert = serde_json::from_str(r#"{"plugin":"x","params":{"gain":1e39,"q":0.5}}"#).unwrap();
assert_eq!(big.params, BTreeMap::from([("q".to_string(), 0.5)]));
}
#[test]
fn fader_taper_round_trips() {
for db in [-100.0f32, -60.0, -24.0, -10.0, 0.0, 6.0, 12.0] {
+17
View File
@@ -160,6 +160,9 @@ pub struct EditState {
/// Clips explicitly selected (Grabber selection).
#[serde(default)]
pub selected_clips: Vec<ClipId>,
/// Audio files selected in the Clip List.
#[serde(default)]
pub selected_sources: Vec<SourceId>,
/// Playhead position when stopped.
pub playhead: Samples,
pub grid: GridValue,
@@ -234,6 +237,7 @@ impl Default for EditState {
selection: Range::default(),
selected_tracks: Vec::new(),
selected_clips: Vec::new(),
selected_sources: Vec::new(),
playhead: 0,
grid: GridValue::default(),
nudge: GridValue::Seconds(1.0),
@@ -308,6 +312,9 @@ pub struct Session {
pub midi_originals: std::collections::BTreeMap<u64, soundcraft_midi::Sequence>,
#[serde(skip)]
pub pool: SourcePool,
/// The folder relative source paths point into: where the session was opened or last saved.
#[serde(skip)]
pub folder: Option<std::path::PathBuf>,
}
impl Default for Session {
@@ -341,6 +348,7 @@ impl Session {
comments: String::new(),
midi_originals: std::collections::BTreeMap::new(),
pool: SourcePool::default(),
folder: None,
};
s.edit.pre_roll = sample_rate.samples(2.0);
s.edit.post_roll = sample_rate.samples(2.0);
@@ -399,6 +407,15 @@ impl Session {
self.tracks.iter_mut().find_map(|t| t.playlist_mut().and_then(|p| p.clip_mut(id)))
}
/// Clips on every playlist, alternates included.
pub fn all_clips(&self) -> impl Iterator<Item = &Clip> {
self.tracks.iter().flat_map(|t| t.playlists.iter().flat_map(|p| &p.clips))
}
pub fn clips_using(&self, src: SourceId) -> impl Iterator<Item = &Clip> {
self.all_clips().filter(move |c| c.source() == Some(src))
}
/// End of the last clip on any track (session length).
pub fn content_end(&self) -> Samples {
self.tracks.iter().filter_map(|t| t.playlist().map(|p| p.end())).max().unwrap_or(0)
+1 -3
View File
@@ -9,6 +9,7 @@ repository.workspace = true
[dependencies]
soundcraft-time.workspace = true
soundcraft-audio-io.workspace = true
soundcraft-model.workspace = true
soundcraft-mix.workspace = true
soundcraft-dsp.workspace = true
@@ -20,8 +21,5 @@ cpal.workspace = true
[target.'cfg(target_arch = "wasm32")'.dependencies]
cpal = { workspace = true, features = ["wasm-bindgen"] }
[dev-dependencies]
soundcraft-audio-io.workspace = true
[lints]
workspace = true
+96 -18
View File
@@ -86,7 +86,6 @@ struct AudioState {
playing: bool,
end: Option<Samples>,
looped: Option<Range>,
block: Vec<Vec<f32>>,
/// Rendered frames waiting to be consumed (planar, main channels) and the fractional read index.
pending: Vec<Vec<f32>>,
/// Stereo fold-down gains per main channel (used when the device has fewer channels).
@@ -122,7 +121,6 @@ impl AudioState {
playing: false,
end: None,
looped: None,
block: vec![vec![0.0; BLOCK]; nch],
pending: vec![vec![0.0; BLOCK]; nch],
pending_len: 0,
read: 0.0,
@@ -151,8 +149,7 @@ impl AudioState {
self.mix.adopt(instances);
// The main format changed (a document edit, not steady state): resize.
let nch = main_channels(&s);
if self.block.len() != nch {
self.block = vec![vec![0.0; BLOCK]; nch];
if self.pending.len() != nch {
self.pending = vec![vec![0.0; BLOCK]; nch];
self.pending_len = 0;
self.read = 0.0;
@@ -171,6 +168,8 @@ impl AudioState {
}
Cmd::Stop => {
self.playing = false;
self.end = None;
self.looped = None;
self.mix.reset();
}
Cmd::Locate(at) => {
@@ -197,8 +196,8 @@ impl AudioState {
}
/// Render the next block at the session rate into `pending`.
fn render_next(&mut self) {
let mut n = BLOCK;
fn render_next(&mut self, quantum: usize) {
let mut n = quantum.clamp(1, BLOCK);
if let Some(l) = self.looped
&& self.pos >= l.end
{
@@ -209,6 +208,7 @@ impl AudioState {
} else if let Some(e) = self.end {
if self.pos >= e {
self.playing = false;
self.end = None;
self.shared.playing.store(false, Ordering::Relaxed);
n = 0;
} else {
@@ -231,19 +231,9 @@ impl AudioState {
}
self.mix.monitor_only = !self.playing;
self.mix.recording = self.recording;
self.mix.render(&self.session, self.pos, n, &mut self.block);
self.mix.render(&self.session, self.pos, n, &mut self.pending);
self.send_garbage();
for (d, s) in self.pending.iter_mut().zip(self.block.iter()) {
if let (Some(dd), Some(ss)) = (d.get_mut(..n), s.get(..n)) {
dd.copy_from_slice(ss);
}
}
self.pending_len = n;
if !self.playing {
return;
}
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
if let Ok(mut m) = self.shared.meters.try_lock() {
m.main.copy_from(&self.mix.main_meter);
for (k, v) in &self.mix.meters {
@@ -255,6 +245,10 @@ impl AudioState {
}
}
}
if self.playing {
self.pos += n as i64;
self.shared.position.store(self.pos, Ordering::Relaxed);
}
}
/// Fill an interleaved device buffer.
@@ -272,13 +266,15 @@ impl AudioState {
}
let speed = f64::from(self.shared.speed.load(Ordering::Relaxed)) / 1000.0;
let step = self.session.sample_rate.as_f64() / self.device_rate.max(1.0) * speed.clamp(0.1, 4.0);
let callback_frames = out.len() / channels;
let quantum = ((callback_frames as f64 * step).ceil() as usize).clamp(1, BLOCK);
for frame in out.chunks_mut(channels) {
while self.read >= self.pending_len as f64 {
self.read -= self.pending_len as f64;
if !self.playing && !monitoring {
break;
}
self.render_next();
self.render_next(quantum);
if self.pending_len == 0 {
break;
}
@@ -584,6 +580,10 @@ impl Player {
self.side().editors.get(&(track, slot)).is_some_and(|e| e.is_open())
}
pub fn any_editor_open(&self) -> bool {
self.side().editors.values().any(|e| e.is_open())
}
/// Shows a parameter change made elsewhere in the plugin's open editor.
pub fn editor_set_param(&self, track: TrackId, slot: usize, id: &str, value: f32) {
if let Some(e) = self.side().editors.get_mut(&(track, slot)) {
@@ -622,6 +622,11 @@ impl Player {
self.shared.speed.store(v, Ordering::Relaxed);
}
/// Playback rate last requested, 1.0 at full speed.
pub fn speed(&self) -> f32 {
self.shared.speed.load(Ordering::Relaxed) as f32 / 1000.0
}
pub fn position(&self) -> Samples {
self.shared.position.load(Ordering::Relaxed)
}
@@ -633,6 +638,21 @@ impl Player {
pub fn meters(&self) -> MeterSnapshot {
self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
/// Copy meters into reusable UI storage. Once track/channel counts are stable this performs
/// no allocation, unlike cloning a new snapshot every video frame.
pub fn copy_meters(&self, out: &mut MeterSnapshot) {
let source = self.shared.meters.lock().unwrap_or_else(PoisonError::into_inner);
copy_meter_snapshot(&source, out);
}
}
fn copy_meter_snapshot(source: &MeterSnapshot, out: &mut MeterSnapshot) {
out.tracks.retain(|id, _| source.tracks.contains_key(id));
for (id, meter) in &source.tracks {
out.tracks.entry(*id).or_default().copy_from(meter);
}
out.main.copy_from(&source.main);
}
impl Side {
@@ -730,6 +750,21 @@ fn open_device(want_rate: u32, want_ch: u16) -> Result<(cpal::Device, cpal::Stre
mod tests {
use super::*;
#[test]
fn reusable_meter_snapshot_updates_and_drops_stale_tracks() {
let keep = TrackId(1);
let stale = TrackId(2);
let mut source = MeterSnapshot::default();
source.tracks.insert(keep, StripMeter { peak: [0.5, 0.25], peaks: vec![0.5, 0.25], gain_reduction_db: 3.0 });
source.main.peaks = vec![0.75, 0.5];
let mut out = MeterSnapshot::default();
out.tracks.insert(stale, StripMeter::default());
copy_meter_snapshot(&source, &mut out);
assert_eq!(out.tracks.len(), 1);
assert_eq!(out.tracks[&keep], source.tracks[&keep]);
assert_eq!(out.main, source.main);
}
#[test]
fn audio_state_plays_and_stops_at_end() {
let s = Arc::new(soundcraft_model::Session::default());
@@ -748,6 +783,23 @@ mod tests {
assert!(!shared.playing.load(Ordering::Relaxed));
}
#[test]
fn short_device_callbacks_render_short_mix_blocks() {
let s = Arc::new(soundcraft_model::Session::default());
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), s, 48_000.0, channel().0);
tx.send(Cmd::Play { from: 0, end: None, looped: None }).unwrap();
let mut out = vec![0.0f32; 2 * 64];
st.fill(&mut out, 2);
assert_eq!(shared.position.load(Ordering::Relaxed), 64);
}
#[test]
fn surround_main_maps_to_device_channels_or_folds_down() {
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
@@ -896,4 +948,30 @@ mod tests {
assert!(p <= 1000, "position {p} should stay inside the loop");
assert!(shared.playing.load(Ordering::Relaxed));
}
#[test]
fn armed_tracks_are_heard_and_metered_after_playback_stops() {
use soundcraft_model::{ChannelFormat, TrackKind};
let mut s = soundcraft_model::Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
s.track_mut(t).unwrap().mixer.record_arm = true;
let shared = Arc::new(Shared {
position: AtomicI64::new(0),
playing: AtomicBool::new(false),
speed: AtomicU32::new(1000),
meters: Mutex::new(MeterSnapshot::default()),
});
let (tx, rx) = channel();
let mut st = AudioState::new(rx, Arc::clone(&shared), Arc::new(s), 48_000.0, channel().0);
let ring = record::InputRing::new(1, 48_000);
tx.send(Cmd::Input(Arc::clone(&ring))).unwrap();
tx.send(Cmd::Play { from: 0, end: Some(1000), looped: None }).unwrap();
let mut out = vec![0.0f32; 4096];
st.fill(&mut out, 2);
assert!(!shared.playing.load(Ordering::Relaxed));
ring.push(&[0.5; 4096]);
st.fill(&mut out, 2);
assert!(out.iter().any(|x| x.abs() > 0.1), "the input is heard");
assert!(shared.meters.lock().unwrap().tracks.get(&t).is_some_and(|m| m.peak[0] > 0.1), "and metered");
}
}
+233 -70
View File
@@ -1,22 +1,78 @@
//! Audio input capture for recording.
//!
//! A [`Recorder`] opens the default input device and appends interleaved samples to a shared
//! buffer while armed. The callback only `try_lock`s; if the UI thread holds the lock the block is
//! kept in a small local backlog and appended next time, so nothing blocks the audio thread.
//! A [`Recorder`] opens the default input device. While armed, the input callback copies each
//! block into a preallocated lock-free ring (it never locks or allocates), and a writer thread
//! streams the ring into one WAV file per take, rewriting the header every second so a crash leaves
//! a playable file.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, PoisonError};
use std::fs::File;
use std::io::{Seek, SeekFrom, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Captured audio: planar f32 at `sample_rate`.
#[derive(Debug, Clone, Default)]
pub struct Take {
pub sample_rate: u32,
pub channels: Vec<Vec<f32>>,
use soundcraft_model::TrackId;
/// What [`Recorder::finish`] returns: the takes that hold audio and the first problem met.
pub type Takes = (Vec<(TrackId, PathBuf)>, Option<String>);
const RING_SECONDS: usize = 4;
/// Single-producer single-consumer FIFO of samples (input callback → writer thread). The slots
/// are atomics, so it needs no lock and no `unsafe`.
struct CaptureRing {
slots: Box<[AtomicU32]>,
mask: usize,
head: AtomicUsize,
tail: AtomicUsize,
}
impl CaptureRing {
fn new(capacity: usize) -> CaptureRing {
let n = capacity.max(2).next_power_of_two();
CaptureRing { slots: (0..n).map(|_| AtomicU32::new(0)).collect(), mask: n - 1, head: AtomicUsize::new(0), tail: AtomicUsize::new(0) }
}
/// Queue up to `n` samples (`sample(i)` is the i-th); returns how many fit.
fn push(&self, n: usize, sample: impl Fn(usize) -> f32) -> usize {
let head = self.head.load(Ordering::Relaxed);
let n = n.min(self.slots.len().saturating_sub(head.wrapping_sub(self.tail.load(Ordering::Acquire))));
for i in 0..n {
if let Some(slot) = self.slots.get(head.wrapping_add(i) & self.mask) {
slot.store(sample(i).to_bits(), Ordering::Relaxed);
}
}
self.head.store(head.wrapping_add(n), Ordering::Release);
n
}
fn pop_all(&self, out: &mut Vec<f32>) {
let (tail, head) = (self.tail.load(Ordering::Relaxed), self.head.load(Ordering::Acquire));
out.extend(
(0..head.wrapping_sub(tail))
.map(|i| self.slots.get(tail.wrapping_add(i) & self.mask).map_or(0.0, |s| f32::from_bits(s.load(Ordering::Relaxed)))),
);
self.tail.store(head, Ordering::Release);
}
}
struct Shared {
armed: AtomicBool,
data: Mutex<Vec<f32>>,
ring: CaptureRing,
/// Samples lost while the ring was full.
dropped: AtomicUsize,
}
impl Shared {
/// The input callback's work while armed. Samples that don't fit are owed (`owed`) and queued
/// as silence before the next block, so the take keeps its timing.
fn capture(&self, data: &[f32], owed: &mut usize) {
*owed -= self.ring.push(*owed, |_| 0.0);
let kept = if *owed == 0 { self.ring.push(data.len(), |i| data.get(i).copied().unwrap_or(0.0)) } else { 0 };
*owed = owed.saturating_add(data.len() - kept);
self.dropped.fetch_add(data.len() - kept, Ordering::Relaxed);
}
}
pub struct Recorder {
@@ -28,12 +84,9 @@ pub struct Recorder {
pub device_name: String,
pub sample_rate: u32,
pub channels: usize,
writer: Option<std::thread::JoinHandle<Takes>>,
}
/// Cap: one hour of 8-channel 192 kHz audio; 64 Mi samples (256 MB of f32) on 32-bit targets
/// (wasm32, i686), where the 64-bit product doesn't fit in `usize`.
const MAX_SAMPLES: usize = if usize::BITS >= 64 { (3600u64 * 192_000 * 8) as usize } else { 64 << 20 };
impl Recorder {
/// Open the default input device. Errors when there is none (the caller shows a message).
#[cfg(not(target_os = "freebsd"))]
@@ -48,29 +101,23 @@ impl Recorder {
return Err(format!("unsupported input sample format {:?}", cfg.sample_format()));
}
let config = cfg.config();
let shared = Arc::new(Shared { armed: AtomicBool::new(false), data: Mutex::new(Vec::new()) });
let channels = usize::from(config.channels).max(1);
let ring = CaptureRing::new((config.sample_rate.0 as usize).saturating_mul(channels * RING_SECONDS).min(1 << 25));
let shared = Arc::new(Shared { armed: AtomicBool::new(false), ring, dropped: AtomicUsize::new(0) });
let sh = Arc::clone(&shared);
let monitor = InputRing::new(usize::from(config.channels), config.sample_rate.0);
let mon = Arc::clone(&monitor);
let mut backlog: Vec<f32> = Vec::new();
let mut owed = 0;
let stream = device
.build_input_stream(
&config,
move |data: &[f32], _: &cpal::InputCallbackInfo| {
crate::mark_audio_thread();
mon.push(data);
if !sh.armed.load(Ordering::Relaxed) {
backlog.clear();
return;
}
if let Ok(mut d) = sh.data.try_lock() {
if d.len() < MAX_SAMPLES {
d.extend_from_slice(&backlog);
d.extend_from_slice(data);
}
backlog.clear();
} else if backlog.len() < 1 << 20 {
backlog.extend_from_slice(data);
if sh.armed.load(Ordering::Acquire) {
sh.capture(data, &mut owed);
} else {
owed = 0;
}
},
|e| {
@@ -82,14 +129,7 @@ impl Recorder {
)
.map_err(|e| e.to_string())?;
stream.play().map_err(|e| e.to_string())?;
Ok(Recorder {
monitor,
shared,
_stream: Some(stream),
device_name: name,
sample_rate: config.sample_rate.0,
channels: usize::from(config.channels).max(1),
})
Ok(Recorder { monitor, shared, _stream: Some(stream), device_name: name, sample_rate: config.sample_rate.0, channels, writer: None })
}
#[cfg(target_os = "freebsd")]
@@ -97,51 +137,174 @@ impl Recorder {
Err("recording is not supported on this platform yet".into())
}
/// Start capturing (clears any previous take).
pub fn arm(&self) {
self.shared.data.lock().unwrap_or_else(PoisonError::into_inner).clear();
self.shared.armed.store(true, Ordering::Relaxed);
/// Create each take's file and start recording into them: a track's take records the given
/// input channels into a new file.
pub fn arm(&mut self, takes: Vec<(TrackId, Vec<usize>, PathBuf)>) -> Result<(), String> {
if self.writer.is_some() {
return Err("already recording".into());
}
let takes =
takes.into_iter().map(|(id, inputs, path)| TakeWriter::create(id, inputs, path, self.sample_rate)).collect::<Result<Vec<_>, _>>()?;
let ring = &self.shared.ring;
ring.tail.store(ring.head.load(Ordering::Acquire), Ordering::Release);
self.shared.armed.store(true, Ordering::Release);
let (shared, channels) = (Arc::clone(&self.shared), self.channels);
match std::thread::Builder::new().name("take writer".into()).spawn(move || write_takes(&shared, takes, channels)) {
Ok(w) => self.writer = Some(w),
Err(e) => {
self.shared.armed.store(false, Ordering::Release);
return Err(e.to_string());
}
}
Ok(())
}
pub fn is_armed(&self) -> bool {
self.shared.armed.load(Ordering::Relaxed)
}
/// Stop capturing and return the take.
pub fn take(&self) -> Take {
self.shared.armed.store(false, Ordering::Relaxed);
let data = std::mem::take(&mut *self.shared.data.lock().unwrap_or_else(PoisonError::into_inner));
deinterleave(&data, self.channels, self.sample_rate)
}
/// Samples captured so far (per channel).
pub fn captured_frames(&self) -> usize {
self.shared.data.try_lock().map_or(0, |d| d.len() / self.channels.max(1))
pub fn finish(&mut self) -> Takes {
self.shared.armed.store(false, Ordering::Release);
let (takes, problem) = self.writer.take().and_then(|w| w.join().ok()).unwrap_or_default();
let lost = self.shared.dropped.swap(0, Ordering::Relaxed) / self.channels.max(1);
let slow =
(lost > 0).then(|| format!("{} ms of input lost: the disk was too slow", lost.saturating_mul(1000) / self.sample_rate.max(1) as usize));
(takes, problem.or(slow))
}
}
pub fn deinterleave(data: &[f32], channels: usize, sample_rate: u32) -> Take {
let ch = channels.max(1);
let frames = data.len() / ch;
let mut out = vec![Vec::with_capacity(frames); ch];
for frame in data.chunks_exact(ch) {
for (c, v) in frame.iter().enumerate() {
if let Some(o) = out.get_mut(c) {
o.push(*v);
}
impl Drop for Recorder {
fn drop(&mut self) {
self.finish();
}
}
fn write_takes(shared: &Shared, mut takes: Vec<TakeWriter>, channels: usize) -> Takes {
let (mut block, mut header_written) = (Vec::new(), Instant::now());
loop {
let stopping = !shared.armed.load(Ordering::Acquire);
// The ring holds interleaved samples and a pop can end mid-frame: write whole frames and
// keep the remainder for the next pop, so channels never shift.
shared.ring.pop_all(&mut block);
let whole = block.len() - block.len() % channels.max(1);
takes.iter_mut().for_each(|t| t.write(&block[..whole], channels));
block.drain(..whole);
if stopping {
break;
}
if header_written.elapsed() >= Duration::from_secs(1) {
header_written = Instant::now();
takes.iter_mut().for_each(TakeWriter::write_header);
}
std::thread::sleep(Duration::from_millis(10));
}
let (mut done, mut problem) = (Vec::new(), None);
for mut t in takes {
t.write_header();
if t.frames == 0 {
let _ = std::fs::remove_file(&t.path);
} else {
done.push((t.id, t.path));
}
problem = problem.or(t.error);
}
(done, problem)
}
/// One take's file. After an error it stops writing; its header keeps covering what was written.
struct TakeWriter {
id: TrackId,
inputs: Vec<usize>,
path: PathBuf,
file: File,
rate: u32,
frames: u64,
error: Option<String>,
}
impl TakeWriter {
fn create(id: TrackId, inputs: Vec<usize>, path: PathBuf, rate: u32) -> Result<TakeWriter, String> {
let file = path.parent().map_or(Ok(()), std::fs::create_dir_all).and_then(|()| File::create_new(&path));
let file = file.map_err(|e| format!("{}: {e}", path.display()))?;
let mut take = TakeWriter { id, inputs, path, file, rate, frames: 0, error: None };
take.write_header();
take.error.take().map_or(Ok(take), Err)
}
/// Append this take's channels of `block` (interleaved, `channels` wide).
fn write(&mut self, block: &[f32], channels: usize) {
let channels = channels.max(1);
if self.error.is_some() {
return;
}
let bytes: Vec<u8> = block
.chunks_exact(channels)
.flat_map(|f| self.inputs.iter().flat_map(move |&i| f.get(i).copied().unwrap_or(0.0).to_le_bytes()))
.collect();
match self.file.write_all(&bytes) {
Ok(()) => self.frames += (block.len() / channels) as u64,
Err(e) => self.fail(e),
}
}
Take { sample_rate, channels: out }
fn write_header(&mut self) {
let header = match soundcraft_audio_io::wav_float_header(self.inputs.len(), self.rate, self.frames) {
Ok(h) => h,
Err(e) => return self.fail(e),
};
let f = &mut self.file;
if let Err(e) = f.seek(SeekFrom::Start(0)).and_then(|_| f.write_all(&header)).and_then(|()| f.seek(SeekFrom::End(0))) {
self.fail(e);
}
}
fn fail(&mut self, e: impl std::fmt::Display) {
self.error.get_or_insert_with(|| format!("{}: {e}", self.path.display()));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn decode(path: &PathBuf) -> Vec<Vec<f32>> {
soundcraft_audio_io::decode(&std::fs::read(path).unwrap(), None).unwrap().1.channels
}
#[test]
fn deinterleaves() {
let t = super::deinterleave(&[1.0, 2.0, 3.0, 4.0, 5.0], 2, 48_000);
assert_eq!(t.channels, vec![vec![1.0, 3.0], vec![2.0, 4.0]]);
let t = super::deinterleave(&[], 0, 48_000);
assert_eq!(t.channels.len(), 1);
fn a_full_ring_owes_the_lost_samples_as_silence() {
let shared = Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(8), dropped: AtomicUsize::new(0) };
let (mut owed, mut out) = (0, Vec::new());
shared.capture(&[1.0; 6], &mut owed);
shared.capture(&[2.0; 4], &mut owed);
shared.ring.pop_all(&mut out);
shared.capture(&[3.0; 2], &mut owed);
shared.ring.pop_all(&mut out);
assert_eq!(out, [vec![1.0; 6], vec![2.0; 2], vec![0.0; 2], vec![3.0; 2]].concat());
assert_eq!((owed, shared.dropped.load(Ordering::Relaxed)), (0, 2));
}
#[test]
fn takes_stream_into_their_files() {
let dir = std::env::temp_dir().join(format!("soundcraft-takes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let gtr = TakeWriter::create(TrackId(1), vec![1, 2], dir.join("Gtr_01.wav"), 48_000).unwrap();
let mut vox = TakeWriter::create(TrackId(2), vec![0], dir.join("Vox_01.wav"), 48_000).unwrap();
assert!(TakeWriter::create(TrackId(3), vec![0], dir.join("Vox_01.wav"), 48_000).is_err(), "never overwrites a file");
vox.write(&[0.5; 300], 3);
vox.write_header();
vox.write(&[0.25; 150], 3);
assert_eq!(decode(&dir.join("Vox_01.wav"))[0].len(), 100, "the header covers what it has seen");
let shared = Arc::new(Shared { armed: AtomicBool::new(true), ring: CaptureRing::new(1 << 16), dropped: AtomicUsize::new(0) });
let sh = Arc::clone(&shared);
let writer = std::thread::spawn(move || write_takes(&sh, vec![gtr, vox], 3));
let input: Vec<f32> = (0..3 * 4_800).map(|i| i as f32 / 20_000.0).collect();
let mut owed = 0;
input.chunks(3 * 480).for_each(|block| shared.capture(block, &mut owed));
shared.armed.store(false, Ordering::Release);
let (done, problem) = writer.join().unwrap();
let channel = |c: usize| input.iter().skip(c).step_by(3).copied().collect::<Vec<f32>>();
assert_eq!((done.len(), problem), (2, None));
assert_eq!(decode(&done[0].1), vec![channel(1), channel(2)]);
assert_eq!(decode(&done[1].1), vec![[vec![0.5; 100], vec![0.25; 50], channel(0)].concat()]);
let _ = std::fs::remove_dir_all(&dir);
}
}
+26 -5
View File
@@ -7,6 +7,7 @@
//! - `engine.commands {filter?}`, `engine.parity`, `session.inspect {detail?}`, `ui.inspect`
//! - `ui.menu.list`, `ui.menu.invoke {path: "Track > New..." | id}` (menu-style: may open dialogs)
//! - `ui.click {x,y,button?,count?,mods?}`, `ui.move {x,y}`, `ui.drag {x,y,to_x,to_y,steps?}`
//! - `ui.scroll {x,y,dx?,dy?,unit?,steps?,mods?}` (trackpad swipe or wheel), `ui.zoom {x,y,factor,steps?}` (pinch)
//! - `ui.key {key, cmd?, shift?, alt?, ctrl?}`, `ui.text {text}`
//! - `ui.screenshot {path?}` (PNG; base64 when no path), `ui.set {...UiState fields}`
//! - `app.quit`
@@ -91,6 +92,8 @@ fn mods(p: &Value) -> egui::Modifiers {
fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Option<Value> {
let p = &req.params;
let f = |k: &str| p.get(k).and_then(Value::as_f64).unwrap_or(0.0) as f32;
let pos = egui::pos2(f("x"), f("y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
Some(match req.method.as_str() {
"engine.execute" | "command" => {
let id = p.get("command").or_else(|| p.get("id")).and_then(Value::as_str).unwrap_or("");
@@ -147,11 +150,10 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
Err(e) => err(e),
},
"ui.move" => {
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(f("x"), f("y"))));
app.synthetic.push(egui::Event::PointerMoved(pos));
ok(json!({}))
}
"ui.click" => {
let pos = egui::pos2(f("x"), f("y"));
let button = match p.get("button").and_then(Value::as_str) {
Some("right") | Some("secondary") => egui::PointerButton::Secondary,
_ => egui::PointerButton::Primary,
@@ -166,9 +168,7 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
ok(json!({}))
}
"ui.drag" => {
let a = egui::pos2(f("x"), f("y"));
let b = egui::pos2(f("to_x"), f("to_y"));
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 200);
let (a, b) = (pos, egui::pos2(f("to_x"), f("to_y")));
let m = mods(p);
app.synthetic.push(egui::Event::PointerMoved(a));
app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m });
@@ -179,6 +179,27 @@ fn handle(app: &mut SoundApp, ctx: &egui::Context, req: &ControlRequest) -> Opti
app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m });
ok(json!({}))
}
"ui.scroll" => {
let line = p.get("unit").and_then(Value::as_str) == Some("line");
let unit = if line { egui::MouseWheelUnit::Line } else { egui::MouseWheelUnit::Point };
let d = |k: &str| f(k).clamp(-100_000.0, 100_000.0) / steps as f32;
let (delta, modifiers) = (egui::vec2(d("dx"), d("dy")), mods(p));
let wheel = |phase, delta| egui::Event::MouseWheel { unit, delta, phase, modifiers };
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::Start, egui::Vec2::ZERO)));
app.synthetic.extend((0..steps).map(|_| wheel(egui::TouchPhase::Move, delta)));
app.synthetic.extend((!line).then(|| wheel(egui::TouchPhase::End, egui::Vec2::ZERO)));
ok(json!({}))
}
"ui.zoom" => {
let Some(factor) = p.get("factor").and_then(Value::as_f64).filter(|v| *v > 0.0) else {
return Some(err("ui.zoom needs a positive factor (> 1 zooms in)"));
};
let each = factor.clamp(1e-3, 1e3).powf(1.0 / steps as f64) as f32;
app.synthetic.push(egui::Event::PointerMoved(pos));
app.synthetic.extend((0..steps).map(|_| egui::Event::Zoom(each)));
ok(json!({}))
}
"ui.key" => {
let name = p.get("key").and_then(Value::as_str).unwrap_or("");
let Some(key) = key_from(name) else { return Some(err(format!("unknown key `{name}`"))) };
+235 -11
View File
@@ -5,7 +5,7 @@ use crate::theme::{Tokens, bold};
use egui::{Align2, vec2};
use serde_json::{Value, json};
use soundcraft_engine::Engine;
use soundcraft_model::{ClipId, TrackId};
use soundcraft_model::{ClipId, Session, SourceId, TrackId};
use soundcraft_time::Samples;
#[derive(Debug, Clone)]
@@ -15,6 +15,7 @@ pub enum Dialog {
Bounce { path: String, format: String, bit_depth: String, normalize: bool },
RenameTrack { id: TrackId, name: String },
RenameClip { id: ClipId, name: String },
ClearClips { clips: Vec<ClipId>, sources: Vec<SourceId>, message: String },
PathPrompt { cmd: String, title: String, path: String, key: String },
Number { cmd: String, title: String, key: String, value: f64, suffix: String },
TempoChange { at: Samples, bpm: f64 }, // from double-clicking the tempo ruler
@@ -23,11 +24,14 @@ pub enum Dialog {
Group { name: String, edit: bool, mix: bool, members: Vec<(u64, String, bool)> },
Session { frame_rate: String, bit_depth: String },
ScoreSetup { title: String, composer: String, bars_per_system: u32, show_track_names: bool },
SaveChanges { then: (String, Value) },
}
#[derive(Default)]
pub struct Dialogs {
pub open: Option<Dialog>,
/// Runs once the session has been saved from `SaveChanges`.
after_save: Option<(String, Value)>,
}
impl Dialogs {
@@ -38,6 +42,7 @@ impl Dialogs {
Dialog::Bounce { .. } => "bounce",
Dialog::RenameTrack { .. } => "rename_track",
Dialog::RenameClip { .. } => "rename_clip",
Dialog::ClearClips { .. } => "clear_clips",
Dialog::PathPrompt { .. } => "path",
Dialog::Number { .. } => "number",
Dialog::TempoChange { .. } => "tempo_change",
@@ -46,6 +51,7 @@ impl Dialogs {
Dialog::Group { .. } => "group",
Dialog::Session { .. } => "session",
Dialog::ScoreSetup { .. } => "score_setup",
Dialog::SaveChanges { .. } => "save_changes",
})
}
@@ -57,11 +63,23 @@ impl Dialogs {
self.open = Some(Dialog::RenameClip { id, name: name.to_string() });
}
/// Clip List › Clear: confirm before clips and files leave the session. False when there is nothing to clear.
pub fn open_clear(&mut self, e: &Engine, clips: Vec<ClipId>, sources: Vec<SourceId>) -> bool {
let Some(message) = clear_message(e.session(), &clips, &sources) else { return false };
self.open = Some(Dialog::ClearClips { clips, sources, message });
true
}
/// Open the dialog for a command; false when the command has no dialog.
pub fn open_for_command(&mut self, e: &Engine, id: &str) -> bool {
self.after_save = None;
let home = default_dir();
let name = e.session().name.clone();
self.open = Some(match id {
"clip.clear" => {
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() }
}
@@ -72,10 +90,16 @@ impl Dialogs {
"file.import_audio" => Dialog::PathPrompt { cmd: id.into(), title: "Import Audio".into(), path: home, key: "path".into() },
"file.import_midi" => Dialog::PathPrompt { cmd: id.into(), title: "Import MIDI".into(), path: home, key: "path".into() },
"session.open" => Dialog::PathPrompt { cmd: id.into(), title: "Open Session".into(), path: home, key: "path".into() },
"session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: id.into(),
"session.save" if e.path.is_some() => return false,
"session.save" | "session.save_as" | "session.save_copy" => Dialog::PathPrompt {
cmd: if id == "session.save_copy" { id } else { "session.save_as" }.into(),
title: "Save Session As".into(),
path: format!("{home}/{name}/{name}.scraft"),
// The browser has no folders: the name becomes the bundle file's name.
path: if home.is_empty() {
format!("{name}.{}", soundcraft_engine::io::BUNDLE_EXTENSION)
} else {
format!("{home}/{name}/{name}.scraft")
},
key: "path".into(),
},
"edit.repeat" => Dialog::Number { cmd: id.into(), title: "Repeat".into(), key: "count".into(), value: 2.0, suffix: "times".into() },
@@ -100,6 +124,11 @@ impl Dialogs {
let s = soundcraft_engine::score::ScoreSetup::from_session(e.session());
Dialog::ScoreSetup { title: s.title, composer: s.composer, bars_per_system: s.bars_per_system, show_track_names: s.show_track_names }
}
"clip.rename" => {
let target = soundcraft_engine::cmd::clip_ids_param(e, &json!({})).first().and_then(|id| e.session().find_clip(*id));
let Some((_, clip)) = target else { return false };
Dialog::RenameClip { id: clip.id, name: clip.name.clone() }
}
_ => match path_dialog(id, &home, &name) {
Some(d) => d,
None => return false,
@@ -109,6 +138,32 @@ impl Dialogs {
}
}
/// When `id` would close a session with unsaved changes, ask first; true when the question is
/// open instead. A take in progress is stopped first, so it counts as a change.
pub fn ask_to_save(app: &mut SoundApp, id: &str, params: &Value) -> bool {
if !matches!(id, "session.new" | "session.open" | "session.close" | "session.revert" | "app.quit")
|| soundcraft_engine::find_command(id).is_some_and(|c| (c.enabled)(&app.engine).is_err())
{
return false;
}
if app.engine.transport.recording {
app.stop_play();
}
if !app.engine.is_dirty() {
return false;
}
app.dialogs.open = Some(Dialog::SaveChanges { then: (id.into(), params.clone()) });
true
}
/// Runs `id`, unless it asks to save first (`ask_to_save`).
pub fn run_or_ask(app: &mut SoundApp, id: &str, params: Value) -> Result<Value, String> {
if ask_to_save(app, id, &params) {
return Ok(Value::Null);
}
app.run(id, params)
}
/// A path prompt for any command whose parameters start with a required `path` or `dir` (plus
/// Print Score, whose path is optional), with a default file name from the command.
pub fn takes_path(id: &str) -> bool {
@@ -146,6 +201,41 @@ fn path_dialog(id: &str, home: &str, name: &str) -> Option<Dialog> {
Some(Dialog::PathPrompt { cmd: id.into(), title, path, key: key.into() })
}
fn clear_message(s: &Session, clips: &[ClipId], sources: &[SourceId]) -> Option<String> {
let listed: std::collections::BTreeSet<ClipId> = clips.iter().copied().collect();
let names: Vec<&str> = s.tracks.iter().flat_map(|t| t.clips()).filter(|c| listed.contains(&c.id)).map(|c| c.name.as_str()).collect();
let files: Vec<_> = sources.iter().filter_map(|id| s.source(*id)).collect();
let count = |n: usize, what: &str| if n == 1 { format!("1 {what}") } else { format!("{n} {what}s") };
let what = match (names.as_slice(), files.as_slice()) {
([], []) => return None,
([name], []) => format!("the clip \"{name}\""),
([], [f]) => format!("the file \"{}\"", f.name),
(c, []) => count(c.len(), "clip"),
([], f) => count(f.len(), "file"),
(c, f) => format!("{} and {}", count(c.len(), "clip"), count(f.len(), "file")),
};
let mut text = format!("Remove {what} from the session?");
let used: Vec<(&str, usize)> =
files.iter().map(|f| (f.name.as_str(), s.clips_using(f.id).filter(|c| !listed.contains(&c.id)).count())).filter(|(_, n)| *n > 0).collect();
let n: usize = used.iter().map(|u| u.1).sum();
let users = if n == 1 {
"a clip on the timeline, which is removed too.".to_string()
} else {
format!("{n} clips on the timeline, which are removed too.")
};
match used.as_slice() {
[] => {}
[_] if files.len() == 1 => text += &format!(" It is used by {users}"),
u if u.len() == files.len() => text += &format!(" They are used by {users}"),
[(name, _)] => text += &format!(" \"{name}\" is used by {users}"),
many => text += &format!(" {} of the files are used by {users}", many.len()),
}
if !files.is_empty() && files.iter().all(|f| !f.unsaved) {
text += if files.len() == 1 { " The file stays on disk." } else { " The files stay on disk." };
}
Some(text)
}
fn default_dir() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
@@ -163,12 +253,15 @@ 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 save_first: Option<(String, Value)> = None;
let confirmed = matches!(d, Dialog::SaveChanges { .. });
let title = match &d {
Dialog::NewTracks { .. } => "New Tracks",
Dialog::NewSession { .. } => "New Session",
Dialog::Bounce { .. } => "Bounce Mix",
Dialog::RenameTrack { .. } => "Rename Track",
Dialog::RenameClip { .. } => "Rename Clip",
Dialog::ClearClips { .. } => "Clear",
Dialog::PathPrompt { title, .. } | Dialog::Number { title, .. } => title.as_str(),
Dialog::TempoChange { .. } => "Tempo Change (BPM)",
Dialog::Fades { .. } => "Fades",
@@ -176,6 +269,8 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
Dialog::Group { .. } => "Create Group",
Dialog::Session { .. } => "Session Setup",
Dialog::ScoreSetup { .. } => "Score Setup",
Dialog::SaveChanges { then } if then.0 == "session.revert" => "Revert to Saved",
Dialog::SaveChanges { .. } => "Unsaved Changes",
}
.to_string();
let enter = ctx.input(|i| i.key_pressed(egui::Key::Enter));
@@ -273,29 +368,42 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
}
}
Dialog::RenameTrack { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
name_field(ui, name);
if buttons(ui, "OK", enter) {
action = Some(("track.rename".into(), json!({"track": id.0, "name": name})));
}
}
Dialog::RenameClip { id, name } => {
let r = ui.text_edit_singleline(name);
r.request_focus();
name_field(ui, name);
if buttons(ui, "OK", enter) {
action = Some(("clip.rename".into(), json!({"clip": id.0, "name": name})));
}
}
Dialog::ClearClips { clips, sources, message } => {
ui.set_max_width(340.0);
ui.label(message.as_str());
if buttons(ui, "Remove", enter) {
action = Some(("clip.clear".into(), json!({"clips": clips, "sources": sources, "with_clips": true})));
}
}
Dialog::PathPrompt { cmd, path, key, .. } => {
ui.add(egui::TextEdit::singleline(path).desired_width(420.0));
if let Some(pick) = &app.services.pick_open
&& cmd != "session.save_as"
&& cmd != "session.save_copy"
&& key != "dir"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(cmd, &[])
{
*path = p;
}
if let Some(pick) = &app.services.pick_folder
&& key == "dir"
&& ui.button("Browse…").clicked()
&& let Some(p) = pick(path)
{
*path = p;
}
if let Some(pick) = &app.services.pick_save
&& (cmd == "session.save_as" || cmd == "session.save_copy")
&& ui.button("Browse…").clicked()
@@ -303,9 +411,17 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
{
*path = p;
}
if buttons(ui, "OK", enter) {
let file = soundcraft_engine::io::session_file(path);
let replaces = soundcraft_engine::io::replaces(&app.engine, cmd, &file);
if replaces {
ui.label(format!("“{}” already exists. Do you want to replace it?", file.file_name().unwrap_or_default().to_string_lossy()));
}
if buttons(ui, if replaces { "Replace" } else { "OK" }, enter && !replaces) {
let mut v = json!({});
v[key.as_str()] = json!(path);
if replaces {
v["replace"] = json!(true);
}
action = Some((cmd.clone(), v));
}
}
@@ -404,18 +520,71 @@ pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
));
}
}
Dialog::SaveChanges { then } => {
let name = &app.engine.session().name;
let revert = then.0 == "session.revert";
if revert || cfg!(target_arch = "wasm32") {
let (question, ok) = if revert {
(format!("Revert “{name}” to its last saved version?"), "Revert")
} else {
(format!("Close “{name}” without saving?"), "Don't Save")
};
ui.label(format!("{question} Your changes will be lost."));
if buttons(ui, ok, false) {
action = Some(then.clone());
}
} else {
ui.label(format!("Save changes to “{name}” before closing?"));
ui.separator();
ui.horizontal(|ui| {
if ui.button("Don't Save").clicked() {
action = Some(then.clone());
}
if buttons(ui, "Save", enter) {
save_first = Some(then.clone());
}
});
}
}
}
});
if esc || ctx.memory(|m| m.data.get_temp::<bool>(egui::Id::new("dlg_cancel")).unwrap_or(false)) {
keep = false;
app.dialogs.after_save = None;
ctx.memory_mut(|m| m.data.remove::<bool>(egui::Id::new("dlg_cancel")));
}
if let Some(then) = save_first {
keep = false;
if !app.dialogs.open_for_command(&app.engine, "session.save") {
action = Some(("session.save".into(), json!({})));
}
app.dialogs.after_save = Some(then);
}
if let Some((cmd, params)) = &action
&& !confirmed
&& ask_to_save(app, cmd, params)
{
keep = false;
action = None;
}
let saving = action.as_ref().is_some_and(|(cmd, _)| cmd.starts_with("session.save"));
if let Some((cmd, params)) = action {
keep = false;
if let Err(e) = app.run(&cmd, params) {
app.ui.status = e;
match app.run(&cmd, params) {
Ok(v) if cmd == "file.export_clips" => {
app.ui.status = format!("Exported {} clip(s) to {}", v["written"], v["dir"].as_str().unwrap_or_default());
}
Ok(_) => {}
Err(e) => app.ui.status = e,
}
}
if saving
&& let Some((then, params)) = app.dialogs.after_save.take()
&& !app.engine.is_dirty()
&& let Err(e) = app.run(&then, params)
{
app.ui.status = e;
}
if keep {
app.dialogs.open = Some(d);
}
@@ -436,6 +605,17 @@ fn buttons(ui: &mut egui::Ui, ok: &str, enter: bool) -> bool {
pressed
}
/// A name field that takes the focus with its text selected, so typing replaces the old name.
fn name_field(ui: &mut egui::Ui, name: &mut String) {
let mut out = egui::TextEdit::singleline(name).show(ui);
if !out.response.has_focus() {
out.response.request_focus();
let all = egui::text::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(name.chars().count()));
out.state.cursor.set_char_range(Some(all));
out.state.store(ui.ctx(), out.response.id);
}
}
/// The command the tempo-change dialog runs on OK.
fn tempo_change_action(at: Samples, bpm: f64) -> (String, Value) {
("event.tempo".into(), json!({"bpm": bpm, "at": at}))
@@ -515,6 +695,18 @@ mod tests {
assert!(matches!(d.open, Some(Dialog::ScoreSetup { bars_per_system: 4, .. })));
}
#[test]
fn clip_rename_opens_the_rename_dialog_for_the_selected_clip() {
let mut e = soundcraft_engine::demo::demo_engine();
let mut d = Dialogs::default();
e.execute("edit.select_none", &json!({})).unwrap();
assert!(!d.open_for_command(&e, "clip.rename"));
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select", &json!({"clips": [clip.id.0]})).unwrap();
assert!(d.open_for_command(&e, "clip.rename"));
assert!(matches!(d.open, Some(Dialog::RenameClip { id, ref name }) if id == clip.id && *name == clip.name));
}
#[test]
fn tempo_change_dialog_runs_a_real_command_at_its_position() {
let (cmd, params) = tempo_change_action(96_000, 133.5);
@@ -522,4 +714,36 @@ mod tests {
assert_eq!(params["at"], 96_000);
assert_eq!(params["bpm"], 133.5);
}
#[test]
fn clear_always_asks_and_names_the_timeline_clips_that_go_too() {
let mut e = soundcraft_engine::demo::demo_engine();
let file = |e: &Engine, name: &str| e.session().sources.iter().find(|f| f.name == name).map(|f| f.id).unwrap();
let (kick, hats) = (file(&e, "Kick"), file(&e, "Hats"));
let kick_clips: Vec<ClipId> = e.session().clips_using(kick).map(|c| c.id).collect();
let snare = e.session().track_by_name("Snare").unwrap().clips()[0].id;
let message = |d: &Dialogs| match &d.open {
Some(Dialog::ClearClips { message, .. }) => message.clone(),
other => panic!("{other:?}"),
};
let mut d = Dialogs::default();
assert!(!d.open_for_command(&e, "clip.clear"));
e.execute("edit.select", &json!({"clips": [snare]})).unwrap();
assert!(d.open_for_command(&e, "clip.clear"));
assert_eq!(message(&d), "Remove the clip \"Snare\" from the session?");
e.execute("edit.select", &json!({"sources": [kick]})).unwrap();
assert!(d.open_for_command(&e, "clip.clear"));
assert!(matches!(&d.open, Some(Dialog::ClearClips { clips, sources, .. }) if clips.is_empty() && *sources == vec![kick]));
assert_eq!(message(&d), "Remove the file \"Kick\" from the session? It is used by 3 clips on the timeline, which are removed too.");
assert!(d.open_clear(&e, kick_clips.clone(), vec![kick]));
assert_eq!(message(&d), "Remove 3 clips and 1 file from the session?");
assert!(d.open_clear(&e, kick_clips, vec![kick, hats]));
assert_eq!(message(&d), "Remove 3 clips and 2 files from the session? \"Hats\" is used by a clip on the timeline, which is removed too.");
e.session_mut().sources.iter_mut().for_each(|f| f.unsaved = false);
assert!(d.open_clear(&e, vec![], vec![kick, hats]));
assert_eq!(
message(&d),
"Remove 2 files from the session? They are used by 4 clips on the timeline, which are removed too. The files stay on disk."
);
}
}
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -52,15 +52,16 @@ pub fn run(app: &mut SoundApp, id: &str, p: &Value) -> Option<Value> {
}
pub fn show(app: &mut SoundApp, ctx: &egui::Context) {
apply_customization(app, ctx);
auto_update(app, ctx);
renderer(app, ctx);
ui_customization(app, ctx);
apply_customization(app, ctx);
}
fn apply_customization(app: &mut SoundApp, ctx: &egui::Context) {
let want = (app.extra.ui_scale.clamp(0.75, 1.5), app.extra.reduce_motion);
if app.extra.applied == Some(want) {
// Rescaling mid-drag would move the Interface scale slider out from under the pointer.
if app.extra.applied == Some(want) || ctx.input(|i| i.pointer.any_down()) {
return;
}
// Only touch the zoom once the user has changed it, so platform scaling stays untouched.
+198 -68
View File
@@ -99,8 +99,13 @@ pub struct UiState {
pub video_burn_in: bool,
pub workspace_dir: String,
pub configurations: Vec<(String, Value)>,
/// Appearance (`ui.theme`): dark by default; light, a Catppuccin flavour or the system on request.
/// Appearance (`ui.theme`): dark by default, light or follow the system on request.
pub theme: theme::ThemeMode,
/// The main window's size (logical points) and maximized state, and the interface scale, as
/// the desktop app last saw them; it restores them at launch.
pub window_size: [f32; 2],
pub window_maximized: bool,
pub ui_scale: f32,
}
impl Default for UiState {
@@ -153,6 +158,9 @@ impl Default for UiState {
workspace_dir: String::new(),
configurations: Vec::new(),
theme: theme::ThemeMode::Dark,
window_size: [1600.0, 1000.0],
window_maximized: false,
ui_scale: 1.0,
}
}
}
@@ -162,6 +170,7 @@ impl Default for UiState {
pub struct Services {
pub pick_open: Option<Box<dyn Fn(&str, &[&str]) -> Option<String>>>,
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
pub pick_folder: Option<Box<dyn Fn(&str) -> Option<String>>>,
}
/// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`).
@@ -169,6 +178,15 @@ pub fn preset_folder_name(plugin: &str) -> String {
plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect()
}
/// On the first frame, maximizes a window too big for the screen it opened on (sized on a larger
/// monitor) instead of letting it run off the edge; its saved size is kept for that monitor.
pub fn fit_window_to_screen(ctx: &egui::Context) {
let (inner, monitor) = ctx.input(|i| (i.viewport().inner_rect, i.viewport().monitor_size));
if ctx.cumulative_pass_nr() == 0 && inner.zip(monitor).is_some_and(|(r, m)| (r.size() - m).max_elem() > 0.0) {
ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true));
}
}
/// Drag-and-drop payload: an audio file (source id) dragged from the Clip List.
#[derive(Debug, Clone, Copy)]
pub struct DragSource(pub u64);
@@ -216,10 +234,12 @@ pub struct SoundApp {
pub autosave_dir: Option<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: f64,
last_autosave: Option<std::time::Instant>,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
pub main_meter: MeterDisplay,
/// Reused multichannel snapshot; populated at an adaptive rate by `logic`.
meter_snapshot: soundcraft_playback::MeterSnapshot,
pub gesture: Option<Gesture>,
pub edit_layout: edit_window::EditLayout,
pub midi: midi_editor::MidiEditorState,
@@ -234,7 +254,7 @@ pub struct SoundApp {
fonts_ready: bool,
fonts_installed: bool,
/// The palette last applied to the context (light?), so visuals are set only on a change.
theme_applied: Option<theme::ThemeMode>,
theme_applied: Option<bool>,
/// Last panel host context, used to cancel renderer-owned gestures on explicit replacement.
pub(crate) docking_context: Option<egui::Context>,
/// Transport simulation when there is no player (tests, offscreen renders).
@@ -247,6 +267,7 @@ pub struct SoundApp {
last_write_at: Samples,
pub frame_ms: f32,
last_frame: Option<f64>,
last_meter_poll: f64,
/// Movies for the Video window and Video track (decoded in the background).
pub video: video_track::VideoPool,
}
@@ -276,10 +297,11 @@ impl SoundApp {
native_menu_bar: false,
autosave_dir: None,
preset_dir: None,
last_autosave: 0.0,
last_autosave: None,
dialogs: dialogs::Dialogs::default(),
meters: HashMap::new(),
main_meter: MeterDisplay::default(),
meter_snapshot: soundcraft_playback::MeterSnapshot::default(),
gesture: None,
edit_layout: edit_window::EditLayout::default(),
midi: midi_editor::MidiEditorState::default(),
@@ -302,6 +324,7 @@ impl SoundApp {
last_write_at: i64::MIN,
frame_ms: 0.0,
last_frame: None,
last_meter_poll: f64::NEG_INFINITY,
video: video_track::VideoPool::default(),
}
}
@@ -329,6 +352,9 @@ impl SoundApp {
// Re-selecting a scrolling mode resumes the playhead follow.
self.edit_layout.follow_hold = false;
}
if id == "track.clear_clip_indicators" {
self.meters.values_mut().chain([&mut self.main_meter]).for_each(|m| m.clip = false);
}
Ok(v)
}
Err(e) => {
@@ -367,6 +393,8 @@ impl SoundApp {
fn stop_play(&mut self) {
if let Some(p) = &self.player {
// Half-speed is a separate command. Stop must not leave that rate for the next Play.
p.set_speed(1.0);
p.stop();
}
if self.engine.transport.recording {
@@ -411,17 +439,14 @@ impl SoundApp {
return;
}
self.ensure_input();
if self.recorder.is_none() {
self.ui.status = "Cannot record: no audio input device".into();
if let Err(err) = self.arm_recorder() {
self.ui.status = format!("Cannot record: {err}");
return;
}
if let Some(p) = &self.player {
p.set_recording(true);
}
let already_playing = self.is_playing();
if let Some(r) = &self.recorder {
r.arm();
}
self.engine.transport.recording = true;
if already_playing {
// Punch in on the fly at the current playhead.
@@ -447,6 +472,19 @@ impl SoundApp {
}
}
/// Arm the input for the record-enabled tracks; a session never saved records into its
/// autosave folder.
fn arm_recorder(&mut self) -> Result<(), String> {
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let unsaved_dir = self.autosave_dir.as_ref().map_or_else(std::env::temp_dir, |d| d.join(name));
let Some(r) = &mut self.recorder else { return Err("no audio input device".into()) };
let plan = soundcraft_engine::io::plan_takes(&self.engine, &unsaved_dir, r.channels);
if plan.is_empty() {
return Err("no audio track is record-enabled".into());
}
r.arm(plan)
}
/// Reads every live third-party plugin's state from the audio engine and stores it on its
/// insert (`mix.insert_state`), so saves and bounces include it.
pub fn capture_plugin_states(&mut self) {
@@ -473,15 +511,14 @@ impl SoundApp {
}
/// Every two minutes, copy a modified session to the autosave folder (not while recording).
fn autosave(&mut self, now: f64) {
/// Timed by the wall clock: egui's stands still while the window is hidden.
fn autosave(&mut self) {
let Some(dir) = self.autosave_dir.clone() else { return };
if self.last_autosave == 0.0 {
self.last_autosave = now;
}
if now - self.last_autosave < 120.0 || !self.engine.is_dirty() || self.engine.transport.recording {
let last = *self.last_autosave.get_or_insert_with(std::time::Instant::now);
if last.elapsed().as_secs() < 120 || !self.engine.is_dirty() || self.engine.transport.recording {
return;
}
self.last_autosave = now;
self.last_autosave = Some(std::time::Instant::now());
self.capture_plugin_states();
let name = soundcraft_engine::io::sanitize_name(&self.engine.session().name);
let path = dir.join(&name).join(format!("{name}.scraft"));
@@ -512,6 +549,12 @@ impl SoundApp {
best.map(|(_, p)| p)
}
/// Whether the meters are live: while playing, and while armed or input-monitored tracks are heard.
fn meters_live(&self) -> bool {
self.engine.transport.playing
|| (self.recorder.is_some() && self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm))
}
/// Open the input device once (for recording and input monitoring) and feed the player.
fn ensure_input(&mut self) {
if self.recorder.is_some() || self.recorder_failed || self.player.is_none() {
@@ -536,49 +579,26 @@ impl SoundApp {
if let Some(p) = &self.player {
p.set_recording(false);
}
let Some(r) = &self.recorder else { return };
let take = r.take();
let rate = take.sample_rate;
let Some(r) = &mut self.recorder else { return };
let (takes, problem) = r.finish();
let tracks = takes.len().max(1);
let sel = self.engine.session().edit.selection;
if self.engine.session().edit.loop_record && !sel.is_empty() {
// Split the capture into one take per loop pass; each pass gets its own playlist.
let pass = usize::try_from(soundcraft_time::to_samples(sel.len() as f64 * f64::from(rate) / self.engine.session().sample_rate.as_f64()))
.unwrap_or(0)
.max(1);
let total = take.channels.first().map_or(0, Vec::len);
let passes = total.div_ceil(pass).max(1);
let mut made = 0;
for k in 0..passes {
let chunk: Vec<Vec<f32>> =
take.channels.iter().map(|c| c.get(k * pass..((k + 1) * pass).min(c.len())).map(<[f32]>::to_vec).unwrap_or_default()).collect();
if chunk.first().is_none_or(|c| c.len() < pass / 8) {
continue;
}
if made > 0 {
let armed: Vec<u64> = self.engine.session().tracks.iter().filter(|t| t.mixer.record_arm).map(|t| t.id.0).collect();
let _ = self.engine.execute("track.playlist_new", &serde_json::json!({"tracks": armed}));
}
if soundcraft_engine::io::add_recording(&mut self.engine, sel.start, chunk, rate).is_ok() {
made += 1;
}
}
self.ui.status = format!("Loop-recorded {made} take(s)");
return;
let looped = self.engine.session().edit.loop_record && !sel.is_empty();
let (start, pass) = if looped { (sel.start, Some(sel.len())) } else { (self.record_start, None) };
let ids = soundcraft_engine::io::add_recording(&mut self.engine, start, takes, pass);
if let Some(p) = self.punch.take() {
// Keep only the punch range of each new take (merged into the Record undo step).
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
let _ = self.engine.execute_merged(
"edit.trim_to_fill_selection",
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
"record",
);
}
match soundcraft_engine::io::add_recording(&mut self.engine, self.record_start, take.channels, rate) {
Ok(ids) => {
if let Some(p) = self.punch.take() {
// Keep only the punch range of each new take (merged into the Record undo step).
let clip_ids: Vec<u64> = ids.iter().map(|c| c.0).collect();
let _ = self.engine.execute_merged(
"edit.trim_to_fill_selection",
&serde_json::json!({"clips": clip_ids, "start": p.start, "end": p.end}),
"record",
);
}
self.ui.status = format!("Recorded {} clip(s)", ids.len());
}
Err(e) => self.ui.status = e.to_string(),
self.ui.status = if looped { format!("Loop-recorded {} take(s)", ids.len() / tracks) } else { format!("Recorded {} clip(s)", ids.len()) };
if let Some(p) = problem {
log::warn!("recording: {p}");
self.ui.status = format!("{} ({p})", self.ui.status);
}
}
@@ -725,7 +745,13 @@ impl SoundApp {
if self.engine.transport.playing {
self.engine.transport.position = p.position();
}
let snap = p.meters();
let now = self.last_frame.unwrap_or_default();
let meter_interval = if self.ui.window == MainWindow::Mix { 1.0 / 60.0 } else { 1.0 / 20.0 };
if now - self.last_meter_poll >= meter_interval {
p.copy_meters(&mut self.meter_snapshot);
self.last_meter_poll = now;
}
let snap = &self.meter_snapshot;
for (id, m) in &snap.tracks {
let d = self.meters.entry(*id).or_default();
feed_meter(d, m.peak, m.gain_reduction_db, dt);
@@ -749,7 +775,7 @@ impl SoundApp {
self.stop_play();
}
}
if !self.engine.transport.playing {
if !self.meters_live() {
for d in self.meters.values_mut() {
feed_meter(d, [0.0, 0.0], 0.0, dt);
}
@@ -759,6 +785,9 @@ impl SoundApp {
/// Per-frame logic: control channel, transport, document sync.
pub fn logic(&mut self, ctx: &egui::Context) {
if ctx.current_pass_index() > 0 {
return;
}
// Fonts set now take effect next frame, so draw only from the frame after.
if !self.fonts_ready {
if self.fonts_installed {
@@ -769,14 +798,13 @@ impl SoundApp {
ctx.request_repaint();
}
}
let mode = theme::resolve(ctx, self.ui.theme);
if self.theme_applied != Some(mode) || theme::active_mode() != mode {
theme::apply(ctx, mode);
self.theme_applied = Some(mode);
let light = theme::wants_light(ctx, self.ui.theme);
if self.theme_applied != Some(light) || theme::is_light() != light {
theme::apply(ctx, light);
self.theme_applied = Some(light);
}
let now = ctx.input(|i| i.time);
let dt = self.last_frame.map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.last_frame = Some(now);
let dt = self.last_frame.replace(now).map_or(1.0 / 60.0, |t| (now - t) as f32).clamp(0.0, 0.25);
self.frame_ms = self.frame_ms * 0.9 + dt * 1000.0 * 0.1;
control::drain(self, ctx);
self.apply_editor_edits();
@@ -798,16 +826,17 @@ impl SoundApp {
}
}
self.write_automation(ctx);
self.autosave(now);
self.autosave();
if self.recorder.is_none()
&& !self.recorder_failed
&& self.engine.session().tracks.iter().any(|t| t.mixer.input_monitor || t.mixer.record_arm)
{
self.ensure_input();
}
if self.is_playing() || self.meters.values().any(|m| m.level[0] > 0.0001) || !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
// Autosave and the macOS menu bar only run on frames, and plugin editors are native windows egui never hears from.
// egui takes its predicted frame time off every delay, so add it back.
let editors = self.player.as_ref().is_some_and(Player::any_editor_open);
ctx.request_repaint_after_secs(if editors { 0.03 } else { 1.0 } + ctx.input(|i| i.predicted_dt));
control::collect_screenshots(self, ctx);
}
@@ -873,6 +902,15 @@ impl SoundApp {
score_editor::show(self, &ctx);
palette::show(self, &ctx);
dialogs::show(self, &ctx);
// Here, not in logic(): a covered or minimized window runs only logic(), with no vsync to pace it.
if self.meters_live() {
let hz = if self.ui.window == MainWindow::Mix { 60 } else { 30 };
ctx.request_repaint_after(std::time::Duration::from_millis(1000 / hz));
} else if self.meters.values().any(|m| m.level[0] > 0.0001) {
ctx.request_repaint_after(std::time::Duration::from_millis(80));
} else if !self.pending_shots.is_empty() || !self.synthetic.is_empty() {
ctx.request_repaint();
}
}
/// UI state as JSON (control channel `ui.inspect`).
@@ -960,6 +998,20 @@ mod tests {
assert_eq!(app.engine.transport.position, 0);
}
#[test]
fn a_hidden_window_still_autosaves() {
let dir = std::env::temp_dir().join(format!("soundcraft-hidden-autosave-{}", std::process::id()));
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.autosave_dir = Some(dir.clone());
app.run("track.new", json!({"count": 1, "format": "mono"})).unwrap();
app.last_autosave = std::time::Instant::now().checked_sub(std::time::Duration::from_secs(121));
let hidden = egui::ViewportInfo { minimized: Some(true), ..Default::default() };
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, hidden)].into_iter().collect(), ..Default::default() };
let _ = egui::Context::default().run_logic(&raw, |ctx| app.logic(ctx));
assert!(app.latest_autosave().is_some());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn session_reset_does_not_apply_old_stop_edits_to_the_new_session() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
@@ -979,6 +1031,16 @@ mod tests {
assert!(!app.engine.is_dirty());
}
#[test]
fn clear_all_clip_indicators_clears_every_meter() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.meters.entry(kick).or_default().clip = true;
app.main_meter.clip = true;
app.run("track.clear_clip_indicators", json!({})).unwrap();
assert!(!app.meters[&kick].clip && !app.main_meter.clip);
}
#[test]
fn automation_first_pass_after_start_is_due_without_overflow() {
// `last_write_at` starts (and resets) at `i64::MIN`: the previous
@@ -997,4 +1059,72 @@ mod tests {
fn automation_write_fires_after_loop_wrap() {
assert!(automation_step_due(0, 1_000_000, 960));
}
#[test]
fn preferences_saved_before_the_window_was_remembered_keep_full_scale() {
let ui: super::UiState = serde_json::from_value(json!({"show_transport": true})).unwrap();
assert_eq!((ui.window_size, ui.window_maximized, ui.ui_scale), ([1600.0, 1000.0], false, 1.0));
}
#[test]
fn a_window_bigger_than_its_screen_opens_maximized() {
let maximizes = |w: f32, frames: usize| {
let ctx = egui::Context::default();
let info = egui::ViewportInfo {
inner_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(w, 1000.0))),
monitor_size: Some(egui::vec2(1728.0, 1117.0)),
..Default::default()
};
let mut maximized = false;
for _ in 0..frames {
let raw = egui::RawInput { viewports: [(egui::ViewportId::ROOT, info.clone())].into_iter().collect(), ..Default::default() };
let mut out = ctx.run_ui(raw, |ui| super::fit_window_to_screen(ui.ctx()));
out.textures_delta.clear();
maximized = out.viewport_output[&egui::ViewportId::ROOT].commands.contains(&egui::ViewportCommand::Maximized(true));
}
maximized
};
assert!(maximizes(2400.0, 1));
assert!(!maximizes(1600.0, 1));
assert!(!maximizes(2400.0, 2));
}
#[test]
fn ordinary_play_after_stop_is_full_speed() {
use serde_json::json;
let engine = soundcraft_engine::Engine::default();
let player = soundcraft_playback::Player::new(engine.session_arc());
let mut app = super::SoundApp::new(engine, Some(player), super::Services::default());
app.engine.execute("edit.select", &json!({"start": 0, "end": 48000})).unwrap();
app.engine.execute("options.loop_playback", &json!({"value": true})).unwrap();
app.engine.execute("options.pre_post_roll", &json!({"value": false})).unwrap();
let speed = |app: &super::SoundApp| app.player.as_ref().map(|p| p.speed()).unwrap_or(0.0);
assert!((speed(&app) - 1.0).abs() < 1e-3);
app.engine.execute("transport.half_speed", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!(app.is_playing());
assert!((speed(&app) - 0.5).abs() < 1e-3, "half-speed playback");
app.engine.execute("transport.stop", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!(!app.is_playing());
assert!((speed(&app) - 1.0).abs() < 1e-3, "stop restores full speed");
app.engine.execute("transport.play", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!(app.is_playing());
assert!((speed(&app) - 1.0).abs() < 1e-3, "ordinary play stays at full speed");
app.engine.execute("transport.stop", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
app.engine.execute("options.loop_playback", &json!({"value": false})).unwrap();
app.engine.execute("transport.half_speed", &json!({})).unwrap();
app.handle_transport(1.0 / 60.0);
assert!((speed(&app) - 0.5).abs() < 1e-3);
app.player.as_ref().unwrap().stop();
app.handle_transport(1.0 / 60.0);
assert!(!app.is_playing());
assert!((speed(&app) - 1.0).abs() < 1e-3, "a half-speed pass that ends on its own restores full speed");
}
}
+47 -7
View File
@@ -145,12 +145,14 @@ pub const AUDIOSUITE: &[(&str, &str)] = &[
/// Commands that open a dialog when invoked from a menu (programmatic calls never do).
const DIALOG_COMMANDS: &[&str] = &[
"clip.clear",
"track.new",
"file.bounce_mix",
"session.new",
"file.import_audio",
"file.import_midi",
"session.open",
"session.save",
"session.save_as",
"session.save_copy",
"track.group",
@@ -161,6 +163,7 @@ const DIALOG_COMMANDS: &[&str] = &[
"event.quantize",
"event.transpose",
"edit.strip_silence",
"clip.rename",
];
/// Shared menu hierarchy for egui and native desktop hosts.
@@ -221,7 +224,7 @@ pub fn menu_bar(app: &mut SoundApp, ui: &mut egui::Ui) {
}
ui.separator();
if ui.button("Quit").clicked() {
let _ = app.run("app.quit", json!({}));
let _ = crate::dialogs::run_or_ask(app, "app.quit", json!({}));
}
});
let extra = ui_aliases();
@@ -261,8 +264,9 @@ fn menu_node(app: &mut SoundApp, ui: &mut egui::Ui, n: &MenuNode, extra: &[(&str
}
let id = command_for_path(&n.path, extra);
let (enabled, checked) = item_state(app, &n.path, id.as_deref());
let label = if checked { format!("✔ {}", n.label) } else { n.label.clone() };
let shortcut = id.as_deref().and_then(soundcraft_engine::find_command).and_then(|c| c.shortcut).unwrap_or("");
let label = live_label(app, id.as_deref()).unwrap_or_else(|| n.label.clone());
let label = if checked { format!("✔ {label}") } else { label };
let shortcut = id.as_deref().and_then(shortcut_for).unwrap_or("");
let btn = egui::Button::new(label).shortcut_text(crate::shortcuts::shortcut_label(shortcut, ui.ctx().os().is_mac()));
if ui.add_enabled(enabled, btn).clicked()
&& let Some(id) = id
@@ -393,6 +397,21 @@ const MENU_WINDOWS: &[(&str, &str)] = &[
("Event > MIDI Real-Time Properties", "window.rtp"),
];
/// The shortcut of an engine or window command.
pub fn shortcut_for(id: &str) -> Option<&'static str> {
soundcraft_engine::find_command(id).map_or_else(|| UI_COMMANDS.iter().find(|c| c.0 == id).and_then(|c| c.3), |c| c.shortcut)
}
/// Undo and Redo name the step they would undo or redo; other items keep their catalog label.
pub fn live_label(app: &SoundApp, id: Option<&str>) -> Option<String> {
let e = &app.engine;
match id? {
"edit.undo" => Some(e.undo_label().map_or_else(|| "Can't Undo".into(), |l| format!("Undo {l}"))),
"edit.redo" => Some(e.redo_label().map_or_else(|| "Can't Redo".into(), |l| format!("Redo {l}"))),
_ => None,
}
}
/// Menu-style invocation: may open a dialog.
pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
if let Some((_, w)) = MENU_WINDOWS.iter().find(|(p, _)| *p == path)
@@ -419,12 +438,13 @@ pub fn invoke_menu(app: &mut SoundApp, id: &str, path: &str) {
return;
}
let params = params_for(path, id);
let _ = app.run(id, params);
let _ = crate::dialogs::run_or_ask(app, id, params);
}
/// Keyboard shortcut for a dialog command: open its dialog. Returns true when handled.
/// Keyboard shortcut for a dialog command, or one that would close a modified session: open its
/// dialog. Returns true when handled.
pub fn invoke_shortcut_dialog(app: &mut SoundApp, id: &str) -> bool {
wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)
(wants_dialog(id) && app.dialogs.open_for_command(&app.engine, id)) || crate::dialogs::ask_to_save(app, id, &json!({}))
}
/// Commands that open a dialog first: the listed ones, Score Setup, and anything that needs a path.
@@ -571,7 +591,7 @@ pub fn run_ui_command(app: &mut SoundApp, id: &str, p: &Value) -> Option<Result<
return Some(Ok(json!({"mode": app.ui.theme.id()})));
};
let Some(mode) = crate::theme::ThemeMode::parse(mode) else {
return Some(Err(format!("unknown theme `{mode}` ({})", crate::theme::ThemeMode::id_list())));
return Some(Err(format!("unknown theme `{mode}` (system, light or dark)")));
};
app.ui.theme = mode;
json!({"mode": mode.id()})
@@ -638,4 +658,24 @@ mod tests {
invoke_menu(&mut app, "ui.bounce_dialog", "File > Bounce Mix...");
assert_eq!(app.dialogs.open_name(), Some("bounce"));
}
#[test]
fn save_asks_for_a_path_until_the_session_has_one() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(matches!(&app.dialogs.open, Some(crate::dialogs::Dialog::PathPrompt { cmd, .. }) if cmd == "session.save_as"));
app.dialogs.open = None;
assert!(invoke_shortcut_dialog(&mut app, "session.save"));
app.dialogs.open = None;
let dir = std::env::temp_dir().join(format!("soundcraft-save-{}", std::process::id()));
let path = dir.join("Song.scraft");
app.run("session.save_as", json!({"path": path.to_string_lossy()})).unwrap();
app.run("track.new", json!({})).unwrap();
assert!(!invoke_shortcut_dialog(&mut app, "session.save"));
invoke_menu(&mut app, "session.save", "File > Save Session");
assert!(app.dialogs.open.is_none());
assert!(!app.engine.is_dirty());
let _ = std::fs::remove_dir_all(&dir);
}
}
+12 -13
View File
@@ -15,7 +15,7 @@ const VEL_H: f32 = 64.0;
pub struct MidiEditorState {
pub selected: Vec<usize>,
pub drag: Option<NoteDrag>,
pub top_pitch: i32,
pub scroll: f32,
pub clip: Option<ClipId>,
/// Rubber-band selection start (screen position).
pub band: Option<egui::Pos2>,
@@ -127,28 +127,27 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
let vel = Rect::from_min_max(pos2(roll.min.x, roll.max.y + 4.0), pos2(roll.max.x, full.max.y - 2.0));
// Vertical range: centre on the notes the first time.
let rows = (roll.height() / ROW_H).floor() as i32;
if app.midi.top_pitch == 0 || app.midi.clip != Some(cid) {
if app.midi.clip != Some(cid) {
let hi = sequence.notes.iter().map(|n| i32::from(n.pitch)).max().unwrap_or(72);
let lo = sequence.notes.iter().map(|n| i32::from(n.pitch)).min().unwrap_or(48);
let mid = (hi + lo) / 2;
app.midi.top_pitch = (mid + rows / 2).clamp(rows.min(127), 127);
app.midi.scroll = (127 - (hi + lo) / 2 - rows / 2) as f32 * ROW_H;
app.midi.clip = Some(cid);
}
if ui.rect_contains_pointer(roll) {
let dy = ui.input(|i| i.smooth_scroll_delta.y);
if dy.abs() > 0.5 {
app.midi.top_pitch = (app.midi.top_pitch + (dy / ROW_H).round() as i32).clamp(rows.min(127), 127);
}
app.midi.scroll -= ui.input(|i| i.smooth_scroll_delta.y);
}
let top = app.midi.top_pitch;
app.midi.scroll = app.midi.scroll.clamp(0.0, (128.0 * ROW_H - roll.height()).max(0.0));
let scroll = app.midi.scroll;
let px_per_tick = roll.width() / len_ticks as f32;
let x_of = |tick: i64| roll.min.x + tick as f32 * px_per_tick;
let y_of = |pitch: i32| roll.min.y + (top - pitch) as f32 * ROW_H;
let y_of = |pitch: i32| roll.min.y + (127 - pitch) as f32 * ROW_H - scroll;
let pitch_at = |y: f32| 127 - ((y - roll.min.y + scroll) / ROW_H).floor() as i32;
let painter = ui.painter().with_clip_rect(roll.union(keys));
// Background rows and keys.
for r in 0..=rows {
let top = pitch_at(roll.min.y);
for r in 0..=rows + 1 {
let pitch = top - r;
let y = roll.min.y + r as f32 * ROW_H;
let y = y_of(pitch);
let row = Rect::from_min_size(pos2(roll.min.x, y), vec2(roll.width(), ROW_H));
painter.rect_filled(row, 0.0, if is_black(pitch) { Color32::from_rgb(30, 30, 32) } else { Color32::from_rgb(40, 40, 42) });
let key = Rect::from_min_size(pos2(keys.min.x, y), vec2(KEY_W - 2.0, ROW_H - 1.0));
@@ -288,7 +287,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
// Audition on the instrument would go here (needs a live MIDI input path).
}
None => {
let pitch = (top - ((p.y - roll.min.y) / ROW_H).floor() as i32).clamp(0, 127);
let pitch = pitch_at(p.y).clamp(0, 127);
let start = (((p.x - roll.min.x) / px_per_tick) as i64 / grid_ticks) * grid_ticks;
let _ = app.run(
"midi.note_add",
+304 -42
View File
@@ -22,11 +22,7 @@ pub fn show(app: &mut SoundApp, ui: &mut Ui) {
pub(crate) fn content(app: &mut SoundApp, ui: &mut Ui) {
let ids: Vec<TrackId> = app.engine.session().tracks.iter().filter(|x| !x.hidden && x.kind != TrackKind::Video).map(|x| x.id).collect();
// Per-channel peaks for multichannel meters (read once per frame).
let snap = app
.player
.as_ref()
.filter(|_| app.engine.transport.playing || app.engine.session().tracks.iter().any(|t| t.mixer.input_monitor))
.map(|p| p.meters());
let snap = app.player.as_ref().filter(|_| app.meters_live()).map(|p| p.meters());
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 1.0;
@@ -225,12 +221,12 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let out_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 33.0), vec2(inner_w - 4.0, 16.0));
let input =
if track.kind.has_playlist() && track.mixer.input == Route::None { "In 1".to_string() } else { route_name(app, &track.mixer.input) };
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(in_r));
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-input", id.0)).max_rect(in_r));
let resp = selector_box(&mut c, in_r.width(), in_r.height(), &input, t.text);
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, true));
let mut c = ui.new_child(egui::UiBuilder::new().max_rect(out_r));
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, true));
let mut c = ui.new_child(egui::UiBuilder::new().id_salt(("mix-output", id.0)).max_rect(out_r));
let resp = selector_box(&mut c, out_r.width(), out_r.height(), &route_name(app, &track.mixer.output), t.text);
egui::Popup::menu(&resp).show(|ui| route_menu(app, ui, id, false));
egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| route_menu(app, ui, id, false));
// Automation mode.
let am_r = Rect::from_min_size(pos2(x0 + 2.0, sec.min.y + 51.0), vec2(inner_w - 4.0, 16.0));
ui.painter().text(pos2(am_r.center().x, am_r.min.y - 1.0), Align2::CENTER_BOTTOM, "", regular(9.0), t.text_dim);
@@ -307,7 +303,10 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let _ = resp;
let pr = Rect::from_center_size(pos2(cx, y + ks + 8.0), vec2(ks + 8.0, 13.0));
ui.painter().rect_filled(pr, 1.0, t.counter_bg);
ui.painter().text(pr.center(), Align2::CENTER_CENTER, pan_text(v), regular(10.0), t.counter_text);
let (_, entered) = editable_readout(ui, pr, ui.id().with(("pan-value", id.0, i)), &pan_text(v), &format!("{:.0}", v * 100.0), parse_pan);
if let Some(v) = entered {
let _ = app.engine.execute_merged("mix.pan", &json!({"track": id.0, "pan": v, "index": i}), &format!("pan:{}:{i}", id.0));
}
}
y += ks + 20.0;
}
@@ -372,7 +371,17 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
// Volume readout and name.
let vr = Rect::from_min_size(pos2(x0 + 4.0, r.max.y - bottom_h + 4.0), vec2(inner_w - 8.0, 15.0));
ui.painter().rect_filled(vr, 1.0, t.counter_bg);
ui.painter().text(vr.center(), Align2::CENTER_CENTER, db_text(track.mixer.volume_db), regular(11.0), t.counter_text);
let (_, entered) = editable_readout(
ui,
vr,
ui.id().with(("volume-value", id.0)),
&db_text(track.mixer.volume_db),
&format!("{:.1}", track.mixer.volume_db),
parse_db,
);
if let Some(db) = entered {
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
}
let kind = match track.kind {
TrackKind::Audio => "audio",
TrackKind::Aux => "aux",
@@ -395,7 +404,7 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
ui.painter().with_clip_rect(nr).text(nr.center(), Align2::CENTER_CENTER, &track.name, bold(12.0), t.text_dark);
let nresp = ui.interact(nr, ui.id().with(("strip_name", id.0)), Sense::click());
if nresp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id.0]}));
crate::edit_window::click_track(app, id, ui.input(|i| i.modifiers), |_, t| !t.hidden && t.kind != TrackKind::Video);
}
if nresp.double_clicked() {
app.dialogs.open_rename_track(id, &track.name);
@@ -414,12 +423,12 @@ fn instrument_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, r: Rect) {
let ins = track.instrument.as_ref();
let info = ins.and_then(|i| plugin_info(&i.plugin));
let resp = ui.interact(r, ui.id().with(("instrument", id.0)), Sense::click());
let fill = if info.is_some() { Color32::from_rgb(52, 62, 80) } else { t.slot_bg };
let fill = if info.is_some() { t.insert_on } else { t.slot_bg };
ui.painter().rect(
r,
CornerRadius::same(2),
if resp.hovered() { fill.gamma_multiply(1.3) } else { fill },
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
Stroke::new(1.0, t.slot_border),
StrokeKind::Inside,
);
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, info.map_or("no instrument", |p| p.short_name), regular(10.5), t.text);
@@ -611,9 +620,11 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
let t = Tokens::current();
let id = track.id;
let snd = track.mixer.sends.get(slot).cloned().flatten();
let resp = ui.interact(r, ui.id().with(("snd", id.0, slot)), Sense::click_and_drag());
let fill = if snd.is_some() { t.send_on } else { t.slot_bg };
ui.painter().rect(r, CornerRadius::same(2), fill, Stroke::new(1.0, t.slot_border), StrokeKind::Inside);
let level_r = Rect::from_min_max(pos2((r.max.x - 42.0).max(r.min.x), r.min.y), r.max);
let route_r = Rect::from_min_max(r.min, pos2((level_r.min.x - 1.0).max(r.min.x), r.max.y));
let route_resp = ui.interact(if snd.is_some() { route_r } else { r }, ui.id().with(("snd-route", id.0, slot)), Sense::click());
match &snd {
Some(s) => {
let name = route_name(app, &s.target);
@@ -624,44 +635,257 @@ fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rec
0.0,
t.counter_text,
);
ui.painter().with_clip_rect(r).text(
r.center() - vec2(0.0, 1.0),
Align2::CENTER_CENTER,
ui.painter().with_clip_rect(route_r).text(
pos2(r.min.x + 4.0, r.center().y - 1.0),
Align2::LEFT_CENTER,
name,
regular(10.0),
regular(9.0),
if s.mute { t.mute } else { t.text },
);
if resp.dragged() {
let db = (s.level_db.max(-60.0) - resp.drag_delta().y * 0.3).clamp(-144.0, 12.0);
let (level_resp, entered) = editable_readout(
ui,
level_r,
ui.id().with(("send-value", id.0, slot)),
&format!("{} dB", db_text(s.level_db)),
&format!("{:.1}", s.level_db),
parse_db,
);
if let Some(db) = entered {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
}
if level_resp.as_ref().is_some_and(egui::Response::dragged) {
// Use this frame's pointer movement. `Response::drag_delta()` is cumulative, so
// applying it to the already-updated value made trackpad adjustment accelerate.
let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift));
let db_per_point = if fine { 0.02 } else { 0.15 };
let db = (s.level_db.max(-60.0) - dy * db_per_point).clamp(-60.0, 12.0);
let _ =
app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
}
if resp.double_clicked() {
if level_resp.as_ref().is_some_and(egui::Response::double_clicked) {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
}
let _ = resp.clone().on_hover_text(format!("Send {}: {} dB (drag to change)", (b'A' + slot as u8) as char, db_text(s.level_db)));
if let Some(resp) = level_resp {
let _ = resp.clone().on_hover_text(format!(
"Send {}: {} dB\nClick to type · drag vertically to adjust · Shift-drag for fine control · double-click for 0 dB · right-click for options",
(b'A' + slot as u8) as char,
db_text(s.level_db)
));
resp.context_menu(|ui| send_options_menu(app, ui, id, slot, s));
}
}
None => {
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
}
}
let popup = if snd.is_none() { egui::Popup::menu(&resp) } else { egui::Popup::context_menu(&resp) };
popup.show(|ui| {
if snd.is_some() && ui.button("no send").clicked() {
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
}
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
for b in busses {
if ui.button(&b).clicked() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": b}));
if let Some(send) = &snd {
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(250.0);
ui.horizontal(|ui| {
ui.strong(format!("Send {}", (b'A' + slot as u8) as char));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(egui::RichText::new(route_name(app, &send.target)).color(Tokens::current().counter_text));
});
});
ui.label(egui::RichText::new("DESTINATION").size(8.0).color(Tokens::current().text_dim));
send_route_menu(app, ui, id, slot, Some(&send.target));
ui.separator();
if ui.button("Remove send").clicked() {
let _ = app.run("mix.send_remove", json!({"track": id.0, "slot": slot}));
ui.close();
}
});
} else {
egui::Popup::menu(&route_resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
ui.set_min_width(250.0);
ui.strong(format!("Send {} · Choose destination", (b'A' + slot as u8) as char));
send_route_menu(app, ui, id, slot, None);
});
}
}
/// Secondary controls live behind the amount readout, separate from the bus selector like Logic.
fn send_options_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, send: &soundcraft_model::SendSlot) {
ui.set_min_width(230.0);
ui.strong(format!("Send {} · {}", (b'A' + slot as u8) as char, route_name(app, &send.target)));
let mut db = send.level_db.max(-60.0);
if ui.add(egui::Slider::new(&mut db, -60.0..=12.0).text("Level").suffix(" dB")).changed() {
let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0));
}
send_level_scale(ui, send.level_db);
ui.horizontal(|ui| {
for (label, delta) in [("−3", -3.0), ("−1", -1.0), ("+1", 1.0), ("+3", 3.0)] {
if ui.small_button(label).clicked() {
let db = (send.level_db.max(-60.0) + delta).clamp(-60.0, 12.0);
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": db}));
}
}
ui.separator();
if ui.button("new bus...").clicked() {
let n = app.engine.session().busses.len() + 1;
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
if ui.small_button("0 dB").clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": 0.0}));
}
if ui.small_button("−∞").clicked() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "db": -144.0}));
}
});
let mut pan = send.pan;
if ui.add(egui::Slider::new(&mut pan, -1.0..=1.0).text("Pan")).changed() {
let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "pan": pan}), &format!("send-pan:{}:{slot}", id.0));
}
ui.horizontal(|ui| {
let mut mute = send.mute;
if ui.checkbox(&mut mute, "Mute").changed() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "mute": mute}));
}
let mut pre = send.pre_fader;
if ui.checkbox(&mut pre, "Pre-fader").changed() {
let _ = app.run("mix.send_level", json!({"track": id.0, "slot": slot, "pre_fader": pre}));
}
});
if let Route::Bus(bus) = send.target
&& let Some(aux) =
app.engine.session().tracks.iter().find(|t| t.kind == TrackKind::Aux && t.mixer.input == Route::Bus(bus)).map(|t| (t.id, t.name.clone()))
&& ui.button("Rename bus…").clicked()
{
app.dialogs.open_rename_track(aux.0, &aux.1);
ui.close();
}
}
/// Paint a value until clicked, then replace it with a focused text field. Enter or clicking away
/// commits a valid value; Escape cancels. The interaction response remains available for dragging.
fn editable_readout(
ui: &mut Ui,
r: Rect,
id: egui::Id,
display: &str,
edit_initial: &str,
parse: fn(&str) -> Option<f32>,
) -> (Option<egui::Response>, Option<f32>) {
let t = Tokens::current();
let active_id = id.with("active");
let text_id = id.with("text");
let mut active: bool = ui.data(|d| d.get_temp(active_id)).unwrap_or(false);
let mut started = false;
let mut response = None;
if !active {
let resp = ui.interact(r, id, Sense::click_and_drag());
if resp.clicked() && !resp.double_clicked() {
active = true;
started = true;
ui.data_mut(|d| d.insert_temp(text_id, edit_initial.to_string()));
} else {
ui.painter().with_clip_rect(r).text(r.center(), Align2::CENTER_CENTER, display, regular(9.0), t.counter_text);
}
response = Some(resp);
}
let mut value = None;
if active {
let mut text: String = ui.data(|d| d.get_temp(text_id)).unwrap_or_else(|| edit_initial.to_string());
let mut child = ui.new_child(egui::UiBuilder::new().id_salt(id).max_rect(r));
child.visuals_mut().override_text_color = Some(t.counter_text);
let edit = egui::TextEdit::singleline(&mut text)
.id(id.with("editor"))
.frame(egui::Frame::NONE)
.horizontal_align(egui::Align::Center)
.font(regular(9.0))
.desired_width(r.width());
let edit_resp = child.add_sized(r.size(), edit);
if started {
edit_resp.request_focus();
}
let (enter, escape) = ui.input(|i| (i.key_pressed(egui::Key::Enter), i.key_pressed(egui::Key::Escape)));
if escape {
active = false;
} else if enter || edit_resp.lost_focus() {
if let Some(parsed) = parse(&text) {
value = Some(parsed);
active = false;
} else if edit_resp.lost_focus() {
active = false;
}
}
ui.data_mut(|d| d.insert_temp(text_id, text));
}
ui.data_mut(|d| d.insert_temp(active_id, active));
(response, value)
}
fn parse_db(text: &str) -> Option<f32> {
let cleaned = text.trim().to_ascii_lowercase().replace("db", "").replace(',', ".");
if matches!(cleaned.trim(), "-inf" | "-infinity" | "off") {
return Some(-144.0);
}
cleaned.trim().parse::<f32>().ok().filter(|v| v.is_finite()).map(|v| v.clamp(-144.0, 12.0))
}
fn parse_pan(text: &str) -> Option<f32> {
let cleaned = text.trim().to_ascii_lowercase().replace(',', ".");
let (sign, number) = if let Some(v) = cleaned.strip_prefix('<').or_else(|| cleaned.strip_prefix('l')) {
(-1.0, v)
} else if let Some(v) = cleaned.strip_suffix('>').or_else(|| cleaned.strip_prefix('r')) {
(1.0, v)
} else {
(1.0, cleaned.as_str())
};
let raw = number.trim().parse::<f32>().ok()? * sign;
let value = if number.contains('.') && raw.abs() <= 1.0 { raw } else { raw / 100.0 };
value.is_finite().then_some(value.clamp(-1.0, 1.0))
}
/// A compact calibrated scale for a send. The marks use the same taper as the strip's level bar.
fn send_level_scale(ui: &mut Ui, db: f32) {
let t = Tokens::current();
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 25.0), Sense::hover());
let line_y = r.min.y + 4.0;
ui.painter().line_segment([pos2(r.min.x, line_y), pos2(r.max.x, line_y)], Stroke::new(1.0, t.slot_border));
for (i, mark) in [-60.0f32, -24.0, -10.0, 0.0, 6.0, 12.0].into_iter().enumerate() {
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(mark);
ui.painter().line_segment([pos2(x, line_y - 3.0), pos2(x, line_y + 3.0)], Stroke::new(1.0, t.text_dim));
let align = if i == 0 {
Align2::LEFT_TOP
} else if i == 5 {
Align2::RIGHT_TOP
} else {
Align2::CENTER_TOP
};
let label = if mark > 0.0 { format!("+{mark:.0}") } else { format!("{mark:.0}") };
ui.painter().text(pos2(x, line_y + 5.0), align, label, regular(8.0), t.text_dim);
}
let x = r.min.x + r.width() * soundcraft_model::fader_db_to_pos(db);
ui.painter().line_segment([pos2(x, r.min.y), pos2(x, line_y + 3.0)], Stroke::new(2.0, t.counter_text));
}
fn send_route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, current: Option<&Route>) {
let busses = app.engine.session().busses.clone();
let search_id = egui::Id::new(("send-bus-search", id.0, slot));
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…"));
ui.data_mut(|d| d.insert_temp(search_id, search.clone()));
let needle = search.trim().to_ascii_lowercase();
let visible: Vec<_> = busses.iter().filter(|bus| needle.is_empty() || bus.name.to_ascii_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt(("send-route-busses", id.0, slot)).max_height(260.0).auto_shrink([false, true]).show_rows(
ui,
22.0,
visible.len(),
|ui, rows| {
for bus in &visible[rows] {
let selected = matches!(current, Some(Route::Bus(bus_id)) if *bus_id == bus.id);
if ui.selectable_label(selected, &bus.name).clicked() {
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": bus.name}));
ui.close();
}
}
},
);
ui.separator();
if ui.button("new bus...").clicked() {
let mut n = busses.len() + 1;
while busses.iter().any(|bus| bus.name == format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
n += 1;
}
let _ = app.run("mix.send", json!({"track": id.0, "slot": slot, "bus": format!("Bus {n}")}));
ui.close();
}
}
fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
@@ -669,29 +893,67 @@ fn route_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, input: bool) {
let cmd = if input { "track.input" } else { "track.output" };
if ui.button(if input { "no input" } else { "no output" }).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "none"}));
ui.close();
}
let main_name = app.engine.session().outputs.first().map_or_else(|| "Out 1-2".to_string(), |o| o.name.clone());
if !input && ui.button(main_name).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: "main"}));
ui.close();
}
if input {
for i in 1..=8 {
if ui.button(format!("In {i}")).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: format!("In {i}")}));
ui.close();
}
}
}
ui.separator();
let busses: Vec<String> = app.engine.session().busses.iter().map(|b| b.name.clone()).collect();
ui.menu_button("bus", |ui| {
for b in &busses {
ui.strong("Bus");
let search_id = egui::Id::new(("route-bus-search", id.0, input));
let mut search: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut search).hint_text("Search buses…"));
ui.data_mut(|d| d.insert_temp(search_id, search.clone()));
let needle = search.trim().to_ascii_lowercase();
egui::ScrollArea::vertical().id_salt(("track-route-busses", id.0, input)).max_height(420.0).show(ui, |ui| {
for b in busses.iter().filter(|b| needle.is_empty() || b.to_ascii_lowercase().contains(&needle)) {
if ui.button(b).clicked() {
let _ = app.run(cmd, json!({"track": id.0, key: b}));
ui.close();
}
}
if ui.button("new bus...").clicked() {
let n = busses.len() + 1;
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
}
});
if ui.button("new bus...").clicked() {
let mut n = busses.len() + 1;
while busses.contains(&format!("Bus {n}")) || app.engine.session().track_by_name(&format!("Bus {n}")).is_some() {
n += 1;
}
let _ = app.run(cmd, json!({"track": id.0, key: format!("Bus {n}")}));
ui.close();
}
}
#[cfg(test)]
mod tests {
use super::{parse_db, parse_pan};
#[test]
fn typed_db_accepts_console_notation_and_clamps() {
assert_eq!(parse_db("-6 dB"), Some(-6.0));
assert_eq!(parse_db("+3,5"), Some(3.5));
assert_eq!(parse_db("off"), Some(-144.0));
assert_eq!(parse_db("99"), Some(12.0));
assert_eq!(parse_db("loud"), None);
}
#[test]
fn typed_pan_accepts_percent_console_and_fractional_values() {
assert_eq!(parse_pan("-50"), Some(-0.5));
assert_eq!(parse_pan("<35"), Some(-0.35));
assert_eq!(parse_pan("35>"), Some(0.35));
assert_eq!(parse_pan("R25"), Some(0.25));
assert_eq!(parse_pan("0.5"), Some(0.5));
assert_eq!(parse_pan("center"), None);
}
}
+66 -58
View File
@@ -4,6 +4,7 @@ use crate::SoundApp;
use crate::theme::{Tokens, bold, mono, regular, rgb};
use egui::{Align2, Color32, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::json;
use soundcraft_model::{ClipId, SourceId};
use soundcraft_time::format_position;
fn panel_header(ui: &mut Ui, title: &str) {
@@ -37,7 +38,7 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 20.0, r.min.y + 3.0), vec2(4.0, 11.0)), 0.0, rgb(color));
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &name, regular(11.5), if shown { t.text } else { t.text_dim });
if resp.clicked() {
let _ = app.run("edit.select", json!({"tracks": [id]}));
crate::edit_window::click_track(app, soundcraft_model::TrackId(id), ui.input(|i| i.modifiers), |_, _| true);
}
}
});
@@ -100,51 +101,58 @@ pub fn tracks_and_groups(app: &mut SoundApp, ui: &mut Ui) {
pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
let t = Tokens::current();
panel_header(ui, "CLIPS");
let items: Vec<(Option<u64>, String, bool, [u8; 3])> = {
let items: Vec<(u64, String, bool, [u8; 3], bool)> = {
let s = app.engine.session();
let mut v: Vec<(Option<u64>, String, bool, [u8; 3])> = Vec::new();
let mut v = Vec::new();
// Whole files first (bold), then clips on tracks.
for src in &s.sources {
v.push((Some(src.id.0), format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150]));
v.push((src.id.0, format!("{} ({}ch)", src.name, src.channels), true, [150, 150, 150], s.edit.selected_sources.contains(&src.id)));
}
for tr in &s.tracks {
for c in tr.clips() {
v.push((Some(c.id.0), c.name.clone(), false, c.color.unwrap_or(tr.color)));
v.push((c.id.0, c.name.clone(), false, c.color.unwrap_or(tr.color), s.edit.selected_clips.contains(&c.id)));
}
}
v
};
let selected: Vec<u64> = app.engine.session().edit.selected_clips.iter().map(|c| c.0).collect();
let fkey = egui::Id::new("clip_filter");
let mut filter: String = ui.ctx().memory(|m| m.data.get_temp(fkey)).unwrap_or_default();
ui.add(egui::TextEdit::singleline(&mut filter).hint_text("🔍 Find clips").desired_width(f32::INFINITY));
ui.ctx().memory_mut(|m| m.data.insert_temp(fkey, filter.clone()));
let needle = filter.to_lowercase();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
let items: Vec<_> = items.into_iter().filter(|(_, n, _, _, _)| needle.is_empty() || n.to_lowercase().contains(&needle)).collect();
egui::ScrollArea::vertical().id_salt("clips_scroll").auto_shrink([false, false]).show(ui, |ui| {
for (id, name, whole, color) in items {
for (id, name, whole, color, selected) in items {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 17.0), if whole { Sense::click_and_drag() } else { Sense::click() });
if whole && let Some(src) = id {
resp.context_menu(|ui| {
resp.context_menu(|ui| {
if whole {
if ui.button("Place on New Track").clicked() {
let _ = app.run("clip.place_source", json!({"source": src}));
let _ = app.run("clip.place_source", json!({"source": id}));
ui.close();
}
let sel_track = app.engine.session().edit.selected_tracks.first().map(|t| t.0);
if let Some(t) = sel_track
&& ui.button("Place on Selected Track").clicked()
{
let _ = app.run("clip.place_source", json!({"source": src, "track": t}));
let _ = app.run("clip.place_source", json!({"source": id, "track": t}));
ui.close();
}
});
ui.separator();
}
if ui.button("Clear…").clicked() {
let (clips, sources) = if whole { (vec![], vec![SourceId(id)]) } else { (vec![ClipId(id)], vec![]) };
app.dialogs.open_clear(&app.engine, clips, sources);
ui.close();
}
});
if whole {
// Whole files drag onto tracks.
resp.dnd_set_drag_payload(crate::DragSource(src));
resp.dnd_set_drag_payload(crate::DragSource(id));
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
}
if !whole && id.is_some_and(|i| selected.contains(&i)) {
if selected {
ui.painter().rect_filled(r, 0.0, t.row_selected);
}
ui.painter().rect_filled(Rect::from_min_size(pos2(r.min.x + 6.0, r.min.y + 4.0), vec2(9.0, 9.0)), 1.0, rgb(color));
@@ -155,11 +163,8 @@ pub fn clip_list(app: &mut SoundApp, ui: &mut Ui) {
if whole { bold(11.0) } else { regular(11.0) },
t.text,
);
if resp.clicked()
&& !whole
&& let Some(i) = id
{
let _ = app.run("edit.select", json!({"clips": [i]}));
if resp.clicked() {
let _ = app.run("edit.select", if whole { json!({"sources": [id]}) } else { json!({"clips": [id]}) });
}
}
});
@@ -407,44 +412,46 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
if info.id == "eq_7band" || info.id == "eq_1band" {
eq_curve(ui, info.id, &ins);
}
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.insert_param",
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
&format!("param:{}:{slot}:{}", tid.0, p.id),
);
if let Some(pl) = &app.player {
// Keep the plugin's own editor in step.
pl.editor_set_param(tid, slot, p.id, x);
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new(("pg", tid.0, slot)).num_columns(3).show(ui, |ui| {
for p in info.params {
let v = ins.params.get(p.id).copied().unwrap_or(p.default);
ui.label(p.name);
let mut x = v;
let resp = if !p.choices.is_empty() {
let mut idx = x.round().max(0.0) as usize;
let r = egui::ComboBox::from_id_salt(("pc", tid.0, slot, p.id))
.selected_text(p.choices.get(idx).copied().unwrap_or("?"))
.show_ui(ui, |ui| {
for (i, c) in p.choices.iter().enumerate() {
ui.selectable_value(&mut idx, i, *c);
}
});
x = idx as f32;
r.response
} else {
let mut sl = egui::Slider::new(&mut x, p.min..=p.max).show_value(false);
if p.taper == soundcraft_dsp::Taper::Log && p.min > 0.0 {
sl = sl.logarithmic(true);
}
ui.add(sl)
};
if (x - v).abs() > f32::EPSILON {
let _ = app.engine.execute_merged(
"mix.insert_param",
&json!({"track": tid.0, "slot": slot, "param": p.id, "value": x}),
&format!("param:{}:{slot}:{}", tid.0, p.id),
);
if let Some(pl) = &app.player {
// Keep the plugin's own editor in step.
pl.editor_set_param(tid, slot, p.id, x);
}
}
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
let _ = resp;
ui.label(egui::RichText::new(p.format(x)).font(mono(11.0)));
ui.end_row();
}
});
});
});
if open {
@@ -501,9 +508,10 @@ fn about(app: &mut SoundApp, ctx: &egui::Context) {
_ => {
ui.label(egui::RichText::new("SoundCraft").font(bold(22.0)));
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
ui.label("An open-source digital audio workstation from the storytold team.");
ui.label("An open-source digital audio workstation from the ArtCraft team.");
ui.label("Dual-licensed MIT OR Apache-2.0. Made with Rust and egui.");
ui.hyperlink_to("github.com/storytold/soundcraft", "https://github.com/storytold/soundcraft");
ui.hyperlink_to("getartcraft.com/apps/soundcraft", "https://getartcraft.com/apps/soundcraft");
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
}
}
});
+24 -7
View File
@@ -55,6 +55,8 @@ fn search_chord(m: Modifiers, mac: bool) -> bool {
}
pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
// Cmd+= and Cmd+0 are Mix/Edit and Quantize to Grid, not egui's built-in interface zoom.
ctx.options_mut(|o| o.zoom_with_keyboard = false);
if ctx.egui_wants_keyboard_input() || app.dialogs.open.is_some() {
return;
}
@@ -73,18 +75,26 @@ pub fn handle(app: &mut SoundApp, ctx: &egui::Context) {
if fixed(app, key, mods, mac) {
continue;
}
let hit =
soundcraft_engine::command_specs().iter().find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)));
if let Some(c) = hit {
let id = c.id;
if let Some(id) = command_for(key, mods, mac) {
if crate::menus::invoke_shortcut_dialog(app, id) {
continue;
}
let _ = app.run(id, json!({}));
} else if let Some(c) =
crate::menus::UI_COMMANDS.iter().find(|c| c.3.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
{
let _ = app.run(c.0, json!({}));
}
}
}
fn command_for(key: Key, mods: Modifiers, mac: bool) -> Option<&'static str> {
soundcraft_engine::command_specs()
.iter()
.find(|c| c.shortcut.and_then(parse).is_some_and(|(m, k)| k == key && mods_match(m, mods, mac)))
.map(|c| c.id)
}
/// Keys with context-dependent meaning. Returns true when handled.
fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let plain = !m.command && !m.alt && !m.ctrl && !m.shift;
@@ -102,7 +112,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool {
let _ = app.run("window.search", json!({}));
true
}
Key::Equals if m.command => {
Key::Equals if m.command && !(mac && m.ctrl) => {
let _ = app.run("window.toggle_mix_edit", json!({}));
true
}
@@ -282,8 +292,8 @@ mod label_tests {
#[cfg(test)]
mod tests {
use super::{mods_match, parse, search_chord};
use egui::Modifiers;
use super::{command_for, mods_match, parse, search_chord};
use egui::{Key, Modifiers};
/// Ctrl held on Windows or Linux, as egui reports it.
const PC_CTRL: Modifiers = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
@@ -313,4 +323,11 @@ mod tests {
assert!(!search_chord(MAC_CMD, true));
assert!(search_chord(Modifiers { ctrl: true, ..MAC_CMD }, true));
}
#[test]
fn shift_cmd_k_prompts_for_a_folder_to_export_clips() {
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..MAC_CMD }, true), Some("file.export_clips"));
assert_eq!(command_for(Key::K, Modifiers { shift: true, ..PC_CTRL }, false), Some("file.export_clips"));
assert!(crate::dialogs::takes_path("file.export_clips"));
}
}
+16 -168
View File
@@ -1,11 +1,10 @@
//! Design tokens. Colours were chosen by eye to give a dark, studio-style look; they are ours.
use craft_ui::theme::catppuccin::Flavor;
use egui::{Color32, FontFamily, FontId};
use std::sync::atomic::{AtomicU8, Ordering};
use std::sync::atomic::{AtomicBool, Ordering};
/// Index into `ThemeMode::ALL` of the active palette. Dark (0) is the default (the incumbent is dark-only).
static ACTIVE_THEME: AtomicU8 = AtomicU8::new(0);
/// Whether the Light palette is active. Dark is the default (the incumbent is dark-only).
static LIGHT_THEME: AtomicBool = AtomicBool::new(false);
/// Unit tests that change the process-wide palette must own it until their assertions finish.
#[cfg(test)]
@@ -14,7 +13,7 @@ pub(crate) fn lock_test_palette() -> std::sync::MutexGuard<'static, ()> {
PALETTE.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System, Light and the Catppuccin flavours are opt-in.
/// The Appearance setting (`ui.theme {mode}`). Dark by default; System and Light are opt-in.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ThemeMode {
@@ -22,45 +21,16 @@ pub enum ThemeMode {
Light,
#[default]
Dark,
#[serde(rename = "catppuccinLatte")]
CatppuccinLatte,
#[serde(rename = "catppuccinFrappe")]
CatppuccinFrappe,
#[serde(rename = "catppuccinMacchiato")]
CatppuccinMacchiato,
#[serde(rename = "catppuccinMocha")]
CatppuccinMocha,
}
impl ThemeMode {
pub const ALL: [Self; 7] =
[Self::Dark, Self::Light, Self::System, Self::CatppuccinLatte, Self::CatppuccinFrappe, Self::CatppuccinMacchiato, Self::CatppuccinMocha];
/// The Catppuccin flavour behind this mode, if it is one.
pub const fn flavor(self) -> Option<Flavor> {
match self {
Self::CatppuccinLatte => Some(Flavor::Latte),
Self::CatppuccinFrappe => Some(Flavor::Frappe),
Self::CatppuccinMacchiato => Some(Flavor::Macchiato),
Self::CatppuccinMocha => Some(Flavor::Mocha),
Self::System | Self::Light | Self::Dark => None,
}
}
/// Every id, `|`-separated: for error messages.
pub fn id_list() -> String {
Self::ALL.map(Self::id).join("|")
}
pub const ALL: [Self; 3] = [Self::Dark, Self::Light, Self::System];
pub const fn label(self) -> &'static str {
match self {
Self::System => "System",
Self::Light => "Light",
Self::Dark => "Dark",
Self::CatppuccinLatte => "Catppuccin Latte",
Self::CatppuccinFrappe => "Catppuccin Frappé",
Self::CatppuccinMacchiato => "Catppuccin Macchiato",
Self::CatppuccinMocha => "Catppuccin Mocha",
}
}
@@ -70,10 +40,6 @@ impl ThemeMode {
Self::System => "system",
Self::Light => "light",
Self::Dark => "dark",
Self::CatppuccinLatte => "catppuccinLatte",
Self::CatppuccinFrappe => "catppuccinFrappe",
Self::CatppuccinMacchiato => "catppuccinMacchiato",
Self::CatppuccinMocha => "catppuccinMocha",
}
}
@@ -360,126 +326,21 @@ impl Tokens {
};
pub fn current() -> Tokens {
Self::for_mode(active_mode())
}
/// The tokens of a resolved mode (`System` reads as Dark: resolve it with `resolve` first).
pub fn for_mode(mode: ThemeMode) -> Tokens {
match mode.flavor() {
Some(f) => catppuccin(f),
None if mode == ThemeMode::Light => Self::LIGHT,
None => Self::DARK,
}
if is_light() { Self::LIGHT } else { Self::DARK }
}
}
/// The palette in use: never `System`.
pub fn active_mode() -> ThemeMode {
ThemeMode::ALL.get(usize::from(ACTIVE_THEME.load(Ordering::Relaxed))).copied().unwrap_or_default()
}
/// Whether the active palette is a light one (Light or Catppuccin Latte).
/// Whether the Light palette is the active one.
pub fn is_light() -> bool {
let mode = active_mode();
mode.flavor().map_or(mode == ThemeMode::Light, |f| !f.is_dark())
LIGHT_THEME.load(Ordering::Relaxed)
}
/// Resolve each frame from the integration's current OS appearance. Unknown uses Dark.
pub fn resolve(ctx: &egui::Context, mode: ThemeMode) -> ThemeMode {
pub fn wants_light(ctx: &egui::Context, mode: ThemeMode) -> bool {
match mode {
ThemeMode::System if ctx.system_theme() == Some(egui::Theme::Light) => ThemeMode::Light,
ThemeMode::System => ThemeMode::Dark,
_ => mode,
}
}
/// A Catppuccin flavour on SoundCraft's token set (accent Mauve, selection Blue).
fn catppuccin(f: Flavor) -> Tokens {
let p = f.palette();
let dark = f.is_dark();
let a = |c: Color32, alpha: u8| Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), alpha);
let tint = |c: Color32, t: f32| p.base.lerp_to_gamma(c, t);
Tokens {
window_bg: p.base,
panel_bg: p.base,
panel_bg2: p.surface0,
toolbar_bg: p.mantle,
toolbar_group: p.crust,
border: if dark { p.crust } else { p.surface1 },
border_light: p.surface1,
text: p.text,
text_dim: p.subtext0,
text_dark: if dark { p.crust } else { p.text },
header_text: p.subtext1,
counter_bg: p.crust,
counter_text: p.green,
counter_label: p.subtext1,
accent: p.mauve,
accent_dark: tint(p.mauve, 0.6),
mode_on: p.green,
mode_on_text: p.crust,
mode_off_text: p.green,
button: p.surface0,
button_hi: p.surface1,
button_border: p.surface2,
name_field: if dark { p.text } else { p.base },
name_field_sel: p.rosewater,
ruler_bg: p.mantle,
ruler_label_bg: p.mantle,
ruler_text: p.subtext0,
ruler_tick: p.overlay0,
tempo_ruler: tint(p.green, 0.5),
meter_ruler: tint(p.sapphire, 0.5),
marker_ruler: p.surface0,
playlist_bg: p.base,
playlist_alt: p.mantle,
grid_line: p.surface0,
bar_line: p.surface1,
selection: a(p.blue, 110),
playhead: p.red,
insertion: p.text,
solo: p.yellow,
mute: p.peach,
rec: p.red,
input: p.green,
auto_read: p.green,
auto_write: p.red,
auto_touch: p.peach,
auto_latch: p.mauve,
meter_green: p.green,
meter_yellow: p.yellow,
meter_red: p.red,
fader_track: p.crust,
strip_bg: p.mantle,
strip_section: p.base,
slot_bg: p.crust,
automation_line: p.text,
menu_bar: p.mantle,
group_border: if dark { p.crust } else { p.surface1 },
mode_off: p.surface0,
counter_off: p.surface1,
counter_border: p.surface1,
counter_rule: p.surface0,
tool_bracket_off: p.surface1,
row_selected: tint(p.blue, 0.35),
visible_dot: p.overlay1,
big_counter_bg: p.crust,
header_selected: p.surface0,
strip_selected: p.surface0,
marker_text: p.text,
playlist_lane: p.mantle,
universe_bg: p.crust,
universe_view: p.text,
meter_bg: p.crust,
meter_clip_off: tint(p.red, 0.3),
knob_bg: p.surface0,
knob_ring: p.overlay0,
knob_pointer: p.text,
insert_on: tint(p.blue, 0.3),
insert_bypass: tint(p.yellow, 0.3),
send_on: tint(p.green, 0.3),
slot_border: p.surface1,
section_label: p.subtext0,
ThemeMode::Light => true,
ThemeMode::Dark => false,
ThemeMode::System => ctx.system_theme() == Some(egui::Theme::Light),
}
}
@@ -518,12 +379,11 @@ pub fn mono(size: f32) -> FontId {
FontId::new(size, FontFamily::Monospace)
}
/// Make `mode` (already resolved, not `System`) the active palette and apply its visuals. Call only when it changes.
pub fn apply(ctx: &egui::Context, mode: ThemeMode) {
let index = ThemeMode::ALL.iter().position(|m| *m == mode).unwrap_or_default();
ACTIVE_THEME.store(u8::try_from(index).unwrap_or_default(), Ordering::Relaxed);
/// Make `light` (or dark) the active palette and apply its visuals. Call only when it changes.
pub fn apply(ctx: &egui::Context, light: bool) {
LIGHT_THEME.store(light, Ordering::Relaxed);
let t = Tokens::current();
let mut v = if is_light() { egui::Visuals::light() } else { egui::Visuals::dark() };
let mut v = if light { egui::Visuals::light() } else { egui::Visuals::dark() };
v.panel_fill = t.panel_bg;
v.window_fill = t.panel_bg2;
v.extreme_bg_color = t.slot_bg;
@@ -576,18 +436,6 @@ mod tests {
assert_eq!(ThemeMode::parse("sepia"), None);
}
#[test]
fn catppuccin_themes_use_the_flavour_palette() {
for mode in ThemeMode::ALL {
let Some(f) = mode.flavor() else { continue };
let (t, p) = (Tokens::for_mode(mode), f.palette());
assert_eq!((t.window_bg, t.panel_bg, t.text, t.accent), (p.base, p.base, p.text, p.mauve), "{}", mode.id());
}
assert_eq!(ThemeMode::ALL.iter().filter(|m| m.flavor().is_some()).count(), Flavor::ALL.len());
assert_eq!(ThemeMode::parse("catppuccinMocha"), Some(ThemeMode::CatppuccinMocha));
assert_eq!(ThemeMode::id_list().split('|').count(), ThemeMode::ALL.len());
}
#[test]
fn theme_command_sets_and_reports_the_mode() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
+4 -4
View File
@@ -405,7 +405,7 @@ mod selector_visual_regressions {
move |ui, regions: &mut serde_json::Map<String, serde_json::Value>| {
if !ready {
crate::fonts::install(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::ThemeMode::Dark);
crate::theme::apply(ui.ctx(), false);
ready = true;
return;
}
@@ -458,7 +458,7 @@ mod selector_visual_regressions {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
crate::theme::apply(&ctx, crate::theme::ThemeMode::Dark);
crate::theme::apply(&ctx, false);
let mut app = mixer_app(narrow, mode);
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 640.0), scale), |ui| crate::mix_window::show(&mut app, ui));
output.textures_delta.clear();
@@ -505,7 +505,7 @@ mod selector_visual_regressions {
move |ui, app: &mut crate::SoundApp| {
if !ready {
crate::fonts::install(ui.ctx());
crate::theme::apply(ui.ctx(), crate::theme::ThemeMode::Dark);
crate::theme::apply(ui.ctx(), false);
ready = true;
return;
}
@@ -553,7 +553,7 @@ mod selector_visual_regressions {
for scale in [1.0, 1.5, 2.0] {
let ctx = egui::Context::default();
crate::fonts::install(&ctx);
crate::theme::apply(&ctx, crate::theme::ThemeMode::Dark);
crate::theme::apply(&ctx, false);
let mut rect = Rect::NOTHING;
let mut output = ctx.run_ui(scaled_input(vec2(320.0, 120.0), scale), |ui| {
rect = selector_box(ui, width, 16.0, label, Tokens::current().text).rect;
+8 -5
View File
@@ -350,7 +350,7 @@ fn workspace(app: &mut SoundApp, ctx: &egui::Context) {
if is_dir {
app.ui.workspace_dir = path;
} else if name.ends_with(".scraft") {
let _ = app.run("session.open", json!({"path": path}));
let _ = crate::dialogs::run_or_ask(app, "session.open", json!({"path": path}));
} else if name.ends_with(".mid") || name.ends_with(".midi") {
let _ = app.run("file.import_midi", json!({"path": path}));
} else {
@@ -493,10 +493,13 @@ pub fn shortcuts_window(app: &mut SoundApp, ctx: &egui::Context) {
win(ctx, &mut open, "Keyboard Shortcuts", vec2(520.0, 480.0), |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
egui::Grid::new("sc").num_columns(3).striped(true).show(ui, |ui| {
for c in soundcraft_engine::command_specs().iter().filter(|c| c.shortcut.is_some()) {
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(c.shortcut.unwrap_or(""), mac)).font(mono(11.0)));
ui.label(c.label);
ui.label(egui::RichText::new(c.menu.join(" › ")).small());
let engine = soundcraft_engine::command_specs().iter().map(|c| (c.shortcut, c.label, c.menu.join(" › ")));
let windows = crate::menus::UI_COMMANDS.iter().map(|c| (c.3, c.1, c.2.replace(" > ", " › ")));
for (shortcut, label, menu) in engine.chain(windows) {
let Some(shortcut) = shortcut else { continue };
ui.label(egui::RichText::new(crate::shortcuts::shortcut_label(shortcut, mac)).font(mono(11.0)));
ui.label(label);
ui.label(egui::RichText::new(menu).small());
ui.end_row();
}
});
+47
View File
@@ -0,0 +1,47 @@
use egui::accesskit::Role;
use egui::{Event, Modifiers, PointerButton, Pos2, Vec2, pos2, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use soundcraft_ui_egui::{Services, SoundApp};
const WINDOW_PX: Vec2 = vec2(1600.0, 1000.0);
fn frame(h: &mut Harness<'_, SoundApp>, px: Pos2, pressed: Option<bool>) {
let zoom = h.ctx.zoom_factor();
h.set_size(WINDOW_PX / zoom);
let pos = (px.to_vec2() / zoom).to_pos2();
h.input_mut().events.push(Event::PointerMoved(pos));
if let Some(pressed) = pressed {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
}
h.step();
}
#[test]
fn interface_scale_slider_follows_the_pointer() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.extra.show_ui_customization = true;
let mut h = Harness::builder().with_size(WINDOW_PX).with_pixels_per_point(1.0).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(6);
let rail = h.get_by_role(Role::Slider).rect();
let start = pos2(rail.left() + rail.width() / 3.0, rail.center().y);
let end = pos2(rail.right() + 40.0, rail.center().y);
frame(&mut h, start, Some(true));
let mut scales = vec![];
for i in 1..=20 {
frame(&mut h, start.lerp(end, i as f32 / 20.0), None);
scales.push(h.state().extra.ui_scale);
}
assert!(scales.windows(2).all(|w| w[0] <= w[1]), "{scales:?}");
frame(&mut h, end, Some(false));
frame(&mut h, end, None);
assert_eq!(h.state().extra.ui_scale, 1.5);
assert_eq!(h.ctx.zoom_factor(), 1.5);
}
+15
View File
@@ -0,0 +1,15 @@
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
#[test]
fn undo_and_redo_name_the_step_they_would_take() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let label = |app: &SoundApp, id| menus::live_label(app, Some(id));
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Can't Undo"));
app.run("mix.mute", json!({"track": "Kick"})).unwrap();
assert_eq!(label(&app, "edit.undo").as_deref(), Some("Undo Mute"));
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Can't Redo"));
app.run("edit.undo", json!({})).unwrap();
assert_eq!(label(&app, "edit.redo").as_deref(), Some("Redo Mute"));
assert_eq!(label(&app, "edit.copy"), None);
}
+28
View File
@@ -0,0 +1,28 @@
use soundcraft_ui_egui::{Services, SoundApp};
use std::time::Duration;
fn pass(ctx: &egui::Context, app: &mut SoundApp, shown: bool) -> Duration {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
app.logic(ui.ctx());
if shown {
app.ui(ui);
}
});
out.textures_delta.clear();
out.viewport_output[&egui::ViewportId::ROOT].repaint_delay
}
#[test]
fn only_a_shown_window_animates_and_a_hidden_one_wakes_once_a_second() {
let ctx = egui::Context::default();
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
for _ in 0..3 {
pass(&ctx, &mut app, true);
}
assert_ne!(pass(&ctx, &mut app, true), Duration::ZERO);
app.run("transport.play", serde_json::json!({})).unwrap();
// Playing repaints at the meter rate (30 Hz in the Edit window, #207).
assert!(pass(&ctx, &mut app, true) <= Duration::from_millis(34));
pass(&ctx, &mut app, false);
assert!((pass(&ctx, &mut app, false).as_secs_f64() - 1.0).abs() < 1e-3);
}
@@ -0,0 +1,172 @@
use egui::{Color32, Event, FullOutput, Rect, Shape, pos2, vec2};
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, theme::ThemeMode};
#[derive(Debug)]
struct Slot {
rect: Rect,
fill: Color32,
text: Color32,
border: Color32,
}
// Read the rectangle and foreground emitted by the real Mix drawing path, rather than
// testing a palette token that a custom-painted control might never use.
fn slot(output: &FullOutput, label: &str) -> Slot {
let text = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Text(text) if text.galley.job.text == label => Some(text),
_ => None,
})
.unwrap_or_else(|| panic!("missing painted label {label}"));
let center = text.visual_bounding_rect().center();
let rect = output
.shapes
.iter()
.find_map(|shape| match &shape.shape {
Shape::Rect(rect) if rect.rect.height() == 15.0 && rect.rect.contains(center) => Some(rect),
_ => None,
})
.unwrap_or_else(|| panic!("missing painted slot for {label}"));
let foreground = text.override_text_color.unwrap_or(text.galley.job.sections[0].format.color);
assert_eq!(text.opacity_factor, 1.0);
assert_eq!(foreground.a(), 255);
assert_eq!(rect.fill.a(), 255);
Slot { rect: rect.rect, fill: rect.fill, text: foreground, border: rect.stroke.color }
}
fn contrast(a: Color32, b: Color32) -> f64 {
let luminance = |color: Color32| {
let linear = |channel: u8| {
let s = f64::from(channel) / 255.0;
if s <= 0.04045 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
};
0.2126 * linear(color.r()) + 0.7152 * linear(color.g()) + 0.0722 * linear(color.b())
};
let (a, b) = (luminance(a), luminance(b));
(a.max(b) + 0.05) / (a.min(b) + 0.05)
}
#[test]
fn populated_mix_instrument_uses_readable_palette_in_real_frames() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
assert_eq!(app.ui.theme, ThemeMode::Dark);
// Keep demo audio loaded for the preservation check while isolating the new strips.
for track in &mut app.engine.session_mut().tracks {
track.hidden = true;
}
app.run("track.new", json!({"kind": "instrument", "name": "Built-in instrument"})).unwrap();
let instrument = app.engine.session().edit.selected_tracks[0];
assert_eq!(app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap().plugin, "subtractive_synth");
app.run("mix.insert", json!({"track": instrument.0, "slot": 0, "plugin": "eq_7band"})).unwrap();
app.run("track.new", json!({"kind": "instrument", "name": "Empty instrument"})).unwrap();
// An unassigned instrument is a supported model state; track.new normally supplies a synth.
let empty = app.engine.session().edit.selected_tracks[0];
app.engine.session_mut().track_mut(empty).unwrap().instrument = None;
app.run("window.mix", json!({})).unwrap();
app.run("view.mix_section", json!({"section": "minimal"})).unwrap();
for section in ["instruments", "inserts_ae"] {
app.run("view.mix_section", json!({"section": section})).unwrap();
}
let session = app.engine.session().clone();
assert!(!session.pool.is_empty(), "the fixture must include decoded audio");
let proof_dir = std::env::var_os("SOUNDCRAFT_MIX_PROOF_DIR").map(std::path::PathBuf::from);
let mut builder = egui_kittest::Harness::builder().with_size(vec2(1600.0, 1000.0)).with_pixels_per_point(2.0);
if proof_dir.is_some() {
builder = builder.wgpu();
}
let mut harness = builder.build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(ui.ctx());
app.ui(ui);
},
app,
);
harness.input_mut().max_texture_side = Some(8192);
let mut failures = Vec::new();
for (name, mode, system, light) in [
("light", ThemeMode::Light, Some(egui::Theme::Dark), true),
("dark", ThemeMode::Dark, Some(egui::Theme::Light), false),
("system-light", ThemeMode::System, Some(egui::Theme::Light), true),
("system-dark", ThemeMode::System, Some(egui::Theme::Dark), false),
] {
harness.state_mut().run("ui.theme", json!({"mode": mode.id()})).unwrap();
harness.input_mut().system_theme = system;
harness.input_mut().events.push(Event::PointerMoved(pos2(1550.0, 950.0)));
for _ in 0..4 {
harness.step();
}
let ordinary = slot(harness.output(), "SubSynth");
let insert = slot(harness.output(), "EQ7");
let empty = slot(harness.output(), "no instrument");
let palette = if light { soundcraft_ui_egui::theme::Tokens::LIGHT } else { soundcraft_ui_egui::theme::Tokens::DARK };
assert_eq!(insert.fill, palette.insert_on);
assert_eq!(empty.fill, palette.slot_bg);
assert_eq!(ordinary.text, palette.text);
if ordinary.fill != insert.fill || ordinary.border != insert.border {
failures.push(format!("{name}: instrument {ordinary:?} differs from populated insert {insert:?}"));
}
for hovered in [false, true] {
if hovered {
harness.input_mut().events.push(Event::PointerMoved(ordinary.rect.center()));
harness.step();
harness.step();
}
let painted = slot(harness.output(), "SubSynth");
let ratio = contrast(painted.text, painted.fill);
println!(
"{name} hover={hovered}: foreground={:?}, fill={:?}, contrast={ratio:.6}:1, rect={:?}",
painted.text.to_array(),
painted.fill.to_array(),
painted.rect
);
if ratio < 4.5 {
failures.push(format!("{name} hover={hovered}: contrast {ratio:.6}:1 is below 4.5:1"));
}
if !light {
let original = Color32::from_rgb(52, 62, 80);
assert_eq!(painted.fill, if hovered { original.gamma_multiply(1.3) } else { original });
assert_eq!(painted.border, Color32::from_rgb(16, 16, 16));
}
if let Some(dir) = &proof_dir {
harness.render().unwrap().save(dir.join(format!("{name}-{}.png", if hovered { "hover" } else { "ordinary" }))).unwrap();
}
}
assert_eq!(harness.state().ui.theme, mode);
assert_eq!(harness.state().engine.session(), &session, "appearance and hover must preserve the full session and decoded audio pool");
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
// Exercise the same painted control's ordinary click/picker path after palette changes.
let at = slot(harness.output(), "SubSynth").rect.center();
for pressed in [true, false] {
harness.input_mut().events.push(Event::PointerButton {
pos: at,
button: egui::PointerButton::Primary,
pressed,
modifiers: Default::default(),
});
harness.step();
}
harness.step(); // Let the popup finish its initial sizing pass.
harness.get_by_label_contains("Built-in").hover();
harness.step();
harness.step();
harness.get_by_label("Drum Synth").click();
harness.step();
harness.step();
let app = harness.state();
let replacement = app.engine.session().track(instrument).unwrap().instrument.as_ref().unwrap();
assert_eq!(replacement.plugin, "drum_synth");
for param in soundcraft_dsp::plugin_info("drum_synth").unwrap().params {
assert_eq!(replacement.params.get(param.id), Some(&param.default));
}
assert_eq!(app.engine.session().edit.selected_tracks, session.edit.selected_tracks);
assert_eq!(app.engine.session().pool, session.pool);
assert!(app.ui.plugin_windows.is_empty(), "a built-in instrument opens its picker");
harness.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(harness.state().engine.session(), &session);
}
+19
View File
@@ -0,0 +1,19 @@
use egui_kittest::Harness;
use soundcraft_ui_egui::{Services, SoundApp};
#[test]
fn a_long_plugin_window_stays_on_a_short_screen() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let kick = app.engine.session().track_by_name("Kick").unwrap().id;
app.ui.plugin_windows = vec![(kick, 0)];
let mut h = Harness::builder().with_size(egui::vec2(1200.0, 500.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(4);
let window = h.ctx.memory(|m| m.area_rect(egui::Id::new(("plugin", kick.0, 0usize)))).unwrap();
assert!(window.bottom() <= 500.0, "{window:?}");
}
+30
View File
@@ -0,0 +1,30 @@
use egui::{Event, Key, Modifiers, vec2};
use egui_kittest::Harness;
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, menus};
#[test]
fn typing_in_the_rename_clip_dialog_replaces_the_old_name() {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let clip = app.engine.session().track_by_name("Kick").unwrap().clips()[0].clone();
app.run("edit.select", json!({"clips": [clip.id.0]})).unwrap();
menus::invoke_menu(&mut app, "clip.rename", "Clip > Rename...");
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
for _ in 0..3 {
h.step();
}
h.input_mut().events.push(Event::Text("Intro".into()));
h.step();
h.input_mut().events.push(Event::Key { key: Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
let session = h.state().engine.session();
let renamed = session.tracks.iter().flat_map(|t| t.clips()).find(|c| c.id == clip.id).map(|c| c.name.clone());
assert_eq!(renamed.as_deref(), Some("Intro"));
}
+38
View File
@@ -0,0 +1,38 @@
//! Save As over another session asks first, and Enter alone never replaces it.
use egui::vec2;
use egui_kittest::{Harness, kittest::Queryable};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, dialogs::Dialog};
#[test]
fn saving_over_another_session_takes_the_replace_button() {
let dir = std::env::temp_dir().join(format!("soundcraft-ui-replace-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("Song.scraft");
soundcraft_engine::Engine::default().execute("session.save_as", &json!({"path": path})).unwrap();
let other = std::fs::read(&path).unwrap();
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
app.dialogs.open = Some(Dialog::PathPrompt {
cmd: "session.save_as".into(),
title: "Save Session As".into(),
path: path.to_string_lossy().into(),
key: "path".into(),
});
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(3);
h.key_press(egui::Key::Enter);
h.run_steps(3);
assert_eq!(std::fs::read(&path).unwrap(), other);
h.get_by_label("Replace").click();
h.run_steps(3);
assert_ne!(std::fs::read(&path).unwrap(), other);
assert_eq!(h.state().engine.path, Some(path.to_string_lossy().into_owned()));
let _ = std::fs::remove_dir_all(&dir);
}
+97
View File
@@ -0,0 +1,97 @@
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use serde_json::json;
use soundcraft_ui_egui::dialogs::Dialog;
use soundcraft_ui_egui::{Services, SoundApp, menus};
fn harness() -> Harness<'static, SoundApp> {
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_ui_state(
|ui, app: &mut SoundApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
)
}
fn steps(h: &mut Harness<'_, SoundApp>) {
for _ in 0..4 {
h.step();
}
}
fn click(h: &mut Harness<'_, SoundApp>, label: &str) {
h.get_by_label(label).click();
steps(h);
}
fn close_session(h: &mut Harness<'_, SoundApp>) {
menus::invoke_menu(h.state_mut(), "session.close", "File > Close Session");
steps(h);
}
fn mute_kick(h: &mut Harness<'_, SoundApp>) {
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
}
#[test]
fn closing_a_modified_session_asks_first() {
let mut h = harness();
let tracks = h.state().engine.session().tracks.len();
h.state_mut().run("edit.select", json!({"tracks": ["Kick"], "start": 0, "end": 48_000})).unwrap();
close_session(&mut h);
assert!(h.state().dialogs.open.is_none(), "a selection alone is not a change");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
mute_kick(&mut h);
close_session(&mut h);
assert_eq!(h.state().dialogs.open_name(), Some("save_changes"));
click(&mut h, "Cancel");
assert!(h.state().dialogs.open.is_none());
assert_eq!(h.state().engine.session().tracks.len(), tracks);
close_session(&mut h);
click(&mut h, "Don't Save");
assert!(h.state().engine.session().tracks.is_empty());
let mut h = harness();
mute_kick(&mut h);
menus::invoke_menu(h.state_mut(), "app.quit", "");
steps(&mut h);
assert!(!h.state().quit_requested);
click(&mut h, "Don't Save");
assert!(h.state().quit_requested);
}
#[test]
fn save_in_the_question_saves_before_closing() {
let dir = std::env::temp_dir().join(format!("soundcraft-save-changes-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join("Song.scraft").to_string_lossy().into_owned();
let mut h = harness();
mute_kick(&mut h);
close_session(&mut h);
click(&mut h, "Save");
match &mut h.state_mut().dialogs.open {
Some(Dialog::PathPrompt { cmd, path: p, .. }) if cmd == "session.save_as" => p.clone_from(&path),
other => panic!("expected Save As, got {other:?}"),
}
steps(&mut h);
click(&mut h, "OK");
assert!(h.state().engine.session().tracks.is_empty());
h.state_mut().run("session.open", json!({"path": path})).unwrap();
assert!(h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
h.state_mut().run("mix.mute", json!({"track": "Kick"})).unwrap();
close_session(&mut h);
click(&mut h, "Save");
assert!(h.state().dialogs.open.is_none());
assert!(h.state().engine.session().tracks.is_empty());
h.state_mut().run("session.open", json!({"path": path})).unwrap();
assert!(!h.state().engine.session().track_by_name("Kick").unwrap().mixer.mute);
let _ = std::fs::remove_dir_all(&dir);
}
+1 -1
View File
@@ -180,7 +180,7 @@ fn shared_controls_visuals() {
}
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
|ui, rects: &mut Vec<egui::Rect>| {
theme::apply(ui.ctx(), theme::ThemeMode::Dark);
theme::apply(ui.ctx(), false);
rects.clear();
for enabled in [true, false] {
ui.add_enabled_ui(enabled, |ui| {
+21 -1
View File
@@ -1,7 +1,7 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use serde_json::json;
use soundcraft_ui_egui::{Services, SoundApp, shortcuts};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
@@ -24,3 +24,23 @@ fn ctrl_shift_arrows_nudge_the_selected_clip_gain() {
assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0, "{os:?}");
}
}
#[test]
fn cmd_equals_toggles_mix_and_edit_without_zooming() {
let mac_cmd = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (os, cmd) in [(OperatingSystem::Mac, mac_cmd), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(os);
ctx.set_zoom_factor(1.25);
press(&ctx, &mut app, Key::Equals, cmd);
assert_eq!(app.ui.window, MainWindow::Mix, "{os:?}");
press(&ctx, &mut app, Key::Equals, cmd);
press(&ctx, &mut app, Key::Num0, cmd);
press(&ctx, &mut app, Key::Minus, cmd);
ctx.run_ui(egui::RawInput::default(), |_| {}).textures_delta.clear();
assert_eq!(app.ui.window, MainWindow::Edit, "{os:?}");
assert_eq!(ctx.zoom_factor(), 1.25, "{os:?}");
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
}
}
let ctx = egui::Context::default();
theme::apply(&ctx, if light { theme::ThemeMode::Light } else { theme::ThemeMode::Dark });
theme::apply(&ctx, light);
let tokens = theme::Tokens::current();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
widgets::icon_button(ui, vec2(38.0, 28.0), "loop", true, "Loop playback");
@@ -58,7 +58,7 @@ fn shared_panels_and_controls_follow_manual_and_live_system_appearance() {
if let Some(directory) = std::env::var_os("CRAFT_UI_THEME_SHOTS").map(std::path::PathBuf::from) {
let mut controls = Harness::builder().with_size(vec2(340.0, 150.0)).with_pixels_per_point(scale).wgpu().build_ui_state(
move |ui, _: &mut ()| {
theme::apply(ui.ctx(), if light { theme::ThemeMode::Light } else { theme::ThemeMode::Dark });
theme::apply(ui.ctx(), light);
for enabled in [true, false] {
ui.add_enabled_ui(enabled, |ui| {
ui.horizontal(|ui| {
+95
View File
@@ -0,0 +1,95 @@
use egui::{Pos2, RawInput, Rect, pos2, vec2};
use serde_json::{Value, json};
use soundcraft_time::to_samples;
use soundcraft_ui_egui::{ControlRequest, Services, SoundApp};
use std::sync::mpsc::{Sender, channel};
struct Rig {
ctx: egui::Context,
app: SoundApp,
tx: Sender<ControlRequest>,
width: f32,
}
impl Rig {
fn new(width: f32) -> Rig {
let (tx, rx) = channel();
let app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()).with_control(rx);
let mut r = Rig { ctx: egui::Context::default(), app, tx, width };
r.frames(6);
r
}
fn frames(&mut self, n: usize) {
for _ in 0..n {
let mut raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(self.width, 900.0))), ..Default::default() };
self.app.raw_input_hook(&mut raw);
self.ctx
.run_ui(raw, |ui| {
self.app.logic(ui.ctx());
self.app.ui(ui);
})
.textures_delta
.clear();
}
}
fn send(&mut self, method: &str, params: Value) {
let (req, reply) = ControlRequest::new(method, params);
self.tx.send(req).unwrap();
self.frames(40);
assert_eq!(reply.try_recv().unwrap()["ok"], true, "{method}");
}
fn view(&self) -> (f64, i64) {
let z = self.app.engine.session().edit.zoom;
(z.samples_per_px, z.scroll)
}
fn timeline(&self) -> Rect {
let [x0, y0, x1, y1] = self.app.edit_layout.timeline;
Rect::from_min_max(pos2(x0, y0), pos2(x1, y1))
}
}
#[test]
fn swipes_and_wheels_scroll_with_the_fingers_on_one_axis_even_slowly() {
let mut r = Rig::new(1400.0);
let (tl, (spp, scroll)) = (r.timeline(), r.view());
let (x, y) = (tl.center().x, tl.center().y);
assert!(r.app.edit_layout.content_h - tl.height() > 60.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -240.0, "dy": -60.0}));
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, 0.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -30.0, "dy": -60.0}));
assert_eq!(r.view(), (spp, scroll + to_samples(240.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, 60.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 1.0, "unit": "line", "steps": 1}));
assert!((r.app.edit_layout.scroll_y - 20.0).abs() < 1.0, "{}", r.app.edit_layout.scroll_y);
r.send("ui.scroll", json!({"x": x, "y": tl.min.y - 20.0, "dx": 100_000.0}));
assert_eq!(r.view(), (spp, 0));
r.app.engine.execute("view.zoom_set", &json!({"samples_per_px": 0.25})).unwrap();
let scroll_y = r.app.edit_layout.scroll_y;
r.send("ui.scroll", json!({"x": x, "y": y, "dx": -12.0, "dy": -6.0, "steps": 24}));
assert_eq!(r.view().1, 3);
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
}
#[test]
fn pinch_and_cmd_scroll_zoom_around_the_pointer_and_shift_scroll_pans() {
let mut r = Rig::new(1400.0);
let tl = r.timeline();
let (x, y) = (tl.min.x + 300.0, tl.center().y);
let under = |r: &Rig| r.view().1 as f64 + 300.0 * r.view().0;
let (spp, at) = (r.view().0, under(&r));
r.send("ui.zoom", json!({"x": x, "y": y, "factor": 4.0}));
assert!((r.view().0 * 4.0 / spp - 1.0).abs() < 1e-4, "{spp} -> {}", r.view().0);
assert!((under(&r) - at).abs() < 8.0);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": 100.0, "cmd": true}));
assert!(r.view().0 < spp / 4.0);
assert!((under(&r) - at).abs() < 8.0);
let ((spp, scroll), scroll_y) = (r.view(), r.app.edit_layout.scroll_y);
r.send("ui.scroll", json!({"x": x, "y": y, "dy": -100.0, "shift": true}));
assert_eq!(r.view(), (spp, scroll + to_samples(100.0 * spp)));
assert_eq!(r.app.edit_layout.scroll_y, scroll_y);
}
+43
View File
@@ -0,0 +1,43 @@
use egui::os::OperatingSystem;
use egui::{Key, Modifiers};
use soundcraft_ui_egui::{MainWindow, Services, SoundApp, shortcuts};
const MAC_CMD: Modifiers = Modifiers { mac_cmd: true, command: true, ..Modifiers::NONE };
fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear();
}
#[test]
fn window_shortcuts_open_and_close_windows() {
let pc_ctrl = Modifiers { ctrl: true, command: true, ..Modifiers::NONE };
for (os, cmd) in [(OperatingSystem::Mac, MAC_CMD), (OperatingSystem::Windows, pc_ctrl), (OperatingSystem::Nix, pc_ctrl)] {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(os);
for key in [Key::Num1, Key::Num3, Key::Num4, Key::Num5, Key::Num9] {
press(&ctx, &mut app, key, cmd);
}
press(&ctx, &mut app, Key::M, Modifiers { alt: true, ..cmd });
let ui = &app.ui;
assert!(
ui.show_transport && ui.show_big_counter && ui.show_automation && ui.show_memory_locations && ui.show_video && ui.narrow_mix,
"{os:?}"
);
press(&ctx, &mut app, Key::W, cmd);
assert!(!app.ui.show_transport && !app.ui.show_big_counter && !app.ui.show_memory_locations, "{os:?}");
}
}
#[test]
fn cmd_ctrl_equals_opens_the_score_editor_on_a_mac() {
let mut app = SoundApp::new(soundcraft_engine::Engine::default(), None, Services::default());
let ctx = egui::Context::default();
ctx.set_os(OperatingSystem::Mac);
press(&ctx, &mut app, Key::Equals, Modifiers { ctrl: true, ..MAC_CMD });
assert!(app.ui.show_score);
assert_eq!(app.ui.window, MainWindow::Edit);
press(&ctx, &mut app, Key::Equals, MAC_CMD);
assert_eq!(app.ui.window, MainWindow::Mix);
}
+7 -2
View File
@@ -1749,7 +1749,7 @@ impl EditorLink {
mod window {
use objc2::rc::Retained;
use objc2::{MainThreadMarker, MainThreadOnly};
use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowStyleMask};
use objc2_app_kit::{NSApplication, NSBackingStoreType, NSFloatingWindowLevel, NSWindow, NSWindowCollectionBehavior, NSWindowStyleMask};
use objc2_foundation::{NSPoint, NSRect, NSSize, NSString};
use std::ffi::{c_char, c_void};
@@ -1780,7 +1780,10 @@ mod window {
let mtm = MainThreadMarker::new().ok_or_else(|| "not on the main thread".to_string())?;
let _app = NSApplication::sharedApplication(mtm);
let rect = NSRect::new(NSPoint::new(200.0, 200.0), NSSize::new(w, h));
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable | NSWindowStyleMask::Miniaturizable;
// Plugin editors are transient windows. In particular they must not be
// minimizable: minimized NSWindows become persistent thumbnails in the macOS Dock,
// which made every editor visited in a session look like another running app.
let style = NSWindowStyleMask::Titled | NSWindowStyleMask::Closable;
// SAFETY: a freshly allocated window on the main thread; valid rect and style.
let window = unsafe {
NSWindow::initWithContentRect_styleMask_backing_defer(NSWindow::alloc(mtm), rect, style, NSBackingStoreType::Buffered, false)
@@ -1791,6 +1794,8 @@ mod window {
window.setTitle(&NSString::from_str(title));
window.setLevel(NSFloatingWindowLevel);
window.setHidesOnDeactivate(true);
window.setExcludedFromWindowsMenu(true);
window.setCollectionBehavior(NSWindowCollectionBehavior::Transient | NSWindowCollectionBehavior::IgnoresCycle);
window.center();
Ok(HostWindow { window: Retained::into_raw(window) })
}
+42
View File
@@ -0,0 +1,42 @@
ArtCraft brand assets: license
The ArtCraft name, wordmark and mark (the "ArtCraft Marks"), including every file in this
directory in every color, size and format, are owned by the ArtCraft Team. They are trademarks
and are NOT open source. They are not covered by SoundCraft's MIT OR Apache-2.0 license, and nothing in
that license, in NOTICE or in any other file of this repository grants any right to them.
Permission
The ArtCraft Team grants a limited, non-exclusive, non-transferable, revocable, royalty-free
permission to use the ArtCraft Marks only:
1. as part of copies of this repository; and
2. within SoundCraft itself, its documentation and its official builds, to identify SoundCraft as made
by the ArtCraft team and community and to link to getartcraft.com and its community.
Conditions
The permission holds only while every one of these is true:
- The marks are used exactly as supplied in this directory. Do not redraw, recolor, crop,
stretch, rotate, animate, outline or add effects to them, extract parts of them, or combine
them with any other name, mark or artwork.
- They are used only in the context of SoundCraft, and only to say that the ArtCraft team makes it.
They must never suggest that the ArtCraft Team made, sponsors, endorses, certifies or supports
any other product, service, fork, modified version, plug-in, organization or person.
- Neither the marks nor the ArtCraft name appear in the name, logo, icon, domain, social media
handle, app-store listing, advertising or marketing of anything other than SoundCraft.
Forks and modified versions
If you distribute or publish a modified version of SoundCraft, or any work derived from it, you must
remove the ArtCraft Marks from it (or replace them with your own) and must not call it ArtCraft or
present it as an ArtCraft product. You may say, in plain text, that your work is based on SoundCraft by
the ArtCraft team. A fork kept only to propose changes back to this repository may keep the marks
unchanged.
Everything else
Any use not expressly permitted here needs prior written permission from the ArtCraft Team
(https://getartcraft.com/). The permission ends automatically for anyone who breaks these terms.
All other rights are reserved.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#0b0b0c" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-10 -19.42 136.34 136.34" role="img" aria-label="ArtCraft">
<title>ArtCraft</title>
<path fill="#4e7bfb" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</svg>

After

Width:  |  Height:  |  Size: 341 B

+3 -1
View File
@@ -42,13 +42,15 @@ Errors: `{"id": 1, "ok": false, "error": "bad parameters for `mix.volume`: no tr
| `engine.execute` (alias `command`) | `{command, params}` | Run a command programmatically. **Never opens a dialog**; empty params use defaults or the current selection. |
| `engine.commands` | `{filter?}` | Every command: id, label, menu path, shortcut, params doc, enabled + reason. Includes UI-layer commands (`window.*`). |
| `engine.parity` | `{}` | Parity report against the incumbent's menu catalog. |
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, transport, undo label. |
| `session.inspect` | `{detail?: "summary"|"full"}` | The document: tracks, clips (with times), inserts (+params when full), sends, routing, automation, markers, groups, busses, sources (`loaded` true when decoded audio is in the pool), selection, zoom (`samples_per_px`, `scroll`), transport, undo label. |
| `ui.inspect` | `{}` | UI state (`window`, panels, dialogs), window size, playing/position, audio device, and `edit_layout` (timeline rect and every track row's rect, for clicking). |
| `ui.set` | any `UiState` fields | e.g. `{"window": "Mix", "narrow_mix": true}`. |
| `ui.menu.list` | `{}` | Every catalog menu path and the command that implements it (or null). |
| `ui.menu.invoke` | `{path}` or `{id}` | Like clicking the menu item: may open its dialog (e.g. `"Track > New..."`). |
| `ui.click` | `{x, y, button?, count?, cmd?, shift?, alt?, ctrl?}` | Synthetic click (window points). |
| `ui.drag` | `{x, y, to_x, to_y, steps?}` | Synthetic drag with the primary button. |
| `ui.scroll` | `{x, y, dx?, dy?, unit?: "point"\|"line", steps?, cmd?, shift?, alt?, ctrl?}` | A two-finger trackpad swipe over `(x, y)`: `dx`/`dy` points spread over `steps` frames (default 8). Positive values move the content right/down (natural scrolling). `unit: "line"` sends mouse-wheel notches instead. With `cmd` it zooms; with `shift` it scrolls sideways. |
| `ui.zoom` | `{x, y, factor, steps?}` | A trackpad pinch around `(x, y)`: `factor` > 1 spreads the fingers (zooms in). |
| `ui.move` | `{x, y}` | Move the pointer. |
| `ui.key` | `{key, cmd?, shift?, alt?, ctrl?}` | Key press (`Space`, `Enter`, `A`, `F7`, `=`…). |
| `ui.text` | `{text}` | Type text into the focused field. |
+2 -2
View File
@@ -22,9 +22,9 @@ claude mcp add soundcraft -- soundcraft-cli mcp --demo
|---|---|
| `list_commands` | Discover every command (id, label, menu path, shortcut, params doc, enabled). |
| `execute` | Run one command with JSON params. |
| `batch` | Run several commands in order (`keep_going` optional). |
| `batch` | Run several commands in order (`keep_going` optional). Any failed call makes the tool result `isError: true`; with `keep_going` the remaining calls still run and every result is listed. |
| `inspect_session` | Verify work: tracks, clips, mixer, plugins, sends, automation, markers, selection; each `sources[]` entry includes `loaded` (decoded audio in the pool). |
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). |
| `new_session`, `open_session`, `save_session` | Session files (`.scraft`, audio in `Audio Files/`). `open_session` returns `missing`: the media files that could not be loaded (empty when all are found). `save_session` won't save over another existing file unless `replace` is true. |
| `import_audio` | WAV, AIFF, FLAC, MP3, OGG, AAC/M4A, ALAC, CAF onto a new or existing track. |
| `bounce_mix` | Render to WAV/AIFF/FLAC; returns peak dBFS and integrated LUFS. |
| `parity` | Feature-parity report. |
@@ -26,10 +26,10 @@
<launchable type="desktop-id">ai.storyteller.soundcraft.desktop</launchable>
<icon type="stock">ai.storyteller.soundcraft</icon>
<!-- Only url types known to appstream 0.15 (Ubuntu 22.04, where CI validates): no vcs-browser. -->
<url type="homepage">https://github.com/storytold/soundcraft</url>
<url type="homepage">https://getartcraft.com/apps/soundcraft</url>
<url type="bugtracker">https://github.com/storytold/soundcraft/issues</url>
<url type="help">https://github.com/storytold/soundcraft</url>
<url type="contact">https://github.com/storytold/soundcraft/issues</url>
<url type="contact">https://discord.gg/artcraft</url>
<categories>
<category>AudioVideo</category>
<category>Audio</category>
+1 -1
View File
@@ -14,7 +14,7 @@ section: sound
priority: optional
maintainer: ${SOUNDCRAFT_MAINTAINER}
vendor: Learning Machines LLC
homepage: https://github.com/storytold/soundcraft
homepage: https://getartcraft.com/apps/soundcraft
license: MIT OR Apache-2.0
description: |-
Open-source, native digital audio workstation. Record, edit and mix
+2 -2
View File
@@ -2,8 +2,8 @@
What Finder shows when the DMG opens: a 660 × 400 pt background with the app icon and the
`Applications` link side by side. `package.sh` copies these files into the image; nothing here is
generated at build time, so the DMG still builds with `hdiutil makehybrid` (no mounted device, no
Finder scripting on CI, nothing extra installed in the signing job).
generated at build time, so the DMG builds with a plain `hdiutil create -srcfolder` (no Finder
scripting on CI, nothing extra installed in the signing job).
| File | What |
|---|---|
+48 -7
View File
@@ -109,6 +109,10 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
plutil -lint "$APP/Contents/Info.plist"
printf 'APPL????' >"$APP/Contents/PkgInfo"
# Drop extended attributes picked up during the build (quarantine, FinderInfo) before signing:
# `codesign --verify --strict` rejects them, and nothing after signing may touch the bundle.
xattr -cr "$APP"
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
sign --options runtime --entitlements "$HERE/entitlements.plist" "$APP/Contents/MacOS/SoundCraft"
@@ -133,15 +137,52 @@ ln -s /Applications "$STAGE/Applications"
mkdir -p "$STAGE/.background"
cp "$HERE/dmg/background.tiff" "$STAGE/.background/background.tiff"
cp "$HERE/dmg/dmg-layout.DS_Store" "$STAGE/.DS_Store"
rm -f "$DMG" "$WORK/raw.dmg"
# makehybrid + convert builds the image without attaching a device, unlike `create -srcfolder`,
# which is flaky on CI runners ("Resource busy") and hangs in sandboxed sessions.
# The volume name has no version: .DS_Store finds the background through an alias that includes it.
hdiutil makehybrid -hfs -hfs-volume-name "SoundCraft" -hfs-openfolder "$STAGE" -o "$WORK/raw.dmg" "$STAGE"
hdiutil convert "$WORK/raw.dmg" -format UDZO -imagekey zlib-level=9 -o "$DMG"
rm -f "$WORK/raw.dmg"
rm -f "$DMG"
# `hdiutil create -srcfolder` writes the files as they are. `makehybrid -hfs` gave every file a
# com.apple.FinderInfo, which `codesign --verify --strict` rejects as "Finder information, or
# similar detritus" on the app in the image and on a copy installed from it (#176). `create`
# attaches a device while it copies, which fails now and then on CI runners ("Resource busy"), so
# it gets a few tries. The volume name has no version: .DS_Store finds the background through an
# alias that includes it.
# Usage: hdiutil_retry <what> <hdiutil args…>; tries five times with growing pauses.
hdiutil_retry() {
local what="$1" attempt
shift
for attempt in 1 2 3 4 5; do
if hdiutil "$@"; then
return 0
fi
[ "$attempt" = 5 ] && { echo "hdiutil $what failed 5 times" >&2; return 1; }
warn "hdiutil $what failed (attempt $attempt of 5); retrying in $((attempt * 10))s"
sleep $((attempt * 10))
done
}
hdiutil_retry create create -srcfolder "$STAGE" -volname "SoundCraft" -fs HFS+ -format UDZO -imagekey zlib-level=9 -ov "$DMG"
sign "$DMG"
codesign --verify --strict --verbose=2 "$DMG"
# The app users get must still pass --strict: inside the finished image and after a ditto copy out
# of it, so a valid container signature can't hide a damaged app (or a makehybrid-style regression).
MOUNT="$WORK/dmg-check"
COPY="$WORK/dmg-copy"
rm -rf "$MOUNT" "$COPY"
mkdir -p "$MOUNT" "$COPY"
hdiutil_retry attach attach -nobrowse -readonly -noautoopen -mountpoint "$MOUNT" "$DMG" >/dev/null
dmg_ok=1
codesign --verify --strict --deep --verbose=2 "$MOUNT/SoundCraft.app" || dmg_ok=0
if [ "$dmg_ok" = 1 ]; then
{ ditto "$MOUNT/SoundCraft.app" "$COPY/SoundCraft.app" &&
codesign --verify --strict --deep --verbose=2 "$COPY/SoundCraft.app"; } || dmg_ok=0
fi
if [ "$dmg_ok" = 1 ] && [ "$NOTARIZE" = 1 ]; then
spctl --assess --type execute -vvv "$COPY/SoundCraft.app" || dmg_ok=0
fi
[ "$dmg_ok" = 1 ] || xattr -lr "$MOUNT/SoundCraft.app" >&2 || true
hdiutil_retry detach detach "$MOUNT" >/dev/null || dmg_ok=0
rm -rf "$COPY"
if [ "$dmg_ok" != 1 ]; then
echo "error: SoundCraft.app in $(basename "$DMG") fails strict verification" >&2
exit 1
fi
if [ "$NOTARIZE" = 1 ]; then
notarize "$DMG"
xcrun stapler staple "$DMG"

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